diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml index 60e1707cf35..d9d810bffa7 100644 --- a/.github/workflows/auto-response.yml +++ b/.github/workflows/auto-response.yml @@ -393,6 +393,7 @@ jobs: } const invalidLabel = "invalid"; + const spamLabel = "r: spam"; const dirtyLabel = "dirty"; const noisyPrMessage = "Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch."; @@ -429,6 +430,21 @@ jobs: }); return; } + if (labelSet.has(spamLabel)) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + state: "closed", + }); + await github.rest.issues.lock({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + lock_reason: "spam", + }); + return; + } if (labelSet.has(invalidLabel)) { await github.rest.issues.update({ owner: context.repo.owner, @@ -440,6 +456,23 @@ jobs: } } + if (issue && labelSet.has(spamLabel)) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + state: "closed", + state_reason: "not_planned", + }); + await github.rest.issues.lock({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + lock_reason: "spam", + }); + return; + } + if (issue && labelSet.has(invalidLabel)) { await github.rest.issues.update({ owner: context.repo.owner, diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index 36f64d2d6ad..f18ba38a091 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -43,6 +43,8 @@ jobs: - name: Set up Docker Builder uses: useblacksmith/setup-docker-builder@v1 + # Blacksmith can fall back to the local docker driver, which rejects gha + # cache export/import. Keep smoke builds driver-agnostic. - name: Build root Dockerfile smoke image uses: useblacksmith/build-push-action@v2 with: @@ -52,8 +54,6 @@ jobs: load: true push: false provenance: false - cache-from: type=gha,scope=install-smoke-root-dockerfile - cache-to: type=gha,mode=max,scope=install-smoke-root-dockerfile - name: Run root Dockerfile CLI smoke run: | @@ -73,8 +73,6 @@ jobs: load: true push: false provenance: false - cache-from: type=gha,scope=install-smoke-root-dockerfile-ext - cache-to: type=gha,mode=max,scope=install-smoke-root-dockerfile-ext - name: Smoke test Dockerfile with extension build arg run: | @@ -89,8 +87,6 @@ jobs: load: true push: false provenance: false - cache-from: type=gha,scope=install-smoke-installer-root - cache-to: type=gha,mode=max,scope=install-smoke-installer-root - name: Build installer non-root image if: github.event_name != 'pull_request' @@ -102,8 +98,6 @@ jobs: load: true push: false provenance: false - cache-from: type=gha,scope=install-smoke-installer-nonroot - cache-to: type=gha,mode=max,scope=install-smoke-installer-nonroot - name: Run installer docker tests env: diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml new file mode 100644 index 00000000000..09126ed6ad2 --- /dev/null +++ b/.github/workflows/openclaw-npm-release.yml @@ -0,0 +1,79 @@ +name: OpenClaw NPM Release + +on: + push: + tags: + - "v*" + +concurrency: + group: openclaw-npm-release-${{ github.ref }} + cancel-in-progress: false + +env: + NODE_VERSION: "22.x" + PNPM_VERSION: "10.23.0" + +jobs: + publish_openclaw_npm: + # npm trusted publishing + provenance requires a GitHub-hosted runner. + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} + install-bun: "false" + use-sticky-disk: "false" + + - name: Validate release tag and package metadata + env: + RELEASE_SHA: ${{ github.sha }} + RELEASE_TAG: ${{ github.ref_name }} + RELEASE_MAIN_REF: origin/main + run: | + set -euo pipefail + # Fetch the full main ref so merge-base ancestry checks keep working + # for older tagged commits that are still contained in main. + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + pnpm release:openclaw:npm:check + + - name: Ensure version is not already published + run: | + set -euo pipefail + PACKAGE_VERSION=$(node -p "require('./package.json').version") + + if npm view "openclaw@${PACKAGE_VERSION}" version >/dev/null 2>&1; then + echo "openclaw@${PACKAGE_VERSION} is already published on npm." + exit 1 + fi + + echo "Publishing openclaw@${PACKAGE_VERSION}" + + - name: Check + run: pnpm check + + - name: Build + run: pnpm build + + - name: Verify release contents + run: pnpm release:check + + - name: Publish + run: | + set -euo pipefail + PACKAGE_VERSION=$(node -p "require('./package.json').version") + + if [[ "$PACKAGE_VERSION" == *-beta.* ]]; then + npm publish --access public --tag beta --provenance + else + npm publish --access public --provenance + fi diff --git a/.gitignore b/.gitignore index 0627a573c79..4defa8acb33 100644 --- a/.gitignore +++ b/.gitignore @@ -81,6 +81,7 @@ apps/ios/*.mobileprovision # Local untracked files .local/ docs/.local/ +tmp/ IDENTITY.md USER.md .tgz diff --git a/AGENTS.md b/AGENTS.md index 80443603c87..69b0df68faa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ - `r: testflight`: close requests asking for TestFlight access/builds. OpenClaw does not provide TestFlight distribution yet, so use the standard response (“Not available, build from source.”) instead of ad-hoc replies. - `r: third-party-extension`: close with guidance to ship as third-party plugin. - `r: moltbook`: close + lock as off-topic (not affiliated). +- `r: spam`: close + lock as spam (`lock_reason: spam`). - `invalid`: close invalid items (issues are closed as `not_planned`; PRs are closed). - `dirty`: close PRs with too many unrelated/unexpected changes (PR-only label). diff --git a/CHANGELOG.md b/CHANGELOG.md index 239646425ca..af544e1f6ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ Docs: https://docs.openclaw.ai - Git/runtime state: ignore the gateway-generated `.dev-state` file so local runtime state does not show up as untracked repo noise. (#41848) Thanks @smysle. - ACP/sessions_spawn: add optional `resumeSessionId` for `runtime: "acp"` so spawned ACP sessions can resume an existing ACPX/Codex conversation instead of always starting fresh. (#41847) Thanks @pejmanjohn. - Exec/child commands: mark child command environments with `OPENCLAW_CLI` so subprocesses can detect when they were launched from the OpenClaw CLI. (#41411) Thanks @vincentkoc. +- iOS/Home canvas: add a bundled welcome screen with a live agent overview that refreshes on connect, reconnect, and foreground return, and move the compact connection pill off the top-left canvas overlay. (#42456) Thanks @ngutman. +- iOS/Home canvas: replace floating controls with a docked toolbar, make the bundled home scaffold adapt to smaller phones, and open chat in the resolved main session instead of a synthetic `ios` session. (#42456) Thanks @ngutman. +- Discord/auto threads: add `autoArchiveDuration` channel config for auto-created threads so Discord thread archiving can stay at 1 hour, 1 day, 3 days, or 1 week instead of always using the 1-hour default. (#35065) Thanks @davidguttman. ### Breaking @@ -23,6 +26,8 @@ Docs: https://docs.openclaw.ai - ACP/ACPX plugin: bump the bundled `acpx` pin to `0.1.16` so plugin-local installs and strict version checks match the latest published CLI. (#41975) Thanks @dutifulbob. - macOS/LaunchAgent install: tighten LaunchAgent directory and plist permissions during install so launchd bootstrap does not fail when the target home path or generated plist inherited group/world-writable modes. - Gateway/Control UI: keep dashboard auth tokens in session-scoped browser storage so same-tab refreshes preserve remote token auth without restoring long-lived localStorage token persistence, while scoping tokens to the selected gateway URL and fragment-only bootstrap flow. (#40892) thanks @velvet-shark. +- Secret files: harden CLI and channel credential file reads against path-swap races by requiring direct regular files for `*File` secret inputs and rejecting symlink-backed secret files. +- Archive extraction: harden TAR and external `tar.bz2` installs against destination symlink and pre-existing child-symlink escapes by extracting into staging first and merging into the canonical destination with safe file opens. - Models/Kimi Coding: send `anthropic-messages` tools in native Anthropic format again so `kimi-coding` stops degrading tool calls into XML/plain-text pseudo invocations instead of real `tool_use` blocks. (#38669, #39907, #40552) Thanks @opriz. - Context engine/tests: add bundled-registry regression coverage for cross-chunk resolution, plugin-sdk re-exports, and concurrent chunk registration. (#40460) thanks @dsantoreis. - Agents/embedded runner: bound compaction retry waiting and drain embedded runs during SIGUSR1 restart so session lanes recover instead of staying blocked behind compaction. (#40324) thanks @cgdusek. @@ -65,6 +70,19 @@ Docs: https://docs.openclaw.ai - Agents/fallback cooldown probing: cap cooldown-bypass probing to one attempt per provider per fallback run so multi-model same-provider cooldown chains can continue to cross-provider fallbacks instead of repeatedly stalling on duplicate cooldown probes. (#41711) Thanks @cgdusek. - Telegram/direct delivery: bridge direct delivery sends to internal `message:sent` hooks so internal hook listeners observe successful Telegram deliveries. (#40185) Thanks @vincentkoc. - Plugins/global hook runner: harden singleton state handling so shared global hook runner reuse does not leak or corrupt runner state across executions. (#40184) Thanks @vincentkoc. +- Agents/fallback: recognize Poe `402 You've used up your points!` billing errors so configured model fallbacks trigger instead of surfacing the raw provider error. (#42278) Thanks @CryUshio. +- Telegram/outbound HTML sends: chunk long HTML-mode messages, preserve plain-text fallback and silent-delivery params across retries, and cut over to plain text when HTML chunk planning cannot safely preserve the full message. (#42240) thanks @obviyus. +- Agents/embedded overload logs: include the failing model and provider in error-path console output, with lifecycle regression coverage for the rendered and sanitized `consoleMessage`. (#41236) thanks @jiarung. +- Agents/failover: treat Gemini `MALFORMED_RESPONSE` stop reasons as retryable timeouts so preview-model enum drift falls back cleanly instead of crashing the run, without also reclassifying malformed function-call errors. (#42292) Thanks @jnMetaCode. +- Discord/Telegram outbound runtime config: thread runtime-resolved config through Discord and Telegram send paths so SecretRef-based credentials stay resolved during message delivery. (#42352) Thanks @joshavant. +- Secrets/SecretRef: reject exec SecretRef traversal ids across schema, runtime, and gateway. (#42370) Thanks @joshavant. +- Telegram/docs: clarify that `channels.telegram.groups` allowlists chats while `groupAllowFrom` allowlists users inside those chats, and point invalid negative chat IDs at the right config key. (#42451) Thanks @altaywtf. +- Models/Alibaba Cloud Model Studio: wire `MODELSTUDIO_API_KEY` through shared env auth, implicit provider discovery, and shell-env fallback so onboarding works outside the wizard too. (#40634) Thanks @pomelo-nwu. +- ACP/sessions_spawn: implicitly stream `mode="run"` ACP spawns to parent only for eligible subagent orchestrator sessions (heartbeat `target: "last"` with a usable session-local route), restoring parent progress relays without thread binding. (#42404) Thanks @davidguttman. +- Sessions/reset model recompute: clear stale runtime model, context-token, and system-prompt metadata before session resets recompute the replacement session, so resets pick up current defaults and explicit overrides instead of reusing old runtime model state. (#41173) thanks @PonyX-lab. +- Browser/Browserbase 429 handling: surface stable no-retry rate-limit guidance without buffering discarded HTTP 429 response bodies from remote browser services. (#40491) thanks @mvanhorn. +- Gateway/auth: allow one trusted device-token retry on shared-token mismatch with recovery hints to prevent reconnect churn during token drift. (#42507) Thanks @joshavant. +- Channels/allowlists: remove stale matcher caching so same-array allowlist edits and wildcard replacements take effect immediately, with regression coverage for in-place mutation cases. ## 2026.3.8 @@ -131,6 +149,9 @@ Docs: https://docs.openclaw.ai - Security/system.run: bind approved `bun` and `deno run` script operands to on-disk file snapshots so post-approval script rewrites are denied before execution. - Skills/download installs: pin the validated per-skill tools root before writing downloaded archives, so rebinding the lexical tools path cannot redirect download writes outside the intended tools directory. Thanks @tdjackey. - Control UI/Debug: replace the Manual RPC free-text method field with a sorted dropdown sourced from gateway-advertised methods, and stack the form vertically for narrower layouts. (#14967) thanks @rixau. +- Auth/profile resolution: log debug details when auto-discovered auth profiles fail during provider API-key resolution, so `--debug` output surfaces the real refresh/keychain/credential-store failure instead of only the generic missing-key message. (#41271) thanks @he-yufeng. +- ACP/cancel scoping: scope `chat.abort` and shared-session ACP event routing by `runId` so one session cannot cancel or consume another session's run when they share the same gateway session key. (#41331) Thanks @pejmanjohn. +- SecretRef/models: harden custom/provider secret persistence and reuse across models.json snapshots, merge behavior, runtime headers, and secret audits. (#42554) Thanks @joshavant. ## 2026.3.7 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4b9fe8fa98f..c7808db9cf8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -86,6 +86,7 @@ Welcome to the lobster tank! 🦞 - Test locally with your OpenClaw instance - Run tests: `pnpm build && pnpm check && pnpm test` +- If you have access to Codex, run `codex review --base origin/main` locally before opening or updating your PR. Treat this as the current highest standard of AI review, even if GitHub Codex review also runs. - Ensure CI checks pass - Keep PRs focused (one thing per PR; do not mix unrelated concerns) - Describe what & why @@ -99,6 +100,8 @@ If a review bot leaves review conversations on your PR, you are expected to hand - Resolve the conversation yourself once the code or explanation fully addresses the bot's concern - Reply and leave it open only when you need maintainer or reviewer judgment - Do not leave "fixed" bot review conversations for maintainers to clean up for you +- If Codex leaves comments, address every relevant one or resolve it with a short explanation when it is not applicable to your change +- If GitHub Codex review does not trigger for some reason, run `codex review --base origin/main` locally anyway and treat that output as required review work This applies to both human-authored and AI-assisted PRs. @@ -127,6 +130,7 @@ Please include in your PR: - [ ] Note the degree of testing (untested / lightly tested / fully tested) - [ ] Include prompts or session logs if possible (super helpful!) - [ ] Confirm you understand what the code does +- [ ] If you have access to Codex, run `codex review --base origin/main` locally and address the findings before asking for review - [ ] Resolve or reply to bot review conversations after you address them AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for. If you are using an LLM coding agent, instruct it to resolve bot review conversations it has addressed instead of leaving them for maintainers. diff --git a/apps/ios/Sources/HomeToolbar.swift b/apps/ios/Sources/HomeToolbar.swift new file mode 100644 index 00000000000..924d95d7919 --- /dev/null +++ b/apps/ios/Sources/HomeToolbar.swift @@ -0,0 +1,223 @@ +import SwiftUI + +struct HomeToolbar: View { + var gateway: StatusPill.GatewayState + var voiceWakeEnabled: Bool + var activity: StatusPill.Activity? + var brighten: Bool + var talkButtonEnabled: Bool + var talkActive: Bool + var talkTint: Color + var onStatusTap: () -> Void + var onChatTap: () -> Void + var onTalkTap: () -> Void + var onSettingsTap: () -> Void + + @Environment(\.colorSchemeContrast) private var contrast + + var body: some View { + VStack(spacing: 0) { + Rectangle() + .fill(.white.opacity(self.contrast == .increased ? 0.46 : (self.brighten ? 0.18 : 0.12))) + .frame(height: self.contrast == .increased ? 1.0 : 0.6) + .allowsHitTesting(false) + + HStack(spacing: 12) { + HomeToolbarStatusButton( + gateway: self.gateway, + voiceWakeEnabled: self.voiceWakeEnabled, + activity: self.activity, + brighten: self.brighten, + onTap: self.onStatusTap) + + Spacer(minLength: 0) + + HStack(spacing: 8) { + HomeToolbarActionButton( + systemImage: "text.bubble.fill", + accessibilityLabel: "Chat", + brighten: self.brighten, + action: self.onChatTap) + + if self.talkButtonEnabled { + HomeToolbarActionButton( + systemImage: self.talkActive ? "waveform.circle.fill" : "waveform.circle", + accessibilityLabel: self.talkActive ? "Talk Mode On" : "Talk Mode Off", + brighten: self.brighten, + tint: self.talkTint, + isActive: self.talkActive, + action: self.onTalkTap) + } + + HomeToolbarActionButton( + systemImage: "gearshape.fill", + accessibilityLabel: "Settings", + brighten: self.brighten, + action: self.onSettingsTap) + } + } + .padding(.horizontal, 12) + .padding(.top, 10) + .padding(.bottom, 8) + } + .frame(maxWidth: .infinity) + .background(.ultraThinMaterial) + .overlay(alignment: .top) { + LinearGradient( + colors: [ + .white.opacity(self.brighten ? 0.10 : 0.06), + .clear, + ], + startPoint: .top, + endPoint: .bottom) + .allowsHitTesting(false) + } + } +} + +private struct HomeToolbarStatusButton: View { + @Environment(\.scenePhase) private var scenePhase + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.colorSchemeContrast) private var contrast + + var gateway: StatusPill.GatewayState + var voiceWakeEnabled: Bool + var activity: StatusPill.Activity? + var brighten: Bool + var onTap: () -> Void + + @State private var pulse: Bool = false + + var body: some View { + Button(action: self.onTap) { + HStack(spacing: 8) { + HStack(spacing: 6) { + Circle() + .fill(self.gateway.color) + .frame(width: 8, height: 8) + .scaleEffect( + self.gateway == .connecting && !self.reduceMotion + ? (self.pulse ? 1.15 : 0.85) + : 1.0 + ) + .opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0) + + Text(self.gateway.title) + .font(.footnote.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + } + + if let activity { + Image(systemName: activity.systemImage) + .font(.footnote.weight(.semibold)) + .foregroundStyle(activity.tint ?? .primary) + .transition(.opacity.combined(with: .move(edge: .top))) + } else { + Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash") + .font(.footnote.weight(.semibold)) + .foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color.black.opacity(self.brighten ? 0.12 : 0.18)) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder( + .white.opacity(self.contrast == .increased ? 0.46 : (self.brighten ? 0.22 : 0.16)), + lineWidth: self.contrast == .increased ? 1.0 : 0.6) + } + } + } + .buttonStyle(.plain) + .accessibilityLabel("Connection Status") + .accessibilityValue(self.accessibilityValue) + .accessibilityHint(self.gateway == .connected ? "Double tap for gateway actions" : "Double tap to open settings") + .onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion) } + .onDisappear { self.pulse = false } + .onChange(of: self.gateway) { _, newValue in + self.updatePulse(for: newValue, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion) + } + .onChange(of: self.scenePhase) { _, newValue in + self.updatePulse(for: self.gateway, scenePhase: newValue, reduceMotion: self.reduceMotion) + } + .onChange(of: self.reduceMotion) { _, newValue in + self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: newValue) + } + .animation(.easeInOut(duration: 0.18), value: self.activity?.title) + } + + private var accessibilityValue: String { + if let activity { + return "\(self.gateway.title), \(activity.title)" + } + return "\(self.gateway.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")" + } + + private func updatePulse(for gateway: StatusPill.GatewayState, scenePhase: ScenePhase, reduceMotion: Bool) { + guard gateway == .connecting, scenePhase == .active, !reduceMotion else { + withAnimation(reduceMotion ? .none : .easeOut(duration: 0.2)) { self.pulse = false } + return + } + + guard !self.pulse else { return } + withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { + self.pulse = true + } + } +} + +private struct HomeToolbarActionButton: View { + @Environment(\.colorSchemeContrast) private var contrast + + let systemImage: String + let accessibilityLabel: String + let brighten: Bool + var tint: Color? + var isActive: Bool = false + let action: () -> Void + + var body: some View { + Button(action: self.action) { + Image(systemName: self.systemImage) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(self.isActive ? (self.tint ?? .primary) : .primary) + .frame(width: 40, height: 40) + .background { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.black.opacity(self.brighten ? 0.12 : 0.18)) + .overlay { + if let tint { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill( + LinearGradient( + colors: [ + tint.opacity(self.isActive ? 0.22 : 0.14), + tint.opacity(self.isActive ? 0.08 : 0.04), + .clear, + ], + startPoint: .topLeading, + endPoint: .bottomTrailing)) + .blendMode(.overlay) + } + } + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder( + (self.tint ?? .white).opacity( + self.isActive + ? 0.34 + : (self.contrast == .increased ? 0.4 : (self.brighten ? 0.22 : 0.16)) + ), + lineWidth: self.contrast == .increased ? 1.0 : (self.isActive ? 0.8 : 0.6)) + } + } + } + .buttonStyle(.plain) + .accessibilityLabel(self.accessibilityLabel) + } +} diff --git a/apps/ios/Sources/Model/NodeAppModel+Canvas.swift b/apps/ios/Sources/Model/NodeAppModel+Canvas.swift index 73e13fa0992..028983d1a5b 100644 --- a/apps/ios/Sources/Model/NodeAppModel+Canvas.swift +++ b/apps/ios/Sources/Model/NodeAppModel+Canvas.swift @@ -34,18 +34,11 @@ extension NodeAppModel { } func showA2UIOnConnectIfNeeded() async { - let current = self.screen.urlString.trimmingCharacters(in: .whitespacesAndNewlines) - if current.isEmpty || current == self.lastAutoA2uiURL { - if let canvasUrl = await self.resolveCanvasHostURLWithCapabilityRefresh(), - let url = URL(string: canvasUrl), - await Self.probeTCP(url: url, timeoutSeconds: 2.5) - { - self.screen.navigate(to: canvasUrl) - self.lastAutoA2uiURL = canvasUrl - } else { - self.lastAutoA2uiURL = nil - self.screen.showDefaultCanvas() - } + await MainActor.run { + // Keep the bundled home canvas as the default connected view. + // Agents can still explicitly present a remote or local canvas later. + self.lastAutoA2uiURL = nil + self.screen.showDefaultCanvas() } } diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 4b9483e7662..babb6b449da 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -88,6 +88,7 @@ final class NodeAppModel { var selectedAgentId: String? var gatewayDefaultAgentId: String? var gatewayAgents: [AgentSummary] = [] + var homeCanvasRevision: Int = 0 var lastShareEventText: String = "No share events yet." var openChatRequestID: Int = 0 private(set) var pendingAgentDeepLinkPrompt: AgentDeepLinkPrompt? @@ -548,6 +549,7 @@ final class NodeAppModel { self.seamColorHex = raw.isEmpty ? nil : raw self.mainSessionBaseKey = mainKey self.talkMode.updateMainSessionKey(self.mainSessionKey) + self.homeCanvasRevision &+= 1 } } catch { if let gatewayError = error as? GatewayResponseError { @@ -574,12 +576,19 @@ final class NodeAppModel { self.selectedAgentId = nil } self.talkMode.updateMainSessionKey(self.mainSessionKey) + self.homeCanvasRevision &+= 1 } } catch { // Best-effort only. } } + func refreshGatewayOverviewIfConnected() async { + guard await self.isOperatorConnected() else { return } + await self.refreshBrandingFromGateway() + await self.refreshAgentsFromGateway() + } + func setSelectedAgentId(_ agentId: String?) { let trimmed = (agentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let stableID = (self.connectedGatewayID ?? "").trimmingCharacters(in: .whitespacesAndNewlines) @@ -590,6 +599,7 @@ final class NodeAppModel { GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: stableID, agentId: self.selectedAgentId) } self.talkMode.updateMainSessionKey(self.mainSessionKey) + self.homeCanvasRevision &+= 1 if let relay = ShareGatewayRelaySettings.loadConfig() { ShareGatewayRelaySettings.saveConfig( ShareGatewayRelayConfig( @@ -1629,11 +1639,9 @@ extension NodeAppModel { } var chatSessionKey: String { - let base = "ios" - let agentId = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let defaultId = (self.gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if agentId.isEmpty || (!defaultId.isEmpty && agentId == defaultId) { return base } - return SessionKey.makeAgentSessionKey(agentId: agentId, baseKey: base) + // Keep chat aligned with the gateway's resolved main session key. + // A hardcoded "ios" base creates synthetic placeholder sessions in the chat UI. + self.mainSessionKey } var activeAgentName: String { @@ -1749,6 +1757,7 @@ private extension NodeAppModel { self.gatewayDefaultAgentId = nil self.gatewayAgents = [] self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: stableID) + self.homeCanvasRevision &+= 1 self.apnsLastRegisteredTokenHex = nil } diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift index 8a97b20e0c7..4cefeb77e74 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift @@ -536,7 +536,7 @@ struct OnboardingWizardView: View { Text( "Approve this device on the gateway.\n" + "1) `openclaw devices approve` (or `openclaw devices approve `)\n" - + "2) `/pair approve` in Telegram\n" + + "2) `/pair approve` in your OpenClaw chat\n" + "\(requestLine)\n" + "OpenClaw will also retry automatically when you return to this app.") } diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index 1eb8459a642..3a078f271c4 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -1,5 +1,6 @@ import SwiftUI import UIKit +import OpenClawProtocol struct RootCanvas: View { @Environment(NodeAppModel.self) private var appModel @@ -137,16 +138,33 @@ struct RootCanvas: View { .environment(self.gatewayController) } .onAppear { self.updateIdleTimer() } + .onAppear { self.updateHomeCanvasState() } .onAppear { self.evaluateOnboardingPresentation(force: false) } .onAppear { self.maybeAutoOpenSettings() } .onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() } - .onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() } + .onChange(of: self.scenePhase) { _, newValue in + self.updateIdleTimer() + self.updateHomeCanvasState() + guard newValue == .active else { return } + Task { + await self.appModel.refreshGatewayOverviewIfConnected() + await MainActor.run { + self.updateHomeCanvasState() + } + } + } .onAppear { self.maybeShowQuickSetup() } .onChange(of: self.gatewayController.gateways.count) { _, _ in self.maybeShowQuickSetup() } .onAppear { self.updateCanvasDebugStatus() } .onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() } - .onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasDebugStatus() } - .onChange(of: self.appModel.gatewayServerName) { _, _ in self.updateCanvasDebugStatus() } + .onChange(of: self.appModel.gatewayStatusText) { _, _ in + self.updateCanvasDebugStatus() + self.updateHomeCanvasState() + } + .onChange(of: self.appModel.gatewayServerName) { _, _ in + self.updateCanvasDebugStatus() + self.updateHomeCanvasState() + } .onChange(of: self.appModel.gatewayServerName) { _, newValue in if newValue != nil { self.showOnboarding = false @@ -155,7 +173,13 @@ struct RootCanvas: View { .onChange(of: self.onboardingRequestID) { _, _ in self.evaluateOnboardingPresentation(force: true) } - .onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in self.updateCanvasDebugStatus() } + .onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in + self.updateCanvasDebugStatus() + self.updateHomeCanvasState() + } + .onChange(of: self.appModel.homeCanvasRevision) { _, _ in + self.updateHomeCanvasState() + } .onChange(of: self.appModel.gatewayServerName) { _, newValue in if newValue != nil { self.onboardingComplete = true @@ -209,6 +233,134 @@ struct RootCanvas: View { self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle) } + private func updateHomeCanvasState() { + let payload = self.makeHomeCanvasPayload() + guard let data = try? JSONEncoder().encode(payload), + let json = String(data: data, encoding: .utf8) + else { + self.appModel.screen.updateHomeCanvasState(json: nil) + return + } + self.appModel.screen.updateHomeCanvasState(json: json) + } + + private func makeHomeCanvasPayload() -> HomeCanvasPayload { + let gatewayName = self.normalized(self.appModel.gatewayServerName) + let gatewayAddress = self.normalized(self.appModel.gatewayRemoteAddress) + let gatewayLabel = gatewayName ?? gatewayAddress ?? "Gateway" + let activeAgentID = self.resolveActiveAgentID() + let agents = self.homeCanvasAgents(activeAgentID: activeAgentID) + + switch self.gatewayStatus { + case .connected: + return HomeCanvasPayload( + gatewayState: "connected", + eyebrow: "Connected to \(gatewayLabel)", + title: "Your agents are ready", + subtitle: + "This phone stays dormant until the gateway needs it, then wakes, syncs, and goes back to sleep.", + gatewayLabel: gatewayLabel, + activeAgentName: self.appModel.activeAgentName, + activeAgentBadge: agents.first(where: { $0.isActive })?.badge ?? "OC", + activeAgentCaption: "Selected on this phone", + agentCount: agents.count, + agents: Array(agents.prefix(6)), + footer: "The overview refreshes on reconnect and when the app returns to foreground.") + case .connecting: + return HomeCanvasPayload( + gatewayState: "connecting", + eyebrow: "Reconnecting", + title: "OpenClaw is syncing back up", + subtitle: + "The gateway session is coming back online. " + + "Agent shortcuts should settle automatically in a moment.", + gatewayLabel: gatewayLabel, + activeAgentName: self.appModel.activeAgentName, + activeAgentBadge: "OC", + activeAgentCaption: "Gateway session in progress", + agentCount: agents.count, + agents: Array(agents.prefix(4)), + footer: "If the gateway is reachable, reconnect should complete without intervention.") + case .error, .disconnected: + return HomeCanvasPayload( + gatewayState: self.gatewayStatus == .error ? "error" : "offline", + eyebrow: "Welcome to OpenClaw", + title: "Your phone stays quiet until it is needed", + subtitle: + "Pair this device to your gateway to wake it only for real work, " + + "keep a live agent overview handy, and avoid battery-draining background loops.", + gatewayLabel: gatewayLabel, + activeAgentName: "Main", + activeAgentBadge: "OC", + activeAgentCaption: "Connect to load your agents", + agentCount: agents.count, + agents: Array(agents.prefix(4)), + footer: + "When connected, the gateway can wake the phone with a silent push " + + "instead of holding an always-on session.") + } + } + + private func resolveActiveAgentID() -> String { + let selected = self.normalized(self.appModel.selectedAgentId) ?? "" + if !selected.isEmpty { + return selected + } + return self.resolveDefaultAgentID() + } + + private func resolveDefaultAgentID() -> String { + self.normalized(self.appModel.gatewayDefaultAgentId) ?? "" + } + + private func homeCanvasAgents(activeAgentID: String) -> [HomeCanvasAgentCard] { + let defaultAgentID = self.resolveDefaultAgentID() + let cards = self.appModel.gatewayAgents.map { agent -> HomeCanvasAgentCard in + let isActive = !activeAgentID.isEmpty && agent.id == activeAgentID + let isDefault = !defaultAgentID.isEmpty && agent.id == defaultAgentID + return HomeCanvasAgentCard( + id: agent.id, + name: self.homeCanvasName(for: agent), + badge: self.homeCanvasBadge(for: agent), + caption: isActive ? "Active on this phone" : (isDefault ? "Default agent" : "Ready"), + isActive: isActive) + } + + return cards.sorted { lhs, rhs in + if lhs.isActive != rhs.isActive { + return lhs.isActive + } + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + } + + private func homeCanvasName(for agent: AgentSummary) -> String { + self.normalized(agent.name) ?? agent.id + } + + private func homeCanvasBadge(for agent: AgentSummary) -> String { + if let identity = agent.identity, + let emoji = identity["emoji"]?.value as? String, + let normalizedEmoji = self.normalized(emoji) + { + return normalizedEmoji + } + let words = self.homeCanvasName(for: agent) + .split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" }) + .prefix(2) + let initials = words.compactMap { $0.first }.map(String.init).joined() + if !initials.isEmpty { + return initials.uppercased() + } + return "OC" + } + + private func normalized(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + private func evaluateOnboardingPresentation(force: Bool) { if force { self.onboardingAllowSkip = true @@ -274,6 +426,28 @@ struct RootCanvas: View { } } +private struct HomeCanvasPayload: Codable { + var gatewayState: String + var eyebrow: String + var title: String + var subtitle: String + var gatewayLabel: String + var activeAgentName: String + var activeAgentBadge: String + var activeAgentCaption: String + var agentCount: Int + var agents: [HomeCanvasAgentCard] + var footer: String +} + +private struct HomeCanvasAgentCard: Codable { + var id: String + var name: String + var badge: String + var caption: String + var isActive: Bool +} + private struct CanvasContent: View { @Environment(NodeAppModel.self) private var appModel @AppStorage("talk.enabled") private var talkEnabled: Bool = false @@ -301,53 +475,33 @@ private struct CanvasContent: View { .transition(.opacity) } } - .overlay(alignment: .topLeading) { - HStack(alignment: .top, spacing: 8) { - StatusPill( - gateway: self.gatewayStatus, - voiceWakeEnabled: self.voiceWakeEnabled, - activity: self.statusActivity, - brighten: self.brightenButtons, - onTap: { - if self.gatewayStatus == .connected { - self.showGatewayActions = true - } else { - self.openSettings() - } - }) - .layoutPriority(1) - - Spacer(minLength: 8) - - HStack(spacing: 8) { - OverlayButton(systemImage: "text.bubble.fill", brighten: self.brightenButtons) { - self.openChat() - } - .accessibilityLabel("Chat") - - if self.talkButtonEnabled { - // Keep Talk mode near status controls while freeing right-side screen real estate. - OverlayButton( - systemImage: self.talkActive ? "waveform.circle.fill" : "waveform.circle", - brighten: self.brightenButtons, - tint: self.appModel.seamColor, - isActive: self.talkActive) - { - let next = !self.talkActive - self.talkEnabled = next - self.appModel.setTalkEnabled(next) - } - .accessibilityLabel("Talk Mode") - } - - OverlayButton(systemImage: "gearshape.fill", brighten: self.brightenButtons) { + .safeAreaInset(edge: .bottom, spacing: 0) { + HomeToolbar( + gateway: self.gatewayStatus, + voiceWakeEnabled: self.voiceWakeEnabled, + activity: self.statusActivity, + brighten: self.brightenButtons, + talkButtonEnabled: self.talkButtonEnabled, + talkActive: self.talkActive, + talkTint: self.appModel.seamColor, + onStatusTap: { + if self.gatewayStatus == .connected { + self.showGatewayActions = true + } else { self.openSettings() } - .accessibilityLabel("Settings") - } - } - .padding(.horizontal, 10) - .safeAreaPadding(.top, 10) + }, + onChatTap: { + self.openChat() + }, + onTalkTap: { + let next = !self.talkActive + self.talkEnabled = next + self.appModel.setTalkEnabled(next) + }, + onSettingsTap: { + self.openSettings() + }) } .overlay(alignment: .topLeading) { if let voiceWakeToastText, !voiceWakeToastText.isEmpty { @@ -380,63 +534,6 @@ private struct CanvasContent: View { } } -private struct OverlayButton: View { - let systemImage: String - let brighten: Bool - var tint: Color? - var isActive: Bool = false - let action: () -> Void - - var body: some View { - Button(action: self.action) { - Image(systemName: self.systemImage) - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(self.isActive ? (self.tint ?? .primary) : .primary) - .padding(10) - .background { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(.ultraThinMaterial) - .overlay { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill( - LinearGradient( - colors: [ - .white.opacity(self.brighten ? 0.26 : 0.18), - .white.opacity(self.brighten ? 0.08 : 0.04), - .clear, - ], - startPoint: .topLeading, - endPoint: .bottomTrailing)) - .blendMode(.overlay) - } - .overlay { - if let tint { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill( - LinearGradient( - colors: [ - tint.opacity(self.isActive ? 0.22 : 0.14), - tint.opacity(self.isActive ? 0.10 : 0.06), - .clear, - ], - startPoint: .topLeading, - endPoint: .bottomTrailing)) - .blendMode(.overlay) - } - } - .overlay { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder( - (self.tint ?? .white).opacity(self.isActive ? 0.34 : (self.brighten ? 0.24 : 0.18)), - lineWidth: self.isActive ? 0.7 : 0.5) - } - .shadow(color: .black.opacity(0.35), radius: 12, y: 6) - } - } - .buttonStyle(.plain) - } -} - private struct CameraFlashOverlay: View { var nonce: Int diff --git a/apps/ios/Sources/Screen/ScreenController.swift b/apps/ios/Sources/Screen/ScreenController.swift index 5c945033551..4c9f3ff5085 100644 --- a/apps/ios/Sources/Screen/ScreenController.swift +++ b/apps/ios/Sources/Screen/ScreenController.swift @@ -20,6 +20,7 @@ final class ScreenController { private var debugStatusEnabled: Bool = false private var debugStatusTitle: String? private var debugStatusSubtitle: String? + private var homeCanvasStateJSON: String? init() { self.reload() @@ -94,6 +95,26 @@ final class ScreenController { subtitle: self.debugStatusSubtitle) } + func updateHomeCanvasState(json: String?) { + self.homeCanvasStateJSON = json + self.applyHomeCanvasStateIfNeeded() + } + + func applyHomeCanvasStateIfNeeded() { + guard let webView = self.activeWebView else { return } + let payload = self.homeCanvasStateJSON ?? "null" + let js = """ + (() => { + try { + const api = globalThis.__openclaw; + if (!api || typeof api.renderHome !== 'function') return; + api.renderHome(\(payload)); + } catch (_) {} + })() + """ + webView.evaluateJavaScript(js) { _, _ in } + } + func waitForA2UIReady(timeoutMs: Int) async -> Bool { let clock = ContinuousClock() let deadline = clock.now.advanced(by: .milliseconds(timeoutMs)) @@ -191,6 +212,7 @@ final class ScreenController { self.activeWebView = webView self.reload() self.applyDebugStatusIfNeeded() + self.applyHomeCanvasStateIfNeeded() } func detachWebView(_ webView: WKWebView) { diff --git a/apps/ios/Sources/Screen/ScreenTab.swift b/apps/ios/Sources/Screen/ScreenTab.swift index 16b5f857496..deabd38331d 100644 --- a/apps/ios/Sources/Screen/ScreenTab.swift +++ b/apps/ios/Sources/Screen/ScreenTab.swift @@ -7,7 +7,7 @@ struct ScreenTab: View { var body: some View { ZStack(alignment: .top) { ScreenWebView(controller: self.appModel.screen) - .ignoresSafeArea() + .ignoresSafeArea(.container, edges: [.top, .leading, .trailing]) .overlay(alignment: .top) { if let errorText = self.appModel.screen.errorText, self.appModel.gatewayServerName == nil diff --git a/apps/ios/Sources/Screen/ScreenWebView.swift b/apps/ios/Sources/Screen/ScreenWebView.swift index a30d78cbd00..61f9af6515c 100644 --- a/apps/ios/Sources/Screen/ScreenWebView.swift +++ b/apps/ios/Sources/Screen/ScreenWebView.swift @@ -161,6 +161,7 @@ private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate { func webView(_: WKWebView, didFinish _: WKNavigation?) { self.controller?.errorText = nil self.controller?.applyDebugStatusIfNeeded() + self.controller?.applyHomeCanvasStateIfNeeded() } func webView(_: WKWebView, didFail _: WKNavigation?, withError error: any Error) { diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 7186c7205b5..7aa79fa24ca 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -65,10 +65,10 @@ struct SettingsTab: View { DisclosureGroup(isExpanded: self.$gatewayExpanded) { if !self.isGatewayConnected { Text( - "1. Open Telegram and message your bot: /pair\n" + "1. Open a chat with your OpenClaw agent and send /pair\n" + "2. Copy the setup code it returns\n" + "3. Paste here and tap Connect\n" - + "4. Back in Telegram, run /pair approve") + + "4. Back in that chat, run /pair approve") .font(.footnote) .foregroundStyle(.secondary) @@ -340,9 +340,9 @@ struct SettingsTab: View { .foregroundStyle(.secondary) } self.featureToggle( - "Show Talk Button", + "Show Talk Control", isOn: self.$talkButtonEnabled, - help: "Shows the floating Talk button in the main interface.") + help: "Shows the Talk control in the main toolbar.") TextField("Default Share Instruction", text: self.$defaultShareInstruction, axis: .vertical) .lineLimit(2 ... 6) .textInputAutocapitalization(.sentences) @@ -896,7 +896,7 @@ struct SettingsTab: View { guard !trimmed.isEmpty else { return nil } let lower = trimmed.lowercased() if lower.contains("pairing required") { - return "Pairing required. Go back to Telegram and run /pair approve, then tap Connect again." + return "Pairing required. Go back to your OpenClaw chat and run /pair approve, then tap Connect again." } if lower.contains("device nonce required") || lower.contains("device nonce mismatch") { return "Secure handshake failed. Make sure Tailscale is connected, then tap Connect again." diff --git a/apps/ios/Sources/Status/StatusPill.swift b/apps/ios/Sources/Status/StatusPill.swift index a723ce5eb39..d6f94185b40 100644 --- a/apps/ios/Sources/Status/StatusPill.swift +++ b/apps/ios/Sources/Status/StatusPill.swift @@ -38,6 +38,7 @@ struct StatusPill: View { var gateway: GatewayState var voiceWakeEnabled: Bool var activity: Activity? + var compact: Bool = false var brighten: Bool = false var onTap: () -> Void @@ -45,11 +46,11 @@ struct StatusPill: View { var body: some View { Button(action: self.onTap) { - HStack(spacing: 10) { - HStack(spacing: 8) { + HStack(spacing: self.compact ? 8 : 10) { + HStack(spacing: self.compact ? 6 : 8) { Circle() .fill(self.gateway.color) - .frame(width: 9, height: 9) + .frame(width: self.compact ? 8 : 9, height: self.compact ? 8 : 9) .scaleEffect( self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.15 : 0.85) @@ -58,34 +59,38 @@ struct StatusPill: View { .opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0) Text(self.gateway.title) - .font(.subheadline.weight(.semibold)) + .font((self.compact ? Font.footnote : Font.subheadline).weight(.semibold)) .foregroundStyle(.primary) } - Divider() - .frame(height: 14) - .opacity(0.35) - if let activity { - HStack(spacing: 6) { + if !self.compact { + Divider() + .frame(height: 14) + .opacity(0.35) + } + + HStack(spacing: self.compact ? 4 : 6) { Image(systemName: activity.systemImage) - .font(.subheadline.weight(.semibold)) + .font((self.compact ? Font.footnote : Font.subheadline).weight(.semibold)) .foregroundStyle(activity.tint ?? .primary) - Text(activity.title) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.primary) - .lineLimit(1) + if !self.compact { + Text(activity.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + } } .transition(.opacity.combined(with: .move(edge: .top))) } else { Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash") - .font(.subheadline.weight(.semibold)) + .font((self.compact ? Font.footnote : Font.subheadline).weight(.semibold)) .foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary) .accessibilityLabel(self.voiceWakeEnabled ? "Voice Wake enabled" : "Voice Wake disabled") .transition(.opacity.combined(with: .move(edge: .top))) } } - .statusGlassCard(brighten: self.brighten, verticalPadding: 8) + .statusGlassCard(brighten: self.brighten, verticalPadding: self.compact ? 6 : 8) } .buttonStyle(.plain) .accessibilityLabel("Connection Status") diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index 7413b0295f9..d2ec7039ad7 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -83,16 +83,16 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer #expect(json.contains("\"value\"")) } - @Test @MainActor func chatSessionKeyDefaultsToIOSBase() { + @Test @MainActor func chatSessionKeyDefaultsToMainBase() { let appModel = NodeAppModel() - #expect(appModel.chatSessionKey == "ios") + #expect(appModel.chatSessionKey == "main") } @Test @MainActor func chatSessionKeyUsesAgentScopedKeyForNonDefaultAgent() { let appModel = NodeAppModel() appModel.gatewayDefaultAgentId = "main" appModel.setSelectedAgentId("agent-123") - #expect(appModel.chatSessionKey == SessionKey.makeAgentSessionKey(agentId: "agent-123", baseKey: "ios")) + #expect(appModel.chatSessionKey == SessionKey.makeAgentSessionKey(agentId: "agent-123", baseKey: "main")) #expect(appModel.mainSessionKey == "agent:agent-123:main") } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index 3dc5eacee6e..f822e32044e 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -131,6 +131,41 @@ private let defaultOperatorConnectScopes: [String] = [ "operator.pairing", ] +private enum GatewayConnectErrorCodes { + static let authTokenMismatch = "AUTH_TOKEN_MISMATCH" + static let authDeviceTokenMismatch = "AUTH_DEVICE_TOKEN_MISMATCH" + static let authTokenMissing = "AUTH_TOKEN_MISSING" + static let authPasswordMissing = "AUTH_PASSWORD_MISSING" + static let authPasswordMismatch = "AUTH_PASSWORD_MISMATCH" + static let authRateLimited = "AUTH_RATE_LIMITED" + static let pairingRequired = "PAIRING_REQUIRED" + static let controlUiDeviceIdentityRequired = "CONTROL_UI_DEVICE_IDENTITY_REQUIRED" + static let deviceIdentityRequired = "DEVICE_IDENTITY_REQUIRED" +} + +private struct GatewayConnectAuthError: LocalizedError { + let message: String + let detailCode: String? + let canRetryWithDeviceToken: Bool + + var errorDescription: String? { self.message } + + var isNonRecoverable: Bool { + switch self.detailCode { + case GatewayConnectErrorCodes.authTokenMissing, + GatewayConnectErrorCodes.authPasswordMissing, + GatewayConnectErrorCodes.authPasswordMismatch, + GatewayConnectErrorCodes.authRateLimited, + GatewayConnectErrorCodes.pairingRequired, + GatewayConnectErrorCodes.controlUiDeviceIdentityRequired, + GatewayConnectErrorCodes.deviceIdentityRequired: + return true + default: + return false + } + } +} + public actor GatewayChannelActor { private let logger = Logger(subsystem: "ai.openclaw", category: "gateway") private var task: WebSocketTaskBox? @@ -160,6 +195,9 @@ public actor GatewayChannelActor { private var watchdogTask: Task? private var tickTask: Task? private var keepaliveTask: Task? + private var pendingDeviceTokenRetry = false + private var deviceTokenRetryBudgetUsed = false + private var reconnectPausedForAuthFailure = false private let defaultRequestTimeoutMs: Double = 15000 private let pushHandler: (@Sendable (GatewayPush) async -> Void)? private let connectOptions: GatewayConnectOptions? @@ -232,10 +270,19 @@ public actor GatewayChannelActor { while self.shouldReconnect { guard await self.sleepUnlessCancelled(nanoseconds: 30 * 1_000_000_000) else { return } // 30s cadence guard self.shouldReconnect else { return } + if self.reconnectPausedForAuthFailure { continue } if self.connected { continue } do { try await self.connect() } catch { + if self.shouldPauseReconnectAfterAuthFailure(error) { + self.reconnectPausedForAuthFailure = true + self.logger.error( + "gateway watchdog reconnect paused for non-recoverable auth failure " + + "\(error.localizedDescription, privacy: .public)" + ) + continue + } let wrapped = self.wrap(error, context: "gateway watchdog reconnect") self.logger.error("gateway watchdog reconnect failed \(wrapped.localizedDescription, privacy: .public)") } @@ -267,7 +314,12 @@ public actor GatewayChannelActor { }, operation: { try await self.sendConnect() }) } catch { - let wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)") + let wrapped: Error + if let authError = error as? GatewayConnectAuthError { + wrapped = authError + } else { + wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)") + } self.connected = false self.task?.cancel(with: .goingAway, reason: nil) await self.disconnectHandler?("connect failed: \(wrapped.localizedDescription)") @@ -281,6 +333,7 @@ public actor GatewayChannelActor { } self.listen() self.connected = true + self.reconnectPausedForAuthFailure = false self.backoffMs = 500 self.lastSeq = nil self.startKeepalive() @@ -371,11 +424,18 @@ public actor GatewayChannelActor { (includeDeviceIdentity && identity != nil) ? DeviceAuthStore.loadToken(deviceId: identity!.deviceId, role: role)?.token : nil - // If we're not sending a device identity, a device token can't be validated server-side. - // In that mode we always use the shared gateway token/password. - let authToken = includeDeviceIdentity ? (storedToken ?? self.token) : self.token + let shouldUseDeviceRetryToken = + includeDeviceIdentity && self.pendingDeviceTokenRetry && + storedToken != nil && self.token != nil && self.isTrustedDeviceRetryEndpoint() + if shouldUseDeviceRetryToken { + self.pendingDeviceTokenRetry = false + } + // Keep shared credentials explicit when provided. Device token retry is attached + // only on a bounded second attempt after token mismatch. + let authToken = self.token ?? (includeDeviceIdentity ? storedToken : nil) + let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil let authSource: GatewayAuthSource - if storedToken != nil { + if authDeviceToken != nil || (self.token == nil && storedToken != nil) { authSource = .deviceToken } else if authToken != nil { authSource = .sharedToken @@ -386,9 +446,12 @@ public actor GatewayChannelActor { } self.lastAuthSource = authSource self.logger.info("gateway connect auth=\(authSource.rawValue, privacy: .public)") - let canFallbackToShared = includeDeviceIdentity && storedToken != nil && self.token != nil if let authToken { - params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)]) + var auth: [String: ProtoAnyCodable] = ["token": ProtoAnyCodable(authToken)] + if let authDeviceToken { + auth["deviceToken"] = ProtoAnyCodable(authDeviceToken) + } + params["auth"] = ProtoAnyCodable(auth) } else if let password = self.password { params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)]) } @@ -426,11 +489,24 @@ public actor GatewayChannelActor { do { let response = try await self.waitForConnectResponse(reqId: reqId) try await self.handleConnectResponse(response, identity: identity, role: role) + self.pendingDeviceTokenRetry = false + self.deviceTokenRetryBudgetUsed = false } catch { - if canFallbackToShared { - if let identity { - DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role) - } + let shouldRetryWithDeviceToken = self.shouldRetryWithStoredDeviceToken( + error: error, + explicitGatewayToken: self.token, + storedToken: storedToken, + attemptedDeviceTokenRetry: authDeviceToken != nil) + if shouldRetryWithDeviceToken { + self.pendingDeviceTokenRetry = true + self.deviceTokenRetryBudgetUsed = true + self.backoffMs = min(self.backoffMs, 250) + } else if authDeviceToken != nil, + let identity, + self.shouldClearStoredDeviceTokenAfterRetry(error) + { + // Retry failed with an explicit device-token mismatch; clear stale local token. + DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role) } throw error } @@ -443,7 +519,13 @@ public actor GatewayChannelActor { ) async throws { if res.ok == false { let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed" - throw NSError(domain: "Gateway", code: 1008, userInfo: [NSLocalizedDescriptionKey: msg]) + let details = res.error?["details"]?.value as? [String: ProtoAnyCodable] + let detailCode = details?["code"]?.value as? String + let canRetryWithDeviceToken = details?["canRetryWithDeviceToken"]?.value as? Bool ?? false + throw GatewayConnectAuthError( + message: msg, + detailCode: detailCode, + canRetryWithDeviceToken: canRetryWithDeviceToken) } guard let payload = res.payload else { throw NSError( @@ -616,19 +698,91 @@ public actor GatewayChannelActor { private func scheduleReconnect() async { guard self.shouldReconnect else { return } + guard !self.reconnectPausedForAuthFailure else { return } let delay = self.backoffMs / 1000 self.backoffMs = min(self.backoffMs * 2, 30000) guard await self.sleepUnlessCancelled(nanoseconds: UInt64(delay * 1_000_000_000)) else { return } guard self.shouldReconnect else { return } + guard !self.reconnectPausedForAuthFailure else { return } do { try await self.connect() } catch { + if self.shouldPauseReconnectAfterAuthFailure(error) { + self.reconnectPausedForAuthFailure = true + self.logger.error( + "gateway reconnect paused for non-recoverable auth failure " + + "\(error.localizedDescription, privacy: .public)" + ) + return + } let wrapped = self.wrap(error, context: "gateway reconnect") self.logger.error("gateway reconnect failed \(wrapped.localizedDescription, privacy: .public)") await self.scheduleReconnect() } } + private func shouldRetryWithStoredDeviceToken( + error: Error, + explicitGatewayToken: String?, + storedToken: String?, + attemptedDeviceTokenRetry: Bool + ) -> Bool { + if self.deviceTokenRetryBudgetUsed { + return false + } + if attemptedDeviceTokenRetry { + return false + } + guard explicitGatewayToken != nil, storedToken != nil else { + return false + } + guard self.isTrustedDeviceRetryEndpoint() else { + return false + } + guard let authError = error as? GatewayConnectAuthError else { + return false + } + return authError.canRetryWithDeviceToken || + authError.detailCode == GatewayConnectErrorCodes.authTokenMismatch + } + + private func shouldPauseReconnectAfterAuthFailure(_ error: Error) -> Bool { + guard let authError = error as? GatewayConnectAuthError else { + return false + } + if authError.isNonRecoverable { + return true + } + if authError.detailCode == GatewayConnectErrorCodes.authTokenMismatch && + self.deviceTokenRetryBudgetUsed && !self.pendingDeviceTokenRetry + { + return true + } + return false + } + + private func shouldClearStoredDeviceTokenAfterRetry(_ error: Error) -> Bool { + guard let authError = error as? GatewayConnectAuthError else { + return false + } + return authError.detailCode == GatewayConnectErrorCodes.authDeviceTokenMismatch + } + + private func isTrustedDeviceRetryEndpoint() -> Bool { + // This client currently treats loopback as the only trusted retry target. + // Unlike the Node gateway client, it does not yet expose a pinned TLS-fingerprint + // trust path for remote retry, so remote fallback remains disabled by default. + guard let host = self.url.host?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + !host.isEmpty + else { + return false + } + if host == "localhost" || host == "::1" || host == "127.0.0.1" || host.hasPrefix("127.") { + return true + } + return false + } + private nonisolated func sleepUnlessCancelled(nanoseconds: UInt64) async -> Bool { do { try await Task.sleep(nanoseconds: nanoseconds) @@ -756,7 +910,8 @@ public actor GatewayChannelActor { return (id: id, data: data) } catch { self.logger.error( - "gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)") + "gateway \(kind) encode failed \(method, privacy: .public) " + + "error=\(error.localizedDescription, privacy: .public)") throw error } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasScaffold/scaffold.html b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasScaffold/scaffold.html index ceb7a975da4..684d5a9f148 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasScaffold/scaffold.html +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasScaffold/scaffold.html @@ -3,7 +3,7 @@ - Canvas + OpenClaw - + +
+
+
+
+ + Welcome to OpenClaw +
+

Your phone stays quiet until it is needed

+

+ Pair this device to your gateway to wake it only for real work, keep a live agent overview handy, and avoid battery-draining background loops. +

+ +
+
+
Gateway
+
Gateway
+
Connect to load your agents
+
+ +
+
Active Agent
+
+
OC
+
+
Main
+
Connect to load your agents
+
+
+
+
+
+ +
+
+
Live agents
+
0 agents
+
+
+ +
+
+
+
Ready
Waiting for agent
+ diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 994c03391ce..48a8a03f59e 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -168,6 +168,7 @@ openclaw pairing approve discord Token resolution is account-aware. Config token values win over env fallback. `DISCORD_BOT_TOKEN` is only used for the default account. +For advanced outbound calls (message tool/channel actions), an explicit per-call `token` is used for that call. Account policy/retry settings still come from the selected account in the active runtime snapshot. ## Recommended: Set up a guild workspace diff --git a/docs/channels/line.md b/docs/channels/line.md index 50972d93d21..a965dc6e991 100644 --- a/docs/channels/line.md +++ b/docs/channels/line.md @@ -87,6 +87,8 @@ Token/secret files: } ``` +`tokenFile` and `secretFile` must point to regular files. Symlinks are rejected. + Multiple accounts: ```json5 diff --git a/docs/channels/nextcloud-talk.md b/docs/channels/nextcloud-talk.md index d4ab9e2c397..7797b1276ff 100644 --- a/docs/channels/nextcloud-talk.md +++ b/docs/channels/nextcloud-talk.md @@ -115,7 +115,7 @@ Provider options: - `channels.nextcloud-talk.enabled`: enable/disable channel startup. - `channels.nextcloud-talk.baseUrl`: Nextcloud instance URL. - `channels.nextcloud-talk.botSecret`: bot shared secret. -- `channels.nextcloud-talk.botSecretFile`: secret file path. +- `channels.nextcloud-talk.botSecretFile`: regular-file secret path. Symlinks are rejected. - `channels.nextcloud-talk.apiUser`: API user for room lookups (DM detection). - `channels.nextcloud-talk.apiPassword`: API/app password for room lookups. - `channels.nextcloud-talk.apiPasswordFile`: API password file path. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index a039cb43483..f2467d12b0a 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -155,6 +155,7 @@ curl "https://api.telegram.org/bot/getUpdates" `groupAllowFrom` is used for group sender filtering. If not set, Telegram falls back to `allowFrom`. `groupAllowFrom` entries should be numeric Telegram user IDs (`telegram:` / `tg:` prefixes are normalized). + Do not put Telegram group or supergroup chat IDs in `groupAllowFrom`. Negative chat IDs belong under `channels.telegram.groups`. Non-numeric entries are ignored for sender authorization. Security boundary (`2026.2.25+`): group sender auth does **not** inherit DM pairing-store approvals. Pairing stays DM-only. For groups, set `groupAllowFrom` or per-group/per-topic `allowFrom`. @@ -177,6 +178,31 @@ curl "https://api.telegram.org/bot/getUpdates" } ``` + Example: allow only specific users inside one specific group: + +```json5 +{ + channels: { + telegram: { + groups: { + "-1001234567890": { + requireMention: true, + allowFrom: ["8734062810", "745123456"], + }, + }, + }, + }, +} +``` + + + Common mistake: `groupAllowFrom` is not a Telegram group allowlist. + + - Put negative Telegram group or supergroup chat IDs like `-1001234567890` under `channels.telegram.groups`. + - Put Telegram user IDs like `8734062810` under `groupAllowFrom` when you want to limit which people inside an allowed group can trigger the bot. + - Use `groupAllowFrom: ["*"]` only when you want any member of an allowed group to be able to talk to the bot. + + @@ -410,6 +436,7 @@ curl "https://api.telegram.org/bot/getUpdates" - `channels.telegram.actions.sticker` (default: disabled) Note: `edit` and `topic-create` are currently enabled by default and do not have separate `channels.telegram.actions.*` toggles. + Runtime sends use the active config/secrets snapshot (startup/reload), so action paths do not perform ad-hoc SecretRef re-resolution per send. Reaction removal semantics: [/tools/reactions](/tools/reactions) @@ -865,7 +892,7 @@ Primary reference: - `channels.telegram.enabled`: enable/disable channel startup. - `channels.telegram.botToken`: bot token (BotFather). -- `channels.telegram.tokenFile`: read token from file path. +- `channels.telegram.tokenFile`: read token from a regular file path. Symlinks are rejected. - `channels.telegram.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing). - `channels.telegram.allowFrom`: DM allowlist (numeric Telegram user IDs). `allowlist` requires at least one sender ID. `open` requires `"*"`. `openclaw doctor --fix` can resolve legacy `@username` entries to IDs and can recover allowlist entries from pairing-store files in allowlist migration flows. - `channels.telegram.actions.poll`: enable or disable Telegram poll creation (default: enabled; still requires `sendMessage`). @@ -926,7 +953,7 @@ Primary reference: Telegram-specific high-signal fields: -- startup/auth: `enabled`, `botToken`, `tokenFile`, `accounts.*` +- startup/auth: `enabled`, `botToken`, `tokenFile`, `accounts.*` (`tokenFile` must point to a regular file; symlinks are rejected) - access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*`, top-level `bindings[]` (`type: "acp"`) - exec approvals: `execApprovals`, `accounts.*.execApprovals` - command/menu: `commands.native`, `commands.nativeSkills`, `customCommands` diff --git a/docs/channels/zalo.md b/docs/channels/zalo.md index 8e5d8ab0382..77b288b0ab7 100644 --- a/docs/channels/zalo.md +++ b/docs/channels/zalo.md @@ -179,7 +179,7 @@ Provider options: - `channels.zalo.enabled`: enable/disable channel startup. - `channels.zalo.botToken`: bot token from Zalo Bot Platform. -- `channels.zalo.tokenFile`: read token from file path. +- `channels.zalo.tokenFile`: read token from a regular file path. Symlinks are rejected. - `channels.zalo.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing). - `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`. The wizard will ask for numeric IDs. - `channels.zalo.groupPolicy`: `open | allowlist | disabled` (default: allowlist). @@ -193,7 +193,7 @@ Provider options: Multi-account options: - `channels.zalo.accounts..botToken`: per-account token. -- `channels.zalo.accounts..tokenFile`: per-account token file. +- `channels.zalo.accounts..tokenFile`: per-account regular token file. Symlinks are rejected. - `channels.zalo.accounts..name`: display name. - `channels.zalo.accounts..enabled`: enable/disable account. - `channels.zalo.accounts..dmPolicy`: per-account DM policy. diff --git a/docs/cli/devices.md b/docs/cli/devices.md index be01e3cc0d5..f73f30dfa1d 100644 --- a/docs/cli/devices.md +++ b/docs/cli/devices.md @@ -92,3 +92,40 @@ Pass `--token` or `--password` explicitly. Missing explicit credentials is an er - These commands require `operator.pairing` (or `operator.admin`) scope. - `devices clear` is intentionally gated by `--yes`. - If pairing scope is unavailable on local loopback (and no explicit `--url` is passed), list/approve can use a local pairing fallback. + +## Token drift recovery checklist + +Use this when Control UI or other clients keep failing with `AUTH_TOKEN_MISMATCH` or `AUTH_DEVICE_TOKEN_MISMATCH`. + +1. Confirm current gateway token source: + +```bash +openclaw config get gateway.auth.token +``` + +2. List paired devices and identify the affected device id: + +```bash +openclaw devices list +``` + +3. Rotate operator token for the affected device: + +```bash +openclaw devices rotate --device --role operator +``` + +4. If rotation is not enough, remove stale pairing and approve again: + +```bash +openclaw devices remove +openclaw devices list +openclaw devices approve +``` + +5. Retry client connection with the current shared token/password. + +Related: + +- [Dashboard auth troubleshooting](/web/dashboard#if-you-see-unauthorized-1008) +- [Gateway troubleshooting](/gateway/troubleshooting#dashboard-control-ui-connectivity) diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 538b80f6138..2a27470fd36 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -203,7 +203,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat } ``` -- Bot token: `channels.telegram.botToken` or `channels.telegram.tokenFile`, with `TELEGRAM_BOT_TOKEN` as fallback for the default account. +- Bot token: `channels.telegram.botToken` or `channels.telegram.tokenFile` (regular file only; symlinks rejected), with `TELEGRAM_BOT_TOKEN` as fallback for the default account. - Optional `channels.telegram.defaultAccount` overrides default account selection when it matches a configured account id. - In multi-account setups (2+ account ids), set an explicit default (`channels.telegram.defaultAccount` or `channels.telegram.accounts.default`) to avoid fallback routing; `openclaw doctor` warns when this is missing or invalid. - `configWrites: false` blocks Telegram-initiated config writes (supergroup ID migrations, `/config set|unset`). @@ -304,6 +304,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat ``` - Token: `channels.discord.token`, with `DISCORD_BOT_TOKEN` as fallback for the default account. +- Direct outbound calls that provide an explicit Discord `token` use that token for the call; account retry/policy settings still come from the selected account in the active runtime snapshot. - Optional `channels.discord.defaultAccount` overrides default account selection when it matches a configured account id. - Use `user:` (DM) or `channel:` (guild channel) for delivery targets; bare numeric IDs are rejected. - Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged name (no `#`). Prefer guild IDs. @@ -2712,6 +2713,7 @@ Validation: - `source: "env"` id pattern: `^[A-Z][A-Z0-9_]{0,127}$` - `source: "file"` id: absolute JSON pointer (for example `"/providers/openai/apiKey"`) - `source: "exec"` id pattern: `^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$` +- `source: "exec"` ids must not contain `.` or `..` slash-delimited path segments (for example `a/../b` is rejected) ### Supported credential surface diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 62a5adb1fef..9c886a31716 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -206,6 +206,12 @@ The Gateway treats these as **claims** and enforces server-side allowlists. persisted by the client for future connects. - Device tokens can be rotated/revoked via `device.token.rotate` and `device.token.revoke` (requires `operator.pairing` scope). +- Auth failures include `error.details.code` plus recovery hints: + - `error.details.canRetryWithDeviceToken` (boolean) + - `error.details.recommendedNextStep` (`retry_with_device_token`, `update_auth_configuration`, `update_auth_credentials`, `wait_then_retry`, `review_auth_configuration`) +- Client behavior for `AUTH_TOKEN_MISMATCH`: + - Trusted clients may attempt one bounded retry with a cached per-device token. + - If that retry fails, clients should stop automatic reconnect loops and surface operator action guidance. ## Device identity + pairing @@ -217,8 +223,9 @@ The Gateway treats these as **claims** and enforces server-side allowlists. - **Local** connects include loopback and the gateway host’s own tailnet address (so same‑host tailnet binds can still auto‑approve). - All WS clients must include `device` identity during `connect` (operator + node). - Control UI can omit it **only** when `gateway.controlUi.dangerouslyDisableDeviceAuth` - is enabled for break-glass use. + Control UI can omit it only in these modes: + - `gateway.controlUi.allowInsecureAuth=true` for localhost-only insecure HTTP compatibility. + - `gateway.controlUi.dangerouslyDisableDeviceAuth=true` (break-glass, severe security downgrade). - All connections must sign the server-provided `connect.challenge` nonce. ### Device auth migration diagnostics diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index e9d75343147..76b89a0f28a 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -21,6 +21,7 @@ Secrets are resolved into an in-memory runtime snapshot. - Startup fails fast when an effectively active SecretRef cannot be resolved. - Reload uses atomic swap: full success, or keep the last-known-good snapshot. - Runtime requests read from the active in-memory snapshot only. +- Outbound delivery paths also read from that active snapshot (for example Discord reply/thread delivery and Telegram action sends); they do not re-resolve SecretRefs on each send. This keeps secret-provider outages off hot request paths. @@ -113,6 +114,7 @@ Validation: - `provider` must match `^[a-z][a-z0-9_-]{0,63}$` - `id` must match `^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$` +- `id` must not contain `.` or `..` as slash-delimited path segments (for example `a/../b` is rejected) ## Provider config @@ -321,6 +323,7 @@ Activation contract: - Success swaps the snapshot atomically. - Startup failure aborts gateway startup. - Runtime reload failure keeps the last-known-good snapshot. +- Providing an explicit per-call channel token to an outbound helper/tool call does not trigger SecretRef activation; activation points remain startup, reload, and explicit `secrets.reload`. ## Degraded and recovered signals diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index c62b77352e8..8b790f4ded6 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -199,7 +199,7 @@ If you run `--deep`, OpenClaw also attempts a best-effort live Gateway probe. Use this when auditing access or deciding what to back up: - **WhatsApp**: `~/.openclaw/credentials/whatsapp//creds.json` -- **Telegram bot token**: config/env or `channels.telegram.tokenFile` +- **Telegram bot token**: config/env or `channels.telegram.tokenFile` (regular file only; symlinks rejected) - **Discord bot token**: config/env or SecretRef (env/file/exec providers) - **Slack tokens**: config/env (`channels.slack.*`) - **Pairing allowlists**: @@ -262,9 +262,14 @@ High-signal `checkId` values you will most likely see in real deployments (not e ## Control UI over HTTP The Control UI needs a **secure context** (HTTPS or localhost) to generate device -identity. `gateway.controlUi.allowInsecureAuth` does **not** bypass secure-context, -device-identity, or device-pairing checks. Prefer HTTPS (Tailscale Serve) or open -the UI on `127.0.0.1`. +identity. `gateway.controlUi.allowInsecureAuth` is a local compatibility toggle: + +- On localhost, it allows Control UI auth without device identity when the page + is loaded over non-secure HTTP. +- It does not bypass pairing checks. +- It does not relax remote (non-localhost) device identity requirements. + +Prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`. For break-glass scenarios only, `gateway.controlUi.dangerouslyDisableDeviceAuth` disables device identity checks entirely. This is a severe security downgrade; diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index 46d2c58b966..ebea28a6541 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -113,9 +113,21 @@ Common signatures: challenge-based device auth flow (`connect.challenge` + `device.nonce`). - `device signature invalid` / `device signature expired` → client signed the wrong payload (or stale timestamp) for the current handshake. -- `unauthorized` / reconnect loop → token/password mismatch. +- `AUTH_TOKEN_MISMATCH` with `canRetryWithDeviceToken=true` → client can do one trusted retry with cached device token. +- repeated `unauthorized` after that retry → shared token/device token drift; refresh token config and re-approve/rotate device token if needed. - `gateway connect failed:` → wrong host/port/url target. +### Auth detail codes quick map + +Use `error.details.code` from the failed `connect` response to pick the next action: + +| Detail code | Meaning | Recommended action | +| ---------------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `AUTH_TOKEN_MISSING` | Client did not send a required shared token. | Paste/set token in the client and retry. For dashboard paths: `openclaw config get gateway.auth.token` then paste into Control UI settings. | +| `AUTH_TOKEN_MISMATCH` | Shared token did not match gateway auth token. | If `canRetryWithDeviceToken=true`, allow one trusted retry. If still failing, run the [token drift recovery checklist](/cli/devices#token-drift-recovery-checklist). | +| `AUTH_DEVICE_TOKEN_MISMATCH` | Cached per-device token is stale or revoked. | Rotate/re-approve device token using [devices CLI](/cli/devices), then reconnect. | +| `PAIRING_REQUIRED` | Device identity is known but not approved for this role. | Approve pending request: `openclaw devices list` then `openclaw devices approve `. | + Device auth v2 migration check: ```bash @@ -135,6 +147,7 @@ Related: - [/web/control-ui](/web/control-ui) - [/gateway/authentication](/gateway/authentication) - [/gateway/remote](/gateway/remote) +- [/cli/devices](/cli/devices) ## Gateway service not running diff --git a/docs/help/faq.md b/docs/help/faq.md index a43e91f4396..a1d8724e125 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -2512,6 +2512,7 @@ Your gateway is running with auth enabled (`gateway.auth.*`), but the UI is not Facts (from code): - The Control UI keeps the token in `sessionStorage` for the current browser tab session and selected gateway URL, so same-tab refreshes keep working without restoring long-lived localStorage token persistence. +- On `AUTH_TOKEN_MISMATCH`, trusted clients can attempt one bounded retry with a cached device token when the gateway returns retry hints (`canRetryWithDeviceToken=true`, `recommendedNextStep=retry_with_device_token`). Fix: @@ -2520,6 +2521,9 @@ Fix: - If remote, tunnel first: `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/`. - Set `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`) on the gateway host. - In the Control UI settings, paste the same token. +- If mismatch persists after the one retry, rotate/re-approve the paired device token: + - `openclaw devices list` + - `openclaw devices rotate --device --role operator` - Still stuck? Run `openclaw status --all` and follow [Troubleshooting](/gateway/troubleshooting). See [Dashboard](/web/dashboard) for auth details. ### I set gatewaybind tailnet but it can't bind nothing listens diff --git a/docs/help/testing.md b/docs/help/testing.md index 9e965b4c769..6580de4da20 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -409,3 +409,6 @@ When you fix a provider/model issue discovered in live: - Prefer targeting the smallest layer that catches the bug: - provider request conversion/replay bug → direct models test - gateway session/history/tool pipeline bug → gateway live smoke or CI-safe gateway mock test +- SecretRef traversal guardrail: + - `src/secrets/exec-secret-ref-id-parity.test.ts` derives one sampled target per SecretRef class from registry metadata (`listSecretTargetRegistryEntries()`), then asserts traversal-segment exec ids are rejected. + - If you add a new `includeInPlan` SecretRef target family in `src/secrets/target-registry-data.ts`, update `classifyTargetClass` in that test. The test intentionally fails on unclassified target ids so new classes cannot be skipped silently. diff --git a/docs/help/troubleshooting.md b/docs/help/troubleshooting.md index e051f77f589..951e1a480d7 100644 --- a/docs/help/troubleshooting.md +++ b/docs/help/troubleshooting.md @@ -136,7 +136,8 @@ flowchart TD Common log signatures: - `device identity required` → HTTP/non-secure context cannot complete device auth. - - `unauthorized` / reconnect loop → wrong token/password or auth mode mismatch. + - `AUTH_TOKEN_MISMATCH` with retry hints (`canRetryWithDeviceToken=true`) → one trusted device-token retry may occur automatically. + - repeated `unauthorized` after that retry → wrong token/password, auth mode mismatch, or stale paired device token. - `gateway connect failed:` → UI is targeting the wrong URL/port or unreachable gateway. Deep pages: diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 6b5dc29c9b9..b13803e69f3 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -19,6 +19,32 @@ When the operator says “release”, immediately do this preflight (no extra qu - Load env from `~/.profile` and confirm `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect vars are set (SPARKLE_PRIVATE_KEY_FILE should live in `~/.profile`). - Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed. +## Versioning + +Current OpenClaw releases use date-based versioning. + +- Stable release version: `YYYY.M.D` + - Git tag: `vYYYY.M.D` + - Examples from repo history: `v2026.2.26`, `v2026.3.8` +- Beta prerelease version: `YYYY.M.D-beta.N` + - Git tag: `vYYYY.M.D-beta.N` + - Examples from repo history: `v2026.2.15-beta.1`, `v2026.3.8-beta.1` +- Use the same version string everywhere, minus the leading `v` where Git tags are not used: + - `package.json`: `2026.3.8` + - Git tag: `v2026.3.8` + - GitHub release title: `openclaw 2026.3.8` +- Do not zero-pad month or day. Use `2026.3.8`, not `2026.03.08`. +- Stable and beta are npm dist-tags, not separate release lines: + - `latest` = stable + - `beta` = prerelease/testing +- Dev is the moving head of `main`, not a normal git-tagged release. +- The release workflow enforces the current stable/beta tag formats and rejects versions whose CalVer date is more than 2 UTC calendar days away from the release date. + +Historical note: + +- Older tags such as `v2026.1.11-1`, `v2026.2.6-3`, and `v2.0.0-beta2` exist in repo history. +- Treat those as legacy tag patterns. New releases should use `vYYYY.M.D` for stable and `vYYYY.M.D-beta.N` for beta. + 1. **Version & metadata** - [ ] Bump `package.json` version (e.g., `2026.1.29`). @@ -67,8 +93,11 @@ When the operator says “release”, immediately do this preflight (no extra qu 6. **Publish (npm)** - [ ] Confirm git status is clean; commit and push as needed. -- [ ] `npm login` (verify 2FA) if needed. -- [ ] `npm publish --access public` (use `--tag beta` for pre-releases). +- [ ] Confirm npm trusted publishing is configured for the `openclaw` package. +- [ ] Push the matching git tag to trigger `.github/workflows/openclaw-npm-release.yml`. + - Stable tags publish to npm `latest`. + - Beta tags publish to npm `beta`. + - The workflow rejects tags that do not match `package.json`, are not on `main`, or whose CalVer date is more than 2 UTC calendar days away from the release date. - [ ] Verify the registry: `npm view openclaw version`, `npm view openclaw dist-tags`, and `npx -y openclaw@X.Y.Z --version` (or `--help`). ### Troubleshooting (notes from 2.0.0-beta2 release) @@ -84,6 +113,7 @@ When the operator says “release”, immediately do this preflight (no extra qu 7. **GitHub release + appcast** - [ ] Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z` (or `git push --tags`). + - Pushing the tag also triggers the npm release workflow. - [ ] Create/refresh the GitHub release for `vX.Y.Z` with **title `openclaw X.Y.Z`** (not just the tag); body should include the **full** changelog section for that version (Highlights + Changes + Fixes), inline (no bare links), and **must not repeat the title inside the body**. - [ ] Attach artifacts: `npm pack` tarball (optional), `OpenClaw-X.Y.Z.zip`, and `OpenClaw-X.Y.Z.dSYM.zip` (if generated). - [ ] Commit the updated `appcast.xml` and push it (Sparkle feeds from main). diff --git a/docs/start/setup.md b/docs/start/setup.md index 4b6113743f8..205f14d20a5 100644 --- a/docs/start/setup.md +++ b/docs/start/setup.md @@ -127,7 +127,7 @@ openclaw health Use this when debugging auth or deciding what to back up: - **WhatsApp**: `~/.openclaw/credentials/whatsapp//creds.json` -- **Telegram bot token**: config/env or `channels.telegram.tokenFile` +- **Telegram bot token**: config/env or `channels.telegram.tokenFile` (regular file only; symlinks rejected) - **Discord bot token**: config/env or SecretRef (env/file/exec providers) - **Slack tokens**: config/env (`channels.slack.*`) - **Pairing allowlists**: diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index e41a96248ae..65a320f1c52 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -243,9 +243,36 @@ Interface details: - `mode: "session"` requires `thread: true` - `cwd` (optional): requested runtime working directory (validated by backend/runtime policy). - `label` (optional): operator-facing label used in session/banner text. +- `resumeSessionId` (optional): resume an existing ACP session instead of creating a new one. The agent replays its conversation history via `session/load`. Requires `runtime: "acp"`. - `streamTo` (optional): `"parent"` streams initial ACP run progress summaries back to the requester session as system events. - When available, accepted responses include `streamLogPath` pointing to a session-scoped JSONL log (`.acp-stream.jsonl`) you can tail for full relay history. +### Resume an existing session + +Use `resumeSessionId` to continue a previous ACP session instead of starting fresh. The agent replays its conversation history via `session/load`, so it picks up with full context of what came before. + +```json +{ + "task": "Continue where we left off — fix the remaining test failures", + "runtime": "acp", + "agentId": "codex", + "resumeSessionId": "" +} +``` + +Common use cases: + +- Hand off a Codex session from your laptop to your phone — tell your agent to pick up where you left off +- Continue a coding session you started interactively in the CLI, now headlessly through your agent +- Pick up work that was interrupted by a gateway restart or idle timeout + +Notes: + +- `resumeSessionId` requires `runtime: "acp"` — returns an error if used with the sub-agent runtime. +- `resumeSessionId` restores the upstream ACP conversation history; `thread` and `mode` still apply normally to the new OpenClaw session you are creating, so `mode: "session"` still requires `thread: true`. +- The target agent must support `session/load` (Codex and Claude Code do). +- If the session ID isn't found, the spawn fails with a clear error — no silent fallback to a new session. + ### Operator smoke test Use this after a gateway deploy when you want a quick live check that ACP spawn diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index c96a91de0ba..59e9c0c226b 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -174,7 +174,12 @@ OpenClaw **blocks** Control UI connections without device identity. } ``` -`allowInsecureAuth` does not bypass Control UI device identity or pairing checks. +`allowInsecureAuth` is a local compatibility toggle only: + +- It allows localhost Control UI sessions to proceed without device identity in + non-secure HTTP contexts. +- It does not bypass pairing checks. +- It does not relax remote (non-localhost) device identity requirements. **Break-glass only:** diff --git a/docs/web/dashboard.md b/docs/web/dashboard.md index ab5872a6754..86cd6fffd4e 100644 --- a/docs/web/dashboard.md +++ b/docs/web/dashboard.md @@ -45,6 +45,8 @@ Prefer localhost, Tailscale Serve, or an SSH tunnel. ## If you see “unauthorized” / 1008 - Ensure the gateway is reachable (local: `openclaw status`; remote: SSH tunnel `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/`). +- For `AUTH_TOKEN_MISMATCH`, clients may do one trusted retry with a cached device token when the gateway returns retry hints. If auth still fails after that retry, resolve token drift manually. +- For token drift repair steps, follow [Token drift recovery checklist](/cli/devices#token-drift-recovery-checklist). - Retrieve or supply the token from the gateway host: - Plaintext config: `openclaw config get gateway.auth.token` - SecretRef-managed config: resolve the external secret provider or export `OPENCLAW_GATEWAY_TOKEN` in this shell, then rerun `openclaw dashboard` diff --git a/extensions/acpx/src/config.test.ts b/extensions/acpx/src/config.test.ts index ef1491d1682..45be08e3edf 100644 --- a/extensions/acpx/src/config.test.ts +++ b/extensions/acpx/src/config.test.ts @@ -5,7 +5,6 @@ import { ACPX_PINNED_VERSION, createAcpxPluginConfigSchema, resolveAcpxPluginConfig, - toAcpMcpServers, } from "./config.js"; describe("acpx plugin config parsing", () => { @@ -20,9 +19,9 @@ describe("acpx plugin config parsing", () => { expect(resolved.command).toBe(ACPX_BUNDLED_BIN); expect(resolved.expectedVersion).toBe(ACPX_PINNED_VERSION); expect(resolved.allowPluginLocalInstall).toBe(true); + expect(resolved.stripProviderAuthEnvVars).toBe(true); expect(resolved.cwd).toBe(path.resolve("/tmp/workspace")); expect(resolved.strictWindowsCmdWrapper).toBe(true); - expect(resolved.mcpServers).toEqual({}); }); it("accepts command override and disables plugin-local auto-install", () => { @@ -37,6 +36,7 @@ describe("acpx plugin config parsing", () => { expect(resolved.command).toBe(path.resolve(command)); expect(resolved.expectedVersion).toBeUndefined(); expect(resolved.allowPluginLocalInstall).toBe(false); + expect(resolved.stripProviderAuthEnvVars).toBe(false); }); it("resolves relative command paths against workspace directory", () => { @@ -50,6 +50,7 @@ describe("acpx plugin config parsing", () => { expect(resolved.command).toBe(path.resolve("/home/user/repos/openclaw", "../acpx/dist/cli.js")); expect(resolved.expectedVersion).toBeUndefined(); expect(resolved.allowPluginLocalInstall).toBe(false); + expect(resolved.stripProviderAuthEnvVars).toBe(false); }); it("keeps bare command names as-is", () => { @@ -63,6 +64,7 @@ describe("acpx plugin config parsing", () => { expect(resolved.command).toBe("acpx"); expect(resolved.expectedVersion).toBeUndefined(); expect(resolved.allowPluginLocalInstall).toBe(false); + expect(resolved.stripProviderAuthEnvVars).toBe(false); }); it("accepts exact expectedVersion override", () => { @@ -78,6 +80,7 @@ describe("acpx plugin config parsing", () => { expect(resolved.command).toBe(path.resolve(command)); expect(resolved.expectedVersion).toBe("0.1.99"); expect(resolved.allowPluginLocalInstall).toBe(false); + expect(resolved.stripProviderAuthEnvVars).toBe(false); }); it("treats expectedVersion=any as no version constraint", () => { @@ -134,97 +137,4 @@ describe("acpx plugin config parsing", () => { }), ).toThrow("strictWindowsCmdWrapper must be a boolean"); }); - - it("accepts mcp server maps", () => { - const resolved = resolveAcpxPluginConfig({ - rawConfig: { - mcpServers: { - canva: { - command: "npx", - args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"], - env: { - CANVA_TOKEN: "secret", - }, - }, - }, - }, - workspaceDir: "/tmp/workspace", - }); - - expect(resolved.mcpServers).toEqual({ - canva: { - command: "npx", - args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"], - env: { - CANVA_TOKEN: "secret", - }, - }, - }); - }); - - it("rejects invalid mcp server definitions", () => { - expect(() => - resolveAcpxPluginConfig({ - rawConfig: { - mcpServers: { - canva: { - command: "npx", - args: ["-y", 1], - }, - }, - }, - workspaceDir: "/tmp/workspace", - }), - ).toThrow( - "mcpServers.canva must have a command string, optional args array, and optional env object", - ); - }); - - it("schema accepts mcp server config", () => { - const schema = createAcpxPluginConfigSchema(); - if (!schema.safeParse) { - throw new Error("acpx config schema missing safeParse"); - } - const parsed = schema.safeParse({ - mcpServers: { - canva: { - command: "npx", - args: ["-y", "mcp-remote@latest"], - env: { - CANVA_TOKEN: "secret", - }, - }, - }, - }); - - expect(parsed.success).toBe(true); - }); -}); - -describe("toAcpMcpServers", () => { - it("converts plugin config maps into ACP stdio MCP entries", () => { - expect( - toAcpMcpServers({ - canva: { - command: "npx", - args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"], - env: { - CANVA_TOKEN: "secret", - }, - }, - }), - ).toEqual([ - { - name: "canva", - command: "npx", - args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"], - env: [ - { - name: "CANVA_TOKEN", - value: "secret", - }, - ], - }, - ]); - }); }); diff --git a/extensions/acpx/src/config.ts b/extensions/acpx/src/config.ts index 9c581c68a8f..ef0207a1365 100644 --- a/extensions/acpx/src/config.ts +++ b/extensions/acpx/src/config.ts @@ -47,6 +47,7 @@ export type ResolvedAcpxPluginConfig = { command: string; expectedVersion?: string; allowPluginLocalInstall: boolean; + stripProviderAuthEnvVars: boolean; installCommand: string; cwd: string; permissionMode: AcpxPermissionMode; @@ -332,6 +333,7 @@ export function resolveAcpxPluginConfig(params: { workspaceDir: params.workspaceDir, }); const allowPluginLocalInstall = command === ACPX_BUNDLED_BIN; + const stripProviderAuthEnvVars = command === ACPX_BUNDLED_BIN; const configuredExpectedVersion = normalized.expectedVersion; const expectedVersion = configuredExpectedVersion === ACPX_VERSION_ANY @@ -343,6 +345,7 @@ export function resolveAcpxPluginConfig(params: { command, expectedVersion, allowPluginLocalInstall, + stripProviderAuthEnvVars, installCommand, cwd, permissionMode: normalized.permissionMode ?? DEFAULT_PERMISSION_MODE, diff --git a/extensions/acpx/src/ensure.test.ts b/extensions/acpx/src/ensure.test.ts index 3bc6f666031..cae52f29f9b 100644 --- a/extensions/acpx/src/ensure.test.ts +++ b/extensions/acpx/src/ensure.test.ts @@ -77,6 +77,7 @@ describe("acpx ensure", () => { command: "/plugin/node_modules/.bin/acpx", args: ["--version"], cwd: "/plugin", + stripProviderAuthEnvVars: undefined, }); }); @@ -148,6 +149,30 @@ describe("acpx ensure", () => { command: "/custom/acpx", args: ["--help"], cwd: "/custom", + stripProviderAuthEnvVars: undefined, + }); + }); + + it("forwards stripProviderAuthEnvVars to version checks", async () => { + spawnAndCollectMock.mockResolvedValueOnce({ + stdout: "Usage: acpx [options]\n", + stderr: "", + code: 0, + error: null, + }); + + await checkAcpxVersion({ + command: "/plugin/node_modules/.bin/acpx", + cwd: "/plugin", + expectedVersion: undefined, + stripProviderAuthEnvVars: true, + }); + + expect(spawnAndCollectMock).toHaveBeenCalledWith({ + command: "/plugin/node_modules/.bin/acpx", + args: ["--help"], + cwd: "/plugin", + stripProviderAuthEnvVars: true, }); }); @@ -186,6 +211,54 @@ describe("acpx ensure", () => { }); }); + it("threads stripProviderAuthEnvVars through version probes and install", async () => { + spawnAndCollectMock + .mockResolvedValueOnce({ + stdout: "acpx 0.0.9\n", + stderr: "", + code: 0, + error: null, + }) + .mockResolvedValueOnce({ + stdout: "added 1 package\n", + stderr: "", + code: 0, + error: null, + }) + .mockResolvedValueOnce({ + stdout: `acpx ${ACPX_PINNED_VERSION}\n`, + stderr: "", + code: 0, + error: null, + }); + + await ensureAcpx({ + command: "/plugin/node_modules/.bin/acpx", + pluginRoot: "/plugin", + expectedVersion: ACPX_PINNED_VERSION, + stripProviderAuthEnvVars: true, + }); + + expect(spawnAndCollectMock.mock.calls[0]?.[0]).toMatchObject({ + command: "/plugin/node_modules/.bin/acpx", + args: ["--version"], + cwd: "/plugin", + stripProviderAuthEnvVars: true, + }); + expect(spawnAndCollectMock.mock.calls[1]?.[0]).toMatchObject({ + command: "npm", + args: ["install", "--omit=dev", "--no-save", `acpx@${ACPX_PINNED_VERSION}`], + cwd: "/plugin", + stripProviderAuthEnvVars: true, + }); + expect(spawnAndCollectMock.mock.calls[2]?.[0]).toMatchObject({ + command: "/plugin/node_modules/.bin/acpx", + args: ["--version"], + cwd: "/plugin", + stripProviderAuthEnvVars: true, + }); + }); + it("fails with actionable error when npm install fails", async () => { spawnAndCollectMock .mockResolvedValueOnce({ diff --git a/extensions/acpx/src/ensure.ts b/extensions/acpx/src/ensure.ts index 39307db1f4f..9b85d53f618 100644 --- a/extensions/acpx/src/ensure.ts +++ b/extensions/acpx/src/ensure.ts @@ -102,6 +102,7 @@ export async function checkAcpxVersion(params: { command: string; cwd?: string; expectedVersion?: string; + stripProviderAuthEnvVars?: boolean; spawnOptions?: SpawnCommandOptions; }): Promise { const expectedVersion = params.expectedVersion?.trim() || undefined; @@ -113,6 +114,7 @@ export async function checkAcpxVersion(params: { command: params.command, args: probeArgs, cwd, + stripProviderAuthEnvVars: params.stripProviderAuthEnvVars, }; let result: Awaited>; try { @@ -198,6 +200,7 @@ export async function ensureAcpx(params: { pluginRoot?: string; expectedVersion?: string; allowInstall?: boolean; + stripProviderAuthEnvVars?: boolean; spawnOptions?: SpawnCommandOptions; }): Promise { if (pendingEnsure) { @@ -214,6 +217,7 @@ export async function ensureAcpx(params: { command: params.command, cwd: pluginRoot, expectedVersion, + stripProviderAuthEnvVars: params.stripProviderAuthEnvVars, spawnOptions: params.spawnOptions, }); if (precheck.ok) { @@ -231,6 +235,7 @@ export async function ensureAcpx(params: { command: "npm", args: ["install", "--omit=dev", "--no-save", `acpx@${installVersion}`], cwd: pluginRoot, + stripProviderAuthEnvVars: params.stripProviderAuthEnvVars, }); if (install.error) { @@ -252,6 +257,7 @@ export async function ensureAcpx(params: { command: params.command, cwd: pluginRoot, expectedVersion, + stripProviderAuthEnvVars: params.stripProviderAuthEnvVars, spawnOptions: params.spawnOptions, }); diff --git a/extensions/acpx/src/runtime-internals/mcp-agent-command.test.ts b/extensions/acpx/src/runtime-internals/mcp-agent-command.test.ts new file mode 100644 index 00000000000..5deed2e8f0f --- /dev/null +++ b/extensions/acpx/src/runtime-internals/mcp-agent-command.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from "vitest"; + +const { spawnAndCollectMock } = vi.hoisted(() => ({ + spawnAndCollectMock: vi.fn(), +})); + +vi.mock("./process.js", () => ({ + spawnAndCollect: spawnAndCollectMock, +})); + +import { __testing, resolveAcpxAgentCommand } from "./mcp-agent-command.js"; + +describe("resolveAcpxAgentCommand", () => { + it("threads stripProviderAuthEnvVars through the config show probe", async () => { + spawnAndCollectMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + agents: { + codex: { + command: "custom-codex", + }, + }, + }), + stderr: "", + code: 0, + error: null, + }); + + const command = await resolveAcpxAgentCommand({ + acpxCommand: "/plugin/node_modules/.bin/acpx", + cwd: "/plugin", + agent: "codex", + stripProviderAuthEnvVars: true, + }); + + expect(command).toBe("custom-codex"); + expect(spawnAndCollectMock).toHaveBeenCalledWith( + { + command: "/plugin/node_modules/.bin/acpx", + args: ["--cwd", "/plugin", "config", "show"], + cwd: "/plugin", + stripProviderAuthEnvVars: true, + }, + undefined, + ); + }); +}); + +describe("buildMcpProxyAgentCommand", () => { + it("escapes Windows-style proxy paths without double-escaping backslashes", () => { + const quoted = __testing.quoteCommandPart( + "C:\\repo\\extensions\\acpx\\src\\runtime-internals\\mcp-proxy.mjs", + ); + + expect(quoted).toBe( + '"C:\\\\repo\\\\extensions\\\\acpx\\\\src\\\\runtime-internals\\\\mcp-proxy.mjs"', + ); + expect(quoted).not.toContain("\\\\\\"); + }); +}); diff --git a/extensions/acpx/src/runtime-internals/mcp-agent-command.ts b/extensions/acpx/src/runtime-internals/mcp-agent-command.ts index f494bd3d32b..481c8156aca 100644 --- a/extensions/acpx/src/runtime-internals/mcp-agent-command.ts +++ b/extensions/acpx/src/runtime-internals/mcp-agent-command.ts @@ -37,6 +37,10 @@ function quoteCommandPart(value: string): string { return `"${value.replace(/["\\]/g, "\\$&")}"`; } +export const __testing = { + quoteCommandPart, +}; + function toCommandLine(parts: string[]): string { return parts.map(quoteCommandPart).join(" "); } @@ -62,6 +66,7 @@ function readConfiguredAgentOverrides(value: unknown): Record { async function loadAgentOverrides(params: { acpxCommand: string; cwd: string; + stripProviderAuthEnvVars?: boolean; spawnOptions?: SpawnCommandOptions; }): Promise> { const result = await spawnAndCollect( @@ -69,6 +74,7 @@ async function loadAgentOverrides(params: { command: params.acpxCommand, args: ["--cwd", params.cwd, "config", "show"], cwd: params.cwd, + stripProviderAuthEnvVars: params.stripProviderAuthEnvVars, }, params.spawnOptions, ); @@ -87,12 +93,14 @@ export async function resolveAcpxAgentCommand(params: { acpxCommand: string; cwd: string; agent: string; + stripProviderAuthEnvVars?: boolean; spawnOptions?: SpawnCommandOptions; }): Promise { const normalizedAgent = normalizeAgentName(params.agent); const overrides = await loadAgentOverrides({ acpxCommand: params.acpxCommand, cwd: params.cwd, + stripProviderAuthEnvVars: params.stripProviderAuthEnvVars, spawnOptions: params.spawnOptions, }); return overrides[normalizedAgent] ?? ACPX_BUILTIN_AGENT_COMMANDS[normalizedAgent] ?? params.agent; diff --git a/extensions/acpx/src/runtime-internals/process.test.ts b/extensions/acpx/src/runtime-internals/process.test.ts index 0eee162eddf..ba6ad923d3b 100644 --- a/extensions/acpx/src/runtime-internals/process.test.ts +++ b/extensions/acpx/src/runtime-internals/process.test.ts @@ -2,7 +2,7 @@ import { spawn } from "node:child_process"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { createWindowsCmdShimFixture } from "../../../shared/windows-cmd-shim-test-fixtures.js"; import { resolveSpawnCommand, @@ -28,6 +28,7 @@ async function createTempDir(): Promise { } afterEach(async () => { + vi.unstubAllEnvs(); while (tempDirs.length > 0) { const dir = tempDirs.pop(); if (!dir) { @@ -289,4 +290,99 @@ describe("spawnAndCollect", () => { const result = await resultPromise; expect(result.error?.name).toBe("AbortError"); }); + + it("strips shared provider auth env vars from spawned acpx children", async () => { + vi.stubEnv("OPENAI_API_KEY", "openai-secret"); + vi.stubEnv("GITHUB_TOKEN", "gh-secret"); + vi.stubEnv("HF_TOKEN", "hf-secret"); + vi.stubEnv("OPENCLAW_API_KEY", "keep-me"); + + const result = await spawnAndCollect({ + command: process.execPath, + args: [ + "-e", + "process.stdout.write(JSON.stringify({openai:process.env.OPENAI_API_KEY,github:process.env.GITHUB_TOKEN,hf:process.env.HF_TOKEN,openclaw:process.env.OPENCLAW_API_KEY,shell:process.env.OPENCLAW_SHELL}))", + ], + cwd: process.cwd(), + stripProviderAuthEnvVars: true, + }); + + expect(result.code).toBe(0); + expect(result.error).toBeNull(); + + const parsed = JSON.parse(result.stdout) as { + openai?: string; + github?: string; + hf?: string; + openclaw?: string; + shell?: string; + }; + expect(parsed.openai).toBeUndefined(); + expect(parsed.github).toBeUndefined(); + expect(parsed.hf).toBeUndefined(); + expect(parsed.openclaw).toBe("keep-me"); + expect(parsed.shell).toBe("acp"); + }); + + it("strips provider auth env vars case-insensitively", async () => { + vi.stubEnv("OpenAI_Api_Key", "openai-secret"); + vi.stubEnv("Github_Token", "gh-secret"); + vi.stubEnv("OPENCLAW_API_KEY", "keep-me"); + + const result = await spawnAndCollect({ + command: process.execPath, + args: [ + "-e", + "process.stdout.write(JSON.stringify({openai:process.env.OpenAI_Api_Key,github:process.env.Github_Token,openclaw:process.env.OPENCLAW_API_KEY,shell:process.env.OPENCLAW_SHELL}))", + ], + cwd: process.cwd(), + stripProviderAuthEnvVars: true, + }); + + expect(result.code).toBe(0); + expect(result.error).toBeNull(); + + const parsed = JSON.parse(result.stdout) as { + openai?: string; + github?: string; + openclaw?: string; + shell?: string; + }; + expect(parsed.openai).toBeUndefined(); + expect(parsed.github).toBeUndefined(); + expect(parsed.openclaw).toBe("keep-me"); + expect(parsed.shell).toBe("acp"); + }); + + it("preserves provider auth env vars for explicit custom commands by default", async () => { + vi.stubEnv("OPENAI_API_KEY", "openai-secret"); + vi.stubEnv("GITHUB_TOKEN", "gh-secret"); + vi.stubEnv("HF_TOKEN", "hf-secret"); + vi.stubEnv("OPENCLAW_API_KEY", "keep-me"); + + const result = await spawnAndCollect({ + command: process.execPath, + args: [ + "-e", + "process.stdout.write(JSON.stringify({openai:process.env.OPENAI_API_KEY,github:process.env.GITHUB_TOKEN,hf:process.env.HF_TOKEN,openclaw:process.env.OPENCLAW_API_KEY,shell:process.env.OPENCLAW_SHELL}))", + ], + cwd: process.cwd(), + }); + + expect(result.code).toBe(0); + expect(result.error).toBeNull(); + + const parsed = JSON.parse(result.stdout) as { + openai?: string; + github?: string; + hf?: string; + openclaw?: string; + shell?: string; + }; + expect(parsed.openai).toBe("openai-secret"); + expect(parsed.github).toBe("gh-secret"); + expect(parsed.hf).toBe("hf-secret"); + expect(parsed.openclaw).toBe("keep-me"); + expect(parsed.shell).toBe("acp"); + }); }); diff --git a/extensions/acpx/src/runtime-internals/process.ts b/extensions/acpx/src/runtime-internals/process.ts index 4df84aece2f..2724f467ab1 100644 --- a/extensions/acpx/src/runtime-internals/process.ts +++ b/extensions/acpx/src/runtime-internals/process.ts @@ -7,7 +7,9 @@ import type { } from "openclaw/plugin-sdk/acpx"; import { applyWindowsSpawnProgramPolicy, + listKnownProviderAuthEnvVarNames, materializeWindowsSpawnProgram, + omitEnvKeysCaseInsensitive, resolveWindowsSpawnProgramCandidate, } from "openclaw/plugin-sdk/acpx"; @@ -125,6 +127,7 @@ export function spawnWithResolvedCommand( command: string; args: string[]; cwd: string; + stripProviderAuthEnvVars?: boolean; }, options?: SpawnCommandOptions, ): ChildProcessWithoutNullStreams { @@ -136,9 +139,15 @@ export function spawnWithResolvedCommand( options, ); + const childEnv = omitEnvKeysCaseInsensitive( + process.env, + params.stripProviderAuthEnvVars ? listKnownProviderAuthEnvVarNames() : [], + ); + childEnv.OPENCLAW_SHELL = "acp"; + return spawn(resolved.command, resolved.args, { cwd: params.cwd, - env: { ...process.env, OPENCLAW_SHELL: "acp" }, + env: childEnv, stdio: ["pipe", "pipe", "pipe"], shell: resolved.shell, windowsHide: resolved.windowsHide, @@ -180,6 +189,7 @@ export async function spawnAndCollect( command: string; args: string[]; cwd: string; + stripProviderAuthEnvVars?: boolean; }, options?: SpawnCommandOptions, runtime?: { diff --git a/extensions/acpx/src/runtime.test.ts b/extensions/acpx/src/runtime.test.ts index 60ad7f49082..198a0367b59 100644 --- a/extensions/acpx/src/runtime.test.ts +++ b/extensions/acpx/src/runtime.test.ts @@ -1,6 +1,6 @@ import os from "node:os"; import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { runAcpRuntimeAdapterContract } from "../../../src/acp/runtime/adapter-contract.testkit.js"; import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js"; import { @@ -19,13 +19,14 @@ beforeAll(async () => { { command: "/definitely/missing/acpx", allowPluginLocalInstall: false, + stripProviderAuthEnvVars: false, installCommand: "n/a", cwd: process.cwd(), - mcpServers: {}, permissionMode: "approve-reads", nonInteractivePermissions: "fail", strictWindowsCmdWrapper: true, queueOwnerTtlSeconds: 0.1, + mcpServers: {}, }, { logger: NOOP_LOGGER }, ); @@ -165,7 +166,7 @@ describe("AcpxRuntime", () => { for await (const _event of runtime.runTurn({ handle, text: "describe this image", - attachments: [{ mediaType: "image/png", data: "aW1hZ2UtYnl0ZXM=" }], + attachments: [{ mediaType: "image/png", data: "aW1hZ2UtYnl0ZXM=" }], // pragma: allowlist secret mode: "prompt", requestId: "req-image", })) { @@ -186,6 +187,40 @@ describe("AcpxRuntime", () => { ]); }); + it("preserves provider auth env vars when runtime uses a custom acpx command", async () => { + vi.stubEnv("OPENAI_API_KEY", "openai-secret"); // pragma: allowlist secret + vi.stubEnv("GITHUB_TOKEN", "gh-secret"); // pragma: allowlist secret + + try { + const { runtime, logPath } = await createMockRuntimeFixture(); + const handle = await runtime.ensureSession({ + sessionKey: "agent:codex:acp:custom-env", + agent: "codex", + mode: "persistent", + }); + + for await (const _event of runtime.runTurn({ + handle, + text: "custom-env", + mode: "prompt", + requestId: "req-custom-env", + })) { + // Drain events; assertions inspect the mock runtime log. + } + + const logs = await readMockRuntimeLogEntries(logPath); + const prompt = logs.find( + (entry) => + entry.kind === "prompt" && + String(entry.sessionName ?? "") === "agent:codex:acp:custom-env", + ); + expect(prompt?.openaiApiKey).toBe("openai-secret"); + expect(prompt?.githubToken).toBe("gh-secret"); + } finally { + vi.unstubAllEnvs(); + } + }); + it("preserves leading spaces across streamed text deltas", async () => { const runtime = sharedFixture?.runtime; expect(runtime).toBeDefined(); @@ -395,7 +430,7 @@ describe("AcpxRuntime", () => { command: "npx", args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"], env: { - CANVA_TOKEN: "secret", + CANVA_TOKEN: "secret", // pragma: allowlist secret }, }, }, diff --git a/extensions/acpx/src/runtime.ts b/extensions/acpx/src/runtime.ts index b1d33a64f09..b0f166584d5 100644 --- a/extensions/acpx/src/runtime.ts +++ b/extensions/acpx/src/runtime.ts @@ -170,6 +170,7 @@ export class AcpxRuntime implements AcpRuntime { command: this.config.command, cwd: this.config.cwd, expectedVersion: this.config.expectedVersion, + stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars, spawnOptions: this.spawnCommandOptions, }); if (!versionCheck.ok) { @@ -183,6 +184,7 @@ export class AcpxRuntime implements AcpRuntime { command: this.config.command, args: ["--help"], cwd: this.config.cwd, + stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars, }, this.spawnCommandOptions, ); @@ -309,6 +311,7 @@ export class AcpxRuntime implements AcpRuntime { command: this.config.command, args, cwd: state.cwd, + stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars, }, this.spawnCommandOptions, ); @@ -495,6 +498,7 @@ export class AcpxRuntime implements AcpRuntime { command: this.config.command, cwd: this.config.cwd, expectedVersion: this.config.expectedVersion, + stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars, spawnOptions: this.spawnCommandOptions, }); if (!versionCheck.ok) { @@ -518,6 +522,7 @@ export class AcpxRuntime implements AcpRuntime { command: this.config.command, args: ["--help"], cwd: this.config.cwd, + stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars, }, this.spawnCommandOptions, ); @@ -683,6 +688,7 @@ export class AcpxRuntime implements AcpRuntime { acpxCommand: this.config.command, cwd: params.cwd, agent: params.agent, + stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars, spawnOptions: this.spawnCommandOptions, }); const resolved = buildMcpProxyAgentCommand({ @@ -705,6 +711,7 @@ export class AcpxRuntime implements AcpRuntime { command: this.config.command, args: params.args, cwd: params.cwd, + stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars, }, this.spawnCommandOptions, { diff --git a/extensions/acpx/src/service.test.ts b/extensions/acpx/src/service.test.ts index 402fd9ae67b..a4572bf2c90 100644 --- a/extensions/acpx/src/service.test.ts +++ b/extensions/acpx/src/service.test.ts @@ -89,6 +89,11 @@ describe("createAcpxRuntimeService", () => { await vi.waitFor(() => { expect(ensureAcpxSpy).toHaveBeenCalledOnce(); + expect(ensureAcpxSpy).toHaveBeenCalledWith( + expect.objectContaining({ + stripProviderAuthEnvVars: true, + }), + ); expect(probeAvailabilitySpy).toHaveBeenCalledOnce(); }); diff --git a/extensions/acpx/src/service.ts b/extensions/acpx/src/service.ts index ab57dc8b885..a863546fb30 100644 --- a/extensions/acpx/src/service.ts +++ b/extensions/acpx/src/service.ts @@ -59,9 +59,8 @@ export function createAcpxRuntimeService( }); const expectedVersionLabel = pluginConfig.expectedVersion ?? "any"; const installLabel = pluginConfig.allowPluginLocalInstall ? "enabled" : "disabled"; - const mcpServerCount = Object.keys(pluginConfig.mcpServers).length; ctx.logger.info( - `acpx runtime backend registered (command: ${pluginConfig.command}, expectedVersion: ${expectedVersionLabel}, pluginLocalInstall: ${installLabel}${mcpServerCount > 0 ? `, mcpServers: ${mcpServerCount}` : ""})`, + `acpx runtime backend registered (command: ${pluginConfig.command}, expectedVersion: ${expectedVersionLabel}, pluginLocalInstall: ${installLabel})`, ); lifecycleRevision += 1; @@ -73,6 +72,7 @@ export function createAcpxRuntimeService( logger: ctx.logger, expectedVersion: pluginConfig.expectedVersion, allowInstall: pluginConfig.allowPluginLocalInstall, + stripProviderAuthEnvVars: pluginConfig.stripProviderAuthEnvVars, spawnOptions: { strictWindowsCmdWrapper: pluginConfig.strictWindowsCmdWrapper, }, diff --git a/extensions/acpx/src/test-utils/runtime-fixtures.ts b/extensions/acpx/src/test-utils/runtime-fixtures.ts index c99417fbd21..c5cbef83877 100644 --- a/extensions/acpx/src/test-utils/runtime-fixtures.ts +++ b/extensions/acpx/src/test-utils/runtime-fixtures.ts @@ -204,6 +204,8 @@ if (command === "prompt") { sessionName: sessionFromOption, stdinText, openclawShell, + openaiApiKey: process.env.OPENAI_API_KEY || "", + githubToken: process.env.GITHUB_TOKEN || "", }); const requestId = "req-1"; @@ -326,6 +328,7 @@ export async function createMockRuntimeFixture(params?: { const config: ResolvedAcpxPluginConfig = { command: scriptPath, allowPluginLocalInstall: false, + stripProviderAuthEnvVars: false, installCommand: "n/a", cwd: dir, permissionMode: params?.permissionMode ?? "approve-all", @@ -378,6 +381,7 @@ export async function readMockRuntimeLogEntries( export async function cleanupMockRuntimeFixtures(): Promise { delete process.env.MOCK_ACPX_LOG; + delete process.env.MOCK_ACPX_CONFIG_SHOW_AGENTS; sharedMockCliScriptPath = null; logFileSequence = 0; while (tempDirs.length > 0) { diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index d0f076f6e84..747fba5b67b 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -21,6 +21,7 @@ import { import { buildAccountScopedDmSecurityPolicy, collectOpenGroupPolicyRestrictSendersWarnings, + createAccountStatusSink, formatNormalizedAllowFromEntries, mapAllowFromEntries, } from "openclaw/plugin-sdk/compat"; @@ -369,8 +370,11 @@ export const bluebubblesPlugin: ChannelPlugin = { startAccount: async (ctx) => { const account = ctx.account; const webhookPath = resolveWebhookPathFromConfig(account.config); - ctx.setStatus({ - accountId: account.accountId, + const statusSink = createAccountStatusSink({ + accountId: ctx.accountId, + setStatus: ctx.setStatus, + }); + statusSink({ baseUrl: account.baseUrl, }); ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`); @@ -379,7 +383,7 @@ export const bluebubblesPlugin: ChannelPlugin = { config: ctx.cfg, runtime: ctx.runtime, abortSignal: ctx.abortSignal, - statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }), + statusSink, webhookPath, }); }, diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts index 32e239d3f45..76fe4523f16 100644 --- a/extensions/bluebubbles/src/config-schema.ts +++ b/extensions/bluebubbles/src/config-schema.ts @@ -1,7 +1,9 @@ import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/bluebubbles"; import { - AllowFromEntrySchema, + AllowFromListSchema, buildCatchallMultiAccountChannelSchema, + DmPolicySchema, + GroupPolicySchema, } from "openclaw/plugin-sdk/compat"; import { z } from "zod"; import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js"; @@ -35,10 +37,10 @@ const bluebubblesAccountSchema = z serverUrl: z.string().optional(), password: buildSecretInputSchema().optional(), webhookPath: z.string().optional(), - dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), - allowFrom: z.array(AllowFromEntrySchema).optional(), - groupAllowFrom: z.array(AllowFromEntrySchema).optional(), - groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(), + dmPolicy: DmPolicySchema.optional(), + allowFrom: AllowFromListSchema, + groupAllowFrom: AllowFromListSchema, + groupPolicy: GroupPolicySchema.optional(), historyLimit: z.number().int().min(0).optional(), dmHistoryLimit: z.number().int().min(0).optional(), textChunkLimit: z.number().int().positive().optional(), diff --git a/extensions/bluebubbles/src/onboarding.ts b/extensions/bluebubbles/src/onboarding.ts index 86b9719ae24..eb66afdfe21 100644 --- a/extensions/bluebubbles/src/onboarding.ts +++ b/extensions/bluebubbles/src/onboarding.ts @@ -10,6 +10,7 @@ import { formatDocsLink, mergeAllowFromEntries, normalizeAccountId, + patchScopedAccountConfig, resolveAccountIdForConfigure, setTopLevelChannelDmPolicyWithAllowFrom, } from "openclaw/plugin-sdk/bluebubbles"; @@ -38,34 +39,14 @@ function setBlueBubblesAllowFrom( accountId: string, allowFrom: string[], ): OpenClawConfig { - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - channels: { - ...cfg.channels, - bluebubbles: { - ...cfg.channels?.bluebubbles, - allowFrom, - }, - }, - }; - } - return { - ...cfg, - channels: { - ...cfg.channels, - bluebubbles: { - ...cfg.channels?.bluebubbles, - accounts: { - ...cfg.channels?.bluebubbles?.accounts, - [accountId]: { - ...cfg.channels?.bluebubbles?.accounts?.[accountId], - allowFrom, - }, - }, - }, - }, - }; + return patchScopedAccountConfig({ + cfg, + channelKey: channel, + accountId, + patch: { allowFrom }, + ensureChannelEnabled: false, + ensureAccountEnabled: false, + }); } function parseBlueBubblesAllowFromInput(raw: string): string[] { diff --git a/extensions/diffs/src/browser.test.ts b/extensions/diffs/src/browser.test.ts index 9c3cf1365ea..c0b03d62cc0 100644 --- a/extensions/diffs/src/browser.test.ts +++ b/extensions/diffs/src/browser.test.ts @@ -1,8 +1,8 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/diffs"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createTempDiffRoot } from "./test-helpers.js"; const { launchMock } = vi.hoisted(() => ({ launchMock: vi.fn(), @@ -17,10 +17,11 @@ vi.mock("playwright-core", () => ({ describe("PlaywrightDiffScreenshotter", () => { let rootDir: string; let outputPath: string; + let cleanupRootDir: () => Promise; beforeEach(async () => { vi.useFakeTimers(); - rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-diffs-browser-")); + ({ rootDir, cleanup: cleanupRootDir } = await createTempDiffRoot("openclaw-diffs-browser-")); outputPath = path.join(rootDir, "preview.png"); launchMock.mockReset(); const browserModule = await import("./browser.js"); @@ -31,7 +32,7 @@ describe("PlaywrightDiffScreenshotter", () => { const browserModule = await import("./browser.js"); await browserModule.resetSharedBrowserStateForTests(); vi.useRealTimers(); - await fs.rm(rootDir, { recursive: true, force: true }); + await cleanupRootDir(); }); it("reuses the same browser across renders and closes it after the idle window", async () => { diff --git a/extensions/diffs/src/http.test.ts b/extensions/diffs/src/http.test.ts index 5e8c2927691..43216580379 100644 --- a/extensions/diffs/src/http.test.ts +++ b/extensions/diffs/src/http.test.ts @@ -1,32 +1,24 @@ -import fs from "node:fs/promises"; import type { IncomingMessage } from "node:http"; -import os from "node:os"; -import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createMockServerResponse } from "../../../src/test-utils/mock-http-response.js"; import { createDiffsHttpHandler } from "./http.js"; import { DiffArtifactStore } from "./store.js"; +import { createDiffStoreHarness } from "./test-helpers.js"; describe("createDiffsHttpHandler", () => { - let rootDir: string; let store: DiffArtifactStore; + let cleanupRootDir: () => Promise; beforeEach(async () => { - rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-diffs-http-")); - store = new DiffArtifactStore({ rootDir }); + ({ store, cleanup: cleanupRootDir } = await createDiffStoreHarness("openclaw-diffs-http-")); }); afterEach(async () => { - await fs.rm(rootDir, { recursive: true, force: true }); + await cleanupRootDir(); }); it("serves a stored diff document", async () => { - const artifact = await store.createArtifact({ - html: "viewer", - title: "Demo", - inputKind: "before_after", - fileCount: 1, - }); + const artifact = await createViewerArtifact(store); const handler = createDiffsHttpHandler({ store }); const res = createMockServerResponse(); @@ -45,12 +37,7 @@ describe("createDiffsHttpHandler", () => { }); it("rejects invalid tokens", async () => { - const artifact = await store.createArtifact({ - html: "viewer", - title: "Demo", - inputKind: "before_after", - fileCount: 1, - }); + const artifact = await createViewerArtifact(store); const handler = createDiffsHttpHandler({ store }); const res = createMockServerResponse(); @@ -113,96 +100,52 @@ describe("createDiffsHttpHandler", () => { expect(String(res.body)).toContain("openclawDiffsReady"); }); - it("blocks non-loopback viewer access by default", async () => { - const artifact = await store.createArtifact({ - html: "viewer", - title: "Demo", - inputKind: "before_after", - fileCount: 1, - }); + it.each([ + { + name: "blocks non-loopback viewer access by default", + request: remoteReq, + allowRemoteViewer: false, + expectedStatusCode: 404, + }, + { + name: "blocks loopback requests that carry proxy forwarding headers by default", + request: localReq, + headers: { "x-forwarded-for": "203.0.113.10" }, + allowRemoteViewer: false, + expectedStatusCode: 404, + }, + { + name: "allows remote access when allowRemoteViewer is enabled", + request: remoteReq, + allowRemoteViewer: true, + expectedStatusCode: 200, + }, + { + name: "allows proxied loopback requests when allowRemoteViewer is enabled", + request: localReq, + headers: { "x-forwarded-for": "203.0.113.10" }, + allowRemoteViewer: true, + expectedStatusCode: 200, + }, + ])("$name", async ({ request, headers, allowRemoteViewer, expectedStatusCode }) => { + const artifact = await createViewerArtifact(store); - const handler = createDiffsHttpHandler({ store }); + const handler = createDiffsHttpHandler({ store, allowRemoteViewer }); const res = createMockServerResponse(); const handled = await handler( - remoteReq({ + request({ method: "GET", url: artifact.viewerPath, + headers, }), res, ); expect(handled).toBe(true); - expect(res.statusCode).toBe(404); - }); - - it("blocks loopback requests that carry proxy forwarding headers by default", async () => { - const artifact = await store.createArtifact({ - html: "viewer", - title: "Demo", - inputKind: "before_after", - fileCount: 1, - }); - - const handler = createDiffsHttpHandler({ store }); - const res = createMockServerResponse(); - const handled = await handler( - localReq({ - method: "GET", - url: artifact.viewerPath, - headers: { "x-forwarded-for": "203.0.113.10" }, - }), - res, - ); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(404); - }); - - it("allows remote access when allowRemoteViewer is enabled", async () => { - const artifact = await store.createArtifact({ - html: "viewer", - title: "Demo", - inputKind: "before_after", - fileCount: 1, - }); - - const handler = createDiffsHttpHandler({ store, allowRemoteViewer: true }); - const res = createMockServerResponse(); - const handled = await handler( - remoteReq({ - method: "GET", - url: artifact.viewerPath, - }), - res, - ); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(200); - expect(res.body).toBe("viewer"); - }); - - it("allows proxied loopback requests when allowRemoteViewer is enabled", async () => { - const artifact = await store.createArtifact({ - html: "viewer", - title: "Demo", - inputKind: "before_after", - fileCount: 1, - }); - - const handler = createDiffsHttpHandler({ store, allowRemoteViewer: true }); - const res = createMockServerResponse(); - const handled = await handler( - localReq({ - method: "GET", - url: artifact.viewerPath, - headers: { "x-forwarded-for": "203.0.113.10" }, - }), - res, - ); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(200); - expect(res.body).toBe("viewer"); + expect(res.statusCode).toBe(expectedStatusCode); + if (expectedStatusCode === 200) { + expect(res.body).toBe("viewer"); + } }); it("rate-limits repeated remote misses", async () => { @@ -232,6 +175,15 @@ describe("createDiffsHttpHandler", () => { }); }); +async function createViewerArtifact(store: DiffArtifactStore) { + return await store.createArtifact({ + html: "viewer", + title: "Demo", + inputKind: "before_after", + fileCount: 1, + }); +} + function localReq(input: { method: string; url: string; diff --git a/extensions/diffs/src/store.test.ts b/extensions/diffs/src/store.test.ts index d4e6aacd409..8039865b71b 100644 --- a/extensions/diffs/src/store.test.ts +++ b/extensions/diffs/src/store.test.ts @@ -1,21 +1,25 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DiffArtifactStore } from "./store.js"; +import { createDiffStoreHarness } from "./test-helpers.js"; describe("DiffArtifactStore", () => { let rootDir: string; let store: DiffArtifactStore; + let cleanupRootDir: () => Promise; beforeEach(async () => { - rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-diffs-store-")); - store = new DiffArtifactStore({ rootDir }); + ({ + rootDir, + store, + cleanup: cleanupRootDir, + } = await createDiffStoreHarness("openclaw-diffs-store-")); }); afterEach(async () => { vi.useRealTimers(); - await fs.rm(rootDir, { recursive: true, force: true }); + await cleanupRootDir(); }); it("creates and retrieves an artifact", async () => { diff --git a/extensions/diffs/src/test-helpers.ts b/extensions/diffs/src/test-helpers.ts new file mode 100644 index 00000000000..f97ed9573e1 --- /dev/null +++ b/extensions/diffs/src/test-helpers.ts @@ -0,0 +1,30 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { DiffArtifactStore } from "./store.js"; + +export async function createTempDiffRoot(prefix: string): Promise<{ + rootDir: string; + cleanup: () => Promise; +}> { + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + return { + rootDir, + cleanup: async () => { + await fs.rm(rootDir, { recursive: true, force: true }); + }, + }; +} + +export async function createDiffStoreHarness(prefix: string): Promise<{ + rootDir: string; + store: DiffArtifactStore; + cleanup: () => Promise; +}> { + const { rootDir, cleanup } = await createTempDiffRoot(prefix); + return { + rootDir, + store: new DiffArtifactStore({ rootDir }), + cleanup, + }; +} diff --git a/extensions/diffs/src/tool.test.ts b/extensions/diffs/src/tool.test.ts index 97ee6234148..416bdf8dc14 100644 --- a/extensions/diffs/src/tool.test.ts +++ b/extensions/diffs/src/tool.test.ts @@ -1,25 +1,24 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/diffs"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { DiffScreenshotter } from "./browser.js"; import { DEFAULT_DIFFS_TOOL_DEFAULTS } from "./config.js"; import { DiffArtifactStore } from "./store.js"; +import { createDiffStoreHarness } from "./test-helpers.js"; import { createDiffsTool } from "./tool.js"; import type { DiffRenderOptions } from "./types.js"; describe("diffs tool", () => { - let rootDir: string; let store: DiffArtifactStore; + let cleanupRootDir: () => Promise; beforeEach(async () => { - rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-diffs-tool-")); - store = new DiffArtifactStore({ rootDir }); + ({ store, cleanup: cleanupRootDir } = await createDiffStoreHarness("openclaw-diffs-tool-")); }); afterEach(async () => { - await fs.rm(rootDir, { recursive: true, force: true }); + await cleanupRootDir(); }); it("returns a viewer URL in view mode", async () => { diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 2be9ae3335b..47980f97d92 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -1,9 +1,9 @@ import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; import { - buildAccountScopedDmSecurityPolicy, buildOpenGroupPolicyConfigureRouteAllowlistWarning, collectAllowlistProviderGroupPolicyWarnings, createScopedAccountConfigAccessors, + createScopedDmSecurityResolver, formatNormalizedAllowFromEntries, } from "openclaw/plugin-sdk/compat"; import { @@ -12,6 +12,7 @@ import { buildComputedAccountStatusSnapshot, buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, + createAccountStatusSink, getChatChannelMeta, listDirectoryGroupEntriesFromMapKeys, listDirectoryUserEntriesFromAllowFrom, @@ -21,6 +22,7 @@ import { PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, resolveGoogleChatGroupRequireMention, + runPassiveAccountLifecycle, type ChannelDock, type ChannelMessageActionAdapter, type ChannelPlugin, @@ -84,6 +86,14 @@ const googleChatConfigBase = createScopedChannelConfigBase({ + channelKey: "googlechat", + resolvePolicy: (account) => account.config.dm?.policy, + resolveAllowFrom: (account) => account.config.dm?.allowFrom, + allowFromPathSuffix: "dm.", + normalizeEntry: (raw) => formatAllowFromEntry(raw), +}); + export const googlechatDock: ChannelDock = { id: "googlechat", capabilities: { @@ -170,18 +180,7 @@ export const googlechatPlugin: ChannelPlugin = { ...googleChatConfigAccessors, }, security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - return buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "googlechat", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dm?.policy, - allowFrom: account.config.dm?.allowFrom ?? [], - allowFromPathSuffix: "dm.", - normalizeEntry: (raw) => formatAllowFromEntry(raw), - }); - }, + resolveDmPolicy: resolveGoogleChatDmPolicy, collectWarnings: ({ account, cfg }) => { const warnings = collectAllowlistProviderGroupPolicyWarnings({ cfg, @@ -512,37 +511,39 @@ export const googlechatPlugin: ChannelPlugin = { gateway: { startAccount: async (ctx) => { const account = ctx.account; - ctx.log?.info(`[${account.accountId}] starting Google Chat webhook`); - ctx.setStatus({ + const statusSink = createAccountStatusSink({ accountId: account.accountId, + setStatus: ctx.setStatus, + }); + ctx.log?.info(`[${account.accountId}] starting Google Chat webhook`); + statusSink({ running: true, lastStartAt: Date.now(), webhookPath: resolveGoogleChatWebhookPath({ account }), audienceType: account.config.audienceType, audience: account.config.audience, }); - const unregister = await startGoogleChatMonitor({ - account, - config: ctx.cfg, - runtime: ctx.runtime, + await runPassiveAccountLifecycle({ abortSignal: ctx.abortSignal, - webhookPath: account.config.webhookPath, - webhookUrl: account.config.webhookUrl, - statusSink: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }), - }); - // Keep the promise pending until abort (webhook mode is passive). - await new Promise((resolve) => { - if (ctx.abortSignal.aborted) { - resolve(); - return; - } - ctx.abortSignal.addEventListener("abort", () => resolve(), { once: true }); - }); - unregister?.(); - ctx.setStatus({ - accountId: account.accountId, - running: false, - lastStopAt: Date.now(), + start: async () => + await startGoogleChatMonitor({ + account, + config: ctx.cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + webhookPath: account.config.webhookPath, + webhookUrl: account.config.webhookUrl, + statusSink, + }), + stop: async (unregister) => { + unregister?.(); + }, + onStop: async () => { + statusSink({ + running: false, + lastStopAt: Date.now(), + }); + }, }); }, }, diff --git a/extensions/googlechat/src/onboarding.ts b/extensions/googlechat/src/onboarding.ts index 2fadfe7661a..f7708dd30b9 100644 --- a/extensions/googlechat/src/onboarding.ts +++ b/extensions/googlechat/src/onboarding.ts @@ -1,5 +1,7 @@ import type { OpenClawConfig, DmPolicy } from "openclaw/plugin-sdk/googlechat"; import { + DEFAULT_ACCOUNT_ID, + applySetupAccountConfigPatch, addWildcardAllowFrom, formatDocsLink, mergeAllowFromEntries, @@ -8,7 +10,6 @@ import { type ChannelOnboardingAdapter, type ChannelOnboardingDmPolicy, type WizardPrompter, - DEFAULT_ACCOUNT_ID, migrateBaseNameToDefaultAccount, } from "openclaw/plugin-sdk/googlechat"; import { @@ -83,45 +84,6 @@ const dmPolicy: ChannelOnboardingDmPolicy = { promptAllowFrom, }; -function applyAccountConfig(params: { - cfg: OpenClawConfig; - accountId: string; - patch: Record; -}): OpenClawConfig { - const { cfg, accountId, patch } = params; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - channels: { - ...cfg.channels, - googlechat: { - ...cfg.channels?.["googlechat"], - enabled: true, - ...patch, - }, - }, - }; - } - return { - ...cfg, - channels: { - ...cfg.channels, - googlechat: { - ...cfg.channels?.["googlechat"], - enabled: true, - accounts: { - ...cfg.channels?.["googlechat"]?.accounts, - [accountId]: { - ...cfg.channels?.["googlechat"]?.accounts?.[accountId], - enabled: true, - ...patch, - }, - }, - }, - }, - }; -} - async function promptCredentials(params: { cfg: OpenClawConfig; prompter: WizardPrompter; @@ -137,7 +99,7 @@ async function promptCredentials(params: { initialValue: true, }); if (useEnv) { - return applyAccountConfig({ cfg, accountId, patch: {} }); + return applySetupAccountConfigPatch({ cfg, channelKey: channel, accountId, patch: {} }); } } @@ -156,8 +118,9 @@ async function promptCredentials(params: { placeholder: "/path/to/service-account.json", validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), }); - return applyAccountConfig({ + return applySetupAccountConfigPatch({ cfg, + channelKey: channel, accountId, patch: { serviceAccountFile: String(path).trim() }, }); @@ -168,8 +131,9 @@ async function promptCredentials(params: { placeholder: '{"type":"service_account", ... }', validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), }); - return applyAccountConfig({ + return applySetupAccountConfigPatch({ cfg, + channelKey: channel, accountId, patch: { serviceAccount: String(json).trim() }, }); @@ -200,8 +164,9 @@ async function promptAudience(params: { initialValue: currentAudience || undefined, validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), }); - return applyAccountConfig({ + return applySetupAccountConfigPatch({ cfg: params.cfg, + channelKey: channel, accountId: params.accountId, patch: { audienceType, audience: String(audience).trim() }, }); diff --git a/extensions/irc/src/accounts.test.ts b/extensions/irc/src/accounts.test.ts index 59a72d7cbcb..afd1b597b81 100644 --- a/extensions/irc/src/accounts.test.ts +++ b/extensions/irc/src/accounts.test.ts @@ -1,5 +1,8 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; -import { listIrcAccountIds, resolveDefaultIrcAccountId } from "./accounts.js"; +import { listIrcAccountIds, resolveDefaultIrcAccountId, resolveIrcAccount } from "./accounts.js"; import type { CoreConfig } from "./types.js"; function asConfig(value: unknown): CoreConfig { @@ -76,3 +79,28 @@ describe("resolveDefaultIrcAccountId", () => { expect(resolveDefaultIrcAccountId(cfg)).toBe("aaa"); }); }); + +describe("resolveIrcAccount", () => { + it.runIf(process.platform !== "win32")("rejects symlinked password files", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-irc-account-")); + const passwordFile = path.join(dir, "password.txt"); + const passwordLink = path.join(dir, "password-link.txt"); + fs.writeFileSync(passwordFile, "secret-pass\n", "utf8"); + fs.symlinkSync(passwordFile, passwordLink); + + const cfg = asConfig({ + channels: { + irc: { + host: "irc.example.com", + nick: "claw", + passwordFile: passwordLink, + }, + }, + }); + + const account = resolveIrcAccount({ cfg }); + expect(account.password).toBe(""); + expect(account.passwordSource).toBe("none"); + fs.rmSync(dir, { recursive: true, force: true }); + }); +}); diff --git a/extensions/irc/src/accounts.ts b/extensions/irc/src/accounts.ts index d61499c4d39..13d48fffdb7 100644 --- a/extensions/irc/src/accounts.ts +++ b/extensions/irc/src/accounts.ts @@ -1,5 +1,5 @@ -import { readFileSync } from "node:fs"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; +import { tryReadSecretFileSync } from "openclaw/plugin-sdk/core"; import { createAccountListHelpers, normalizeResolvedSecretInputString, @@ -100,13 +100,11 @@ function resolvePassword(accountId: string, merged: IrcAccountConfig) { } if (merged.passwordFile?.trim()) { - try { - const filePassword = readFileSync(merged.passwordFile.trim(), "utf-8").trim(); - if (filePassword) { - return { password: filePassword, source: "passwordFile" as const }; - } - } catch { - // Ignore unreadable files here; status will still surface missing configuration. + const filePassword = tryReadSecretFileSync(merged.passwordFile, "IRC password file", { + rejectSymlink: true, + }); + if (filePassword) { + return { password: filePassword, source: "passwordFile" as const }; } } @@ -137,11 +135,10 @@ function resolveNickServConfig(accountId: string, nickserv?: IrcNickServConfig): envPassword || ""; if (!resolvedPassword && passwordFile) { - try { - resolvedPassword = readFileSync(passwordFile, "utf-8").trim(); - } catch { - // Ignore unreadable files; monitor/probe status will surface failures. - } + resolvedPassword = + tryReadSecretFileSync(passwordFile, "IRC NickServ password file", { + rejectSymlink: true, + }) ?? ""; } const merged: IrcNickServConfig = { diff --git a/extensions/irc/src/channel.startup.test.ts b/extensions/irc/src/channel.startup.test.ts new file mode 100644 index 00000000000..ef972f64c0e --- /dev/null +++ b/extensions/irc/src/channel.startup.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createStartAccountContext } from "../../test-utils/start-account-context.js"; +import type { ResolvedIrcAccount } from "./accounts.js"; + +const hoisted = vi.hoisted(() => ({ + monitorIrcProvider: vi.fn(), +})); + +vi.mock("./monitor.js", async () => { + const actual = await vi.importActual("./monitor.js"); + return { + ...actual, + monitorIrcProvider: hoisted.monitorIrcProvider, + }; +}); + +import { ircPlugin } from "./channel.js"; + +describe("ircPlugin gateway.startAccount", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("keeps startAccount pending until abort, then stops the monitor", async () => { + const stop = vi.fn(); + hoisted.monitorIrcProvider.mockResolvedValue({ stop }); + + const account: ResolvedIrcAccount = { + accountId: "default", + enabled: true, + name: "default", + configured: true, + host: "irc.example.com", + port: 6697, + tls: true, + nick: "openclaw", + username: "openclaw", + realname: "OpenClaw", + password: "", + passwordSource: "none", + config: {} as ResolvedIrcAccount["config"], + }; + + const abort = new AbortController(); + const task = ircPlugin.gateway!.startAccount!( + createStartAccountContext({ + account, + abortSignal: abort.signal, + }), + ); + let settled = false; + void task.then(() => { + settled = true; + }); + + await vi.waitFor(() => { + expect(hoisted.monitorIrcProvider).toHaveBeenCalledOnce(); + }); + expect(settled).toBe(false); + expect(stop).not.toHaveBeenCalled(); + + abort.abort(); + await task; + + expect(stop).toHaveBeenCalledOnce(); + }); +}); diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index 03d86da4c54..c598a9a0ef3 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -9,10 +9,12 @@ import { buildBaseAccountStatusSnapshot, buildBaseChannelStatusSummary, buildChannelConfigSchema, + createAccountStatusSink, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, getChatChannelMeta, PAIRING_APPROVED_MESSAGE, + runPassiveAccountLifecycle, setAccountEnabledInConfigSection, type ChannelPlugin, } from "openclaw/plugin-sdk/irc"; @@ -353,6 +355,10 @@ export const ircPlugin: ChannelPlugin = { gateway: { startAccount: async (ctx) => { const account = ctx.account; + const statusSink = createAccountStatusSink({ + accountId: ctx.accountId, + setStatus: ctx.setStatus, + }); if (!account.configured) { throw new Error( `IRC is not configured for account "${account.accountId}" (need host and nick in channels.irc).`, @@ -361,14 +367,20 @@ export const ircPlugin: ChannelPlugin = { ctx.log?.info( `[${account.accountId}] starting IRC provider (${account.host}:${account.port}${account.tls ? " tls" : ""})`, ); - const { stop } = await monitorIrcProvider({ - accountId: account.accountId, - config: ctx.cfg as CoreConfig, - runtime: ctx.runtime, + await runPassiveAccountLifecycle({ abortSignal: ctx.abortSignal, - statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }), + start: async () => + await monitorIrcProvider({ + accountId: account.accountId, + config: ctx.cfg as CoreConfig, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + statusSink, + }), + stop: async (monitor) => { + monitor.stop(); + }, }); - return { stop }; }, }, }; diff --git a/extensions/irc/src/onboarding.ts b/extensions/irc/src/onboarding.ts index d7d7b7f79a9..5e7c80c94d7 100644 --- a/extensions/irc/src/onboarding.ts +++ b/extensions/irc/src/onboarding.ts @@ -1,6 +1,7 @@ import { DEFAULT_ACCOUNT_ID, formatDocsLink, + patchScopedAccountConfig, promptChannelAccessConfig, resolveAccountIdForConfigure, setTopLevelChannelAllowFrom, @@ -59,35 +60,14 @@ function updateIrcAccountConfig( accountId: string, patch: Partial, ): CoreConfig { - const current = cfg.channels?.irc ?? {}; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - channels: { - ...cfg.channels, - irc: { - ...current, - ...patch, - }, - }, - }; - } - return { - ...cfg, - channels: { - ...cfg.channels, - irc: { - ...current, - accounts: { - ...current.accounts, - [accountId]: { - ...current.accounts?.[accountId], - ...patch, - }, - }, - }, - }, - }; + return patchScopedAccountConfig({ + cfg, + channelKey: channel, + accountId, + patch, + ensureChannelEnabled: false, + ensureAccountEnabled: false, + }) as CoreConfig; } function setIrcDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig { diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index 9388579ab38..ddc612b8fa7 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -1,7 +1,8 @@ import { - buildAccountScopedDmSecurityPolicy, - createScopedAccountConfigAccessors, collectAllowlistProviderRestrictSendersWarnings, + createScopedAccountConfigAccessors, + createScopedChannelConfigBase, + createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/compat"; import { buildChannelConfigSchema, @@ -43,6 +44,24 @@ const lineConfigAccessors = createScopedAccountConfigAccessors({ .map((entry) => entry.replace(/^line:(?:user:)?/i, "")), }); +const lineConfigBase = createScopedChannelConfigBase({ + sectionKey: "line", + listAccountIds: (cfg) => getLineRuntime().channel.line.listLineAccountIds(cfg), + resolveAccount: (cfg, accountId) => + getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId: accountId ?? undefined }), + defaultAccountId: (cfg) => getLineRuntime().channel.line.resolveDefaultLineAccountId(cfg), + clearBaseFields: ["channelSecret", "tokenFile", "secretFile"], +}); + +const resolveLineDmPolicy = createScopedDmSecurityResolver({ + channelKey: "line", + resolvePolicy: (account) => account.config.dmPolicy, + resolveAllowFrom: (account) => account.config.allowFrom, + policyPathSuffix: "dmPolicy", + approveHint: "openclaw pairing approve line ", + normalizeEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""), +}); + function patchLineAccountConfig( cfg: OpenClawConfig, lineConfig: LineConfig, @@ -113,40 +132,7 @@ export const linePlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.line"] }, configSchema: buildChannelConfigSchema(LineConfigSchema), config: { - listAccountIds: (cfg) => getLineRuntime().channel.line.listLineAccountIds(cfg), - resolveAccount: (cfg, accountId) => - getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId: accountId ?? undefined }), - defaultAccountId: (cfg) => getLineRuntime().channel.line.resolveDefaultLineAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => { - const lineConfig = (cfg.channels?.line ?? {}) as LineConfig; - return patchLineAccountConfig(cfg, lineConfig, accountId, { enabled }); - }, - deleteAccount: ({ cfg, accountId }) => { - const lineConfig = (cfg.channels?.line ?? {}) as LineConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - // oxlint-disable-next-line no-unused-vars - const { channelSecret, tokenFile, secretFile, ...rest } = lineConfig; - return { - ...cfg, - channels: { - ...cfg.channels, - line: rest, - }, - }; - } - const accounts = { ...lineConfig.accounts }; - delete accounts[accountId]; - return { - ...cfg, - channels: { - ...cfg.channels, - line: { - ...lineConfig, - accounts: Object.keys(accounts).length > 0 ? accounts : undefined, - }, - }, - }; - }, + ...lineConfigBase, isConfigured: (account) => Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()), describeAccount: (account) => ({ @@ -159,19 +145,7 @@ export const linePlugin: ChannelPlugin = { ...lineConfigAccessors, }, security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - return buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "line", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dmPolicy, - allowFrom: account.config.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - approveHint: "openclaw pairing approve line ", - normalizeEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""), - }); - }, + resolveDmPolicy: resolveLineDmPolicy, collectWarnings: ({ account, cfg }) => { return collectAllowlistProviderRestrictSendersWarnings({ cfg, diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index c33c85ebe05..a024b3f3e8a 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -1,8 +1,9 @@ import { - buildAccountScopedDmSecurityPolicy, buildOpenGroupPolicyWarning, collectAllowlistProviderGroupPolicyWarnings, createScopedAccountConfigAccessors, + createScopedChannelConfigBase, + createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/compat"; import { applyAccountNameToChannelSection, @@ -10,10 +11,8 @@ import { buildProbeChannelStatusSummary, collectStatusIssuesFromLastError, DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, normalizeAccountId, PAIRING_APPROVED_MESSAGE, - setAccountEnabledInConfigSection, type ChannelPlugin, } from "openclaw/plugin-sdk/matrix"; import { matrixMessageActions } from "./actions.js"; @@ -106,6 +105,30 @@ const matrixConfigAccessors = createScopedAccountConfigAccessors({ formatAllowFrom: (allowFrom) => normalizeMatrixAllowList(allowFrom), }); +const matrixConfigBase = createScopedChannelConfigBase({ + sectionKey: "matrix", + listAccountIds: listMatrixAccountIds, + resolveAccount: (cfg, accountId) => resolveMatrixAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultMatrixAccountId, + clearBaseFields: [ + "name", + "homeserver", + "userId", + "accessToken", + "password", + "deviceName", + "initialSyncLimit", + ], +}); + +const resolveMatrixDmPolicy = createScopedDmSecurityResolver({ + channelKey: "matrix", + resolvePolicy: (account) => account.config.dm?.policy, + resolveAllowFrom: (account) => account.config.dm?.allowFrom, + allowFromPathSuffix: "dm.", + normalizeEntry: (raw) => normalizeMatrixUserId(raw), +}); + export const matrixPlugin: ChannelPlugin = { id: "matrix", meta, @@ -127,32 +150,7 @@ export const matrixPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.matrix"] }, configSchema: buildChannelConfigSchema(MatrixConfigSchema), config: { - listAccountIds: (cfg) => listMatrixAccountIds(cfg as CoreConfig), - resolveAccount: (cfg, accountId) => resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }), - defaultAccountId: (cfg) => resolveDefaultMatrixAccountId(cfg as CoreConfig), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg: cfg as CoreConfig, - sectionKey: "matrix", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg: cfg as CoreConfig, - sectionKey: "matrix", - accountId, - clearBaseFields: [ - "name", - "homeserver", - "userId", - "accessToken", - "password", - "deviceName", - "initialSyncLimit", - ], - }), + ...matrixConfigBase, isConfigured: (account) => account.configured, describeAccount: (account) => ({ accountId: account.accountId, @@ -164,18 +162,7 @@ export const matrixPlugin: ChannelPlugin = { ...matrixConfigAccessors, }, security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - return buildAccountScopedDmSecurityPolicy({ - cfg: cfg as CoreConfig, - channelKey: "matrix", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dm?.policy, - allowFrom: account.config.dm?.allowFrom ?? [], - allowFromPathSuffix: "dm.", - normalizeEntry: (raw) => normalizeMatrixUserId(raw), - }); - }, + resolveDmPolicy: resolveMatrixDmPolicy, collectWarnings: ({ account, cfg }) => { return collectAllowlistProviderGroupPolicyWarnings({ cfg: cfg as CoreConfig, diff --git a/extensions/matrix/src/config-schema.ts b/extensions/matrix/src/config-schema.ts index cd1c89fbdb6..a95d2fbda96 100644 --- a/extensions/matrix/src/config-schema.ts +++ b/extensions/matrix/src/config-schema.ts @@ -1,9 +1,13 @@ +import { + AllowFromListSchema, + buildNestedDmConfigSchema, + DmPolicySchema, + GroupPolicySchema, +} from "openclaw/plugin-sdk/compat"; import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/matrix"; import { z } from "zod"; import { buildSecretInputSchema } from "./secret-input.js"; -const allowFromEntry = z.union([z.string(), z.number()]); - const matrixActionSchema = z .object({ reactions: z.boolean().optional(), @@ -14,14 +18,6 @@ const matrixActionSchema = z }) .optional(); -const matrixDmSchema = z - .object({ - enabled: z.boolean().optional(), - policy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), - allowFrom: z.array(allowFromEntry).optional(), - }) - .optional(); - const matrixRoomSchema = z .object({ enabled: z.boolean().optional(), @@ -29,7 +25,7 @@ const matrixRoomSchema = z requireMention: z.boolean().optional(), tools: ToolPolicySchema, autoReply: z.boolean().optional(), - users: z.array(allowFromEntry).optional(), + users: AllowFromListSchema, skills: z.array(z.string()).optional(), systemPrompt: z.string().optional(), }) @@ -49,7 +45,7 @@ export const MatrixConfigSchema = z.object({ initialSyncLimit: z.number().optional(), encryption: z.boolean().optional(), allowlistOnly: z.boolean().optional(), - groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(), + groupPolicy: GroupPolicySchema.optional(), replyToMode: z.enum(["off", "first", "all"]).optional(), threadReplies: z.enum(["off", "inbound", "always"]).optional(), textChunkLimit: z.number().optional(), @@ -57,9 +53,9 @@ export const MatrixConfigSchema = z.object({ responsePrefix: z.string().optional(), mediaMaxMb: z.number().optional(), autoJoin: z.enum(["always", "allowlist", "off"]).optional(), - autoJoinAllowlist: z.array(allowFromEntry).optional(), - groupAllowFrom: z.array(allowFromEntry).optional(), - dm: matrixDmSchema, + autoJoinAllowlist: AllowFromListSchema, + groupAllowFrom: AllowFromListSchema, + dm: buildNestedDmConfigSchema(), groups: z.object({}).catchall(matrixRoomSchema).optional(), rooms: z.object({}).catchall(matrixRoomSchema).optional(), actions: matrixActionSchema, diff --git a/extensions/matrix/src/matrix/monitor/allowlist.ts b/extensions/matrix/src/matrix/monitor/allowlist.ts index e9402c38362..326360cade5 100644 --- a/extensions/matrix/src/matrix/monitor/allowlist.ts +++ b/extensions/matrix/src/matrix/monitor/allowlist.ts @@ -1,6 +1,7 @@ import { + compileAllowlist, normalizeStringEntries, - resolveAllowlistMatchByCandidates, + resolveAllowlistCandidates, type AllowlistMatch, } from "openclaw/plugin-sdk/matrix"; @@ -75,11 +76,11 @@ export function resolveMatrixAllowListMatch(params: { allowList: string[]; userId?: string; }): MatrixAllowListMatch { - const allowList = params.allowList; - if (allowList.length === 0) { + const compiledAllowList = compileAllowlist(params.allowList); + if (compiledAllowList.set.size === 0) { return { allowed: false }; } - if (allowList.includes("*")) { + if (compiledAllowList.wildcard) { return { allowed: true, matchKey: "*", matchSource: "wildcard" }; } const userId = normalizeMatrixUser(params.userId); @@ -88,7 +89,10 @@ export function resolveMatrixAllowListMatch(params: { { value: userId ? `matrix:${userId}` : "", source: "prefixed-id" }, { value: userId ? `user:${userId}` : "", source: "prefixed-user" }, ]; - return resolveAllowlistMatchByCandidates({ allowList, candidates }); + return resolveAllowlistCandidates({ + compiledAllowlist: compiledAllowList, + candidates, + }); } export function resolveMatrixAllowListMatches(params: { allowList: string[]; userId?: string }) { diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index b62231ac997..2dffaa6f3cf 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -9,6 +9,7 @@ import { applySetupAccountConfigPatch, buildComputedAccountStatusSnapshot, buildChannelConfigSchema, + createAccountStatusSink, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, migrateBaseNameToDefaultAccount, @@ -500,8 +501,11 @@ export const mattermostPlugin: ChannelPlugin = { gateway: { startAccount: async (ctx) => { const account = ctx.account; - ctx.setStatus({ - accountId: account.accountId, + const statusSink = createAccountStatusSink({ + accountId: ctx.accountId, + setStatus: ctx.setStatus, + }); + statusSink({ baseUrl: account.baseUrl, botTokenSource: account.botTokenSource, }); @@ -513,7 +517,7 @@ export const mattermostPlugin: ChannelPlugin = { config: ctx.cfg, runtime: ctx.runtime, abortSignal: ctx.abortSignal, - statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }), + statusSink, }); }, }, diff --git a/extensions/nextcloud-talk/src/accounts.test.ts b/extensions/nextcloud-talk/src/accounts.test.ts new file mode 100644 index 00000000000..dbc43690a3b --- /dev/null +++ b/extensions/nextcloud-talk/src/accounts.test.ts @@ -0,0 +1,30 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { resolveNextcloudTalkAccount } from "./accounts.js"; +import type { CoreConfig } from "./types.js"; + +describe("resolveNextcloudTalkAccount", () => { + it.runIf(process.platform !== "win32")("rejects symlinked botSecretFile paths", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-nextcloud-talk-")); + const secretFile = path.join(dir, "secret.txt"); + const secretLink = path.join(dir, "secret-link.txt"); + fs.writeFileSync(secretFile, "bot-secret\n", "utf8"); + fs.symlinkSync(secretFile, secretLink); + + const cfg = { + channels: { + "nextcloud-talk": { + baseUrl: "https://cloud.example.com", + botSecretFile: secretLink, + }, + }, + } as CoreConfig; + + const account = resolveNextcloudTalkAccount({ cfg }); + expect(account.secret).toBe(""); + expect(account.secretSource).toBe("none"); + fs.rmSync(dir, { recursive: true, force: true }); + }); +}); diff --git a/extensions/nextcloud-talk/src/accounts.ts b/extensions/nextcloud-talk/src/accounts.ts index 74bb45cfd8b..2cfba6fea44 100644 --- a/extensions/nextcloud-talk/src/accounts.ts +++ b/extensions/nextcloud-talk/src/accounts.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import { tryReadSecretFileSync } from "openclaw/plugin-sdk/core"; import { createAccountListHelpers, DEFAULT_ACCOUNT_ID, @@ -88,13 +88,13 @@ function resolveNextcloudTalkSecret( } if (merged.botSecretFile) { - try { - const fileSecret = readFileSync(merged.botSecretFile, "utf-8").trim(); - if (fileSecret) { - return { secret: fileSecret, source: "secretFile" }; - } - } catch { - // File not found or unreadable, fall through. + const fileSecret = tryReadSecretFileSync( + merged.botSecretFile, + "Nextcloud Talk bot secret file", + { rejectSymlink: true }, + ); + if (fileSecret) { + return { secret: fileSecret, source: "secretFile" }; } } diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index 6fdf36e9f8c..8a908b7e0ac 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -2,8 +2,10 @@ import { buildAccountScopedDmSecurityPolicy, collectAllowlistProviderGroupPolicyWarnings, collectOpenGroupPolicyRouteAllowlistWarnings, + createAccountStatusSink, formatAllowFromLowercase, mapAllowFromEntries, + runPassiveAccountLifecycle, } from "openclaw/plugin-sdk/compat"; import { applyAccountNameToChannelSection, @@ -15,7 +17,6 @@ import { deleteAccountFromConfigSection, normalizeAccountId, setAccountEnabledInConfigSection, - waitForAbortSignal, type ChannelPlugin, type OpenClawConfig, type ChannelSetupInput, @@ -338,17 +339,25 @@ export const nextcloudTalkPlugin: ChannelPlugin = ctx.log?.info(`[${account.accountId}] starting Nextcloud Talk webhook server`); - const { stop } = await monitorNextcloudTalkProvider({ - accountId: account.accountId, - config: ctx.cfg as CoreConfig, - runtime: ctx.runtime, - abortSignal: ctx.abortSignal, - statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }), + const statusSink = createAccountStatusSink({ + accountId: ctx.accountId, + setStatus: ctx.setStatus, }); - // Keep webhook channels pending for the account lifecycle. - await waitForAbortSignal(ctx.abortSignal); - stop(); + await runPassiveAccountLifecycle({ + abortSignal: ctx.abortSignal, + start: async () => + await monitorNextcloudTalkProvider({ + accountId: account.accountId, + config: ctx.cfg as CoreConfig, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + statusSink, + }), + stop: async (monitor) => { + monitor.stop(); + }, + }); }, logoutAccount: async ({ accountId, cfg }) => { const nextCfg = { ...cfg } as OpenClawConfig; diff --git a/extensions/nextcloud-talk/src/onboarding.ts b/extensions/nextcloud-talk/src/onboarding.ts index 3ccf2851c3b..7b1a8b11d28 100644 --- a/extensions/nextcloud-talk/src/onboarding.ts +++ b/extensions/nextcloud-talk/src/onboarding.ts @@ -1,15 +1,14 @@ import { - buildSingleChannelSecretPromptState, formatDocsLink, hasConfiguredSecretInput, mapAllowFromEntries, mergeAllowFromEntries, - promptSingleChannelSecretInput, + patchScopedAccountConfig, + runSingleChannelSecretStep, resolveAccountIdForConfigure, DEFAULT_ACCOUNT_ID, normalizeAccountId, setTopLevelChannelDmPolicyWithAllowFrom, - type SecretInput, type ChannelOnboardingAdapter, type ChannelOnboardingDmPolicy, type OpenClawConfig, @@ -39,38 +38,12 @@ function setNextcloudTalkAccountConfig( accountId: string, updates: Record, ): CoreConfig { - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - channels: { - ...cfg.channels, - "nextcloud-talk": { - ...cfg.channels?.["nextcloud-talk"], - enabled: true, - ...updates, - }, - }, - }; - } - - return { - ...cfg, - channels: { - ...cfg.channels, - "nextcloud-talk": { - ...cfg.channels?.["nextcloud-talk"], - enabled: true, - accounts: { - ...cfg.channels?.["nextcloud-talk"]?.accounts, - [accountId]: { - ...cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId], - enabled: cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true, - ...updates, - }, - }, - }, - }, - }; + return patchScopedAccountConfig({ + cfg, + channelKey: channel, + accountId, + patch: updates, + }) as CoreConfig; } async function noteNextcloudTalkSecretHelp(prompter: WizardPrompter): Promise { @@ -215,12 +188,6 @@ export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = { hasConfiguredSecretInput(resolvedAccount.config.botSecret) || resolvedAccount.config.botSecretFile, ); - const secretPromptState = buildSingleChannelSecretPromptState({ - accountConfigured, - hasConfigToken: hasConfigSecret, - allowEnv, - envValue: process.env.NEXTCLOUD_TALK_BOT_SECRET, - }); let baseUrl = resolvedAccount.baseUrl; if (!baseUrl) { @@ -241,32 +208,35 @@ export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = { ).trim(); } - let secret: SecretInput | null = null; - if (!accountConfigured) { - await noteNextcloudTalkSecretHelp(prompter); - } - - const secretResult = await promptSingleChannelSecretInput({ + const secretStep = await runSingleChannelSecretStep({ cfg: next, prompter, providerHint: "nextcloud-talk", credentialLabel: "bot secret", - accountConfigured: secretPromptState.accountConfigured, - canUseEnv: secretPromptState.canUseEnv, - hasConfigToken: secretPromptState.hasConfigToken, + accountConfigured, + hasConfigToken: hasConfigSecret, + allowEnv, + envValue: process.env.NEXTCLOUD_TALK_BOT_SECRET, envPrompt: "NEXTCLOUD_TALK_BOT_SECRET detected. Use env var?", keepPrompt: "Nextcloud Talk bot secret already configured. Keep it?", inputPrompt: "Enter Nextcloud Talk bot secret", preferredEnvVar: "NEXTCLOUD_TALK_BOT_SECRET", + onMissingConfigured: async () => await noteNextcloudTalkSecretHelp(prompter), + applyUseEnv: async (cfg) => + setNextcloudTalkAccountConfig(cfg as CoreConfig, accountId, { + baseUrl, + }), + applySet: async (cfg, value) => + setNextcloudTalkAccountConfig(cfg as CoreConfig, accountId, { + baseUrl, + botSecret: value, + }), }); - if (secretResult.action === "set") { - secret = secretResult.value; - } + next = secretStep.cfg as CoreConfig; - if (secretResult.action === "use-env" || secret || baseUrl !== resolvedAccount.baseUrl) { + if (secretStep.action === "keep" && baseUrl !== resolvedAccount.baseUrl) { next = setNextcloudTalkAccountConfig(next, accountId, { baseUrl, - ...(secret ? { botSecret: secret } : {}), }); } @@ -287,26 +257,28 @@ export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = { validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), }), ).trim(); - const apiPasswordResult = await promptSingleChannelSecretInput({ + const apiPasswordStep = await runSingleChannelSecretStep({ cfg: next, prompter, providerHint: "nextcloud-talk-api", credentialLabel: "API password", - ...buildSingleChannelSecretPromptState({ - accountConfigured: Boolean(existingApiUser && existingApiPasswordConfigured), - hasConfigToken: existingApiPasswordConfigured, - allowEnv: false, - }), + accountConfigured: Boolean(existingApiUser && existingApiPasswordConfigured), + hasConfigToken: existingApiPasswordConfigured, + allowEnv: false, envPrompt: "", keepPrompt: "Nextcloud Talk API password already configured. Keep it?", inputPrompt: "Enter Nextcloud Talk API password", preferredEnvVar: "NEXTCLOUD_TALK_API_PASSWORD", + applySet: async (cfg, value) => + setNextcloudTalkAccountConfig(cfg as CoreConfig, accountId, { + apiUser, + apiPassword: value, + }), }); - const apiPassword = apiPasswordResult.action === "set" ? apiPasswordResult.value : undefined; - next = setNextcloudTalkAccountConfig(next, accountId, { - apiUser, - ...(apiPassword ? { apiPassword } : {}), - }); + next = + apiPasswordStep.action === "keep" + ? setNextcloudTalkAccountConfig(next, accountId, { apiUser }) + : (apiPasswordStep.cfg as CoreConfig); } if (forceAllowFrom) { diff --git a/extensions/nostr/src/config-schema.ts b/extensions/nostr/src/config-schema.ts index a25868da356..25d928b4837 100644 --- a/extensions/nostr/src/config-schema.ts +++ b/extensions/nostr/src/config-schema.ts @@ -1,8 +1,7 @@ +import { AllowFromListSchema, DmPolicySchema } from "openclaw/plugin-sdk/compat"; import { MarkdownConfigSchema, buildChannelConfigSchema } from "openclaw/plugin-sdk/nostr"; import { z } from "zod"; -const allowFromEntry = z.union([z.string(), z.number()]); - /** * Validates https:// URLs only (no javascript:, data:, file:, etc.) */ @@ -76,10 +75,10 @@ export const NostrConfigSchema = z.object({ relays: z.array(z.string()).optional(), /** DM access policy: pairing, allowlist, open, or disabled */ - dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), + dmPolicy: DmPolicySchema.optional(), /** Allowed sender pubkeys (npub or hex format) */ - allowFrom: z.array(allowFromEntry).optional(), + allowFrom: AllowFromListSchema, /** Profile metadata (NIP-01 kind:0 content) */ profile: NostrProfileSchema.optional(), diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index 2bf1b681497..f0736069015 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -313,6 +313,68 @@ describe("telegramPlugin duplicate token guard", () => { expect(result).toMatchObject({ channel: "telegram", messageId: "tg-2" }); }); + it("sends outbound payload media lists and keeps buttons on the first message only", async () => { + const sendMessageTelegram = vi + .fn() + .mockResolvedValueOnce({ messageId: "tg-3", chatId: "12345" }) + .mockResolvedValueOnce({ messageId: "tg-4", chatId: "12345" }); + setTelegramRuntime({ + channel: { + telegram: { + sendMessageTelegram, + }, + }, + } as unknown as PluginRuntime); + + const result = await telegramPlugin.outbound!.sendPayload!({ + cfg: createCfg(), + to: "12345", + text: "", + payload: { + text: "Approval required", + mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"], + channelData: { + telegram: { + quoteText: "quoted", + buttons: [[{ text: "Allow Once", callback_data: "/approve abc allow-once" }]], + }, + }, + }, + mediaLocalRoots: ["/tmp/media"], + accountId: "ops", + silent: true, + }); + + expect(sendMessageTelegram).toHaveBeenCalledTimes(2); + expect(sendMessageTelegram).toHaveBeenNthCalledWith( + 1, + "12345", + "Approval required", + expect.objectContaining({ + mediaUrl: "https://example.com/1.jpg", + mediaLocalRoots: ["/tmp/media"], + quoteText: "quoted", + silent: true, + buttons: [[{ text: "Allow Once", callback_data: "/approve abc allow-once" }]], + }), + ); + expect(sendMessageTelegram).toHaveBeenNthCalledWith( + 2, + "12345", + "", + expect.objectContaining({ + mediaUrl: "https://example.com/2.jpg", + mediaLocalRoots: ["/tmp/media"], + quoteText: "quoted", + silent: true, + }), + ); + expect( + (sendMessageTelegram.mock.calls[1]?.[2] as Record)?.buttons, + ).toBeUndefined(); + expect(result).toMatchObject({ channel: "telegram", messageId: "tg-4" }); + }); + it("ignores accounts with missing tokens during duplicate-token checks", async () => { const cfg = createCfg(); cfg.channels!.telegram!.accounts!.ops = {} as never; diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 5893f4e0a2e..52ae2b15ea8 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -1,9 +1,9 @@ import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; import { collectAllowlistProviderGroupPolicyWarnings, - buildAccountScopedDmSecurityPolicy, collectOpenGroupPolicyRouteAllowlistWarnings, createScopedAccountConfigAccessors, + createScopedDmSecurityResolver, formatAllowFromLowercase, } from "openclaw/plugin-sdk/compat"; import { @@ -31,6 +31,7 @@ import { resolveTelegramAccount, resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, + sendTelegramPayloadMessages, telegramOnboardingAdapter, TelegramConfigSchema, type ChannelMessageActionAdapter, @@ -91,10 +92,6 @@ const telegramMessageActions: ChannelMessageActionAdapter = { }, }; -type TelegramInlineButtons = ReadonlyArray< - ReadonlyArray<{ text: string; callback_data: string; style?: "danger" | "success" | "primary" }> ->; - const telegramConfigAccessors = createScopedAccountConfigAccessors({ resolveAccount: ({ cfg, accountId }) => resolveTelegramAccount({ cfg, accountId }), resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom, @@ -112,6 +109,14 @@ const telegramConfigBase = createScopedChannelConfigBase({ + channelKey: "telegram", + resolvePolicy: (account) => account.config.dmPolicy, + resolveAllowFrom: (account) => account.config.allowFrom, + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => raw.replace(/^(telegram|tg):/i, ""), +}); + export const telegramPlugin: ChannelPlugin = { id: "telegram", meta: { @@ -180,18 +185,7 @@ export const telegramPlugin: ChannelPlugin { - return buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "telegram", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dmPolicy, - allowFrom: account.config.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - normalizeEntry: (raw) => raw.replace(/^(telegram|tg):/i, ""), - }); - }, + resolveDmPolicy: resolveTelegramDmPolicy, collectWarnings: ({ account, cfg }) => { const groupAllowlistConfigured = account.config.groups && Object.keys(account.config.groups).length > 0; @@ -335,47 +329,21 @@ export const telegramPlugin: ChannelPlugin> | undefined; - for (let i = 0; i < mediaUrls.length; i += 1) { - const mediaUrl = mediaUrls[i]; - const isFirst = i === 0; - finalResult = await send(to, isFirst ? text : "", { - ...baseOpts, - mediaUrl, - ...(isFirst ? { buttons: telegramData?.buttons } : {}), - }); - } - return { channel: "telegram", ...(finalResult ?? { messageId: "unknown", chatId: to }) }; + const result = await sendTelegramPayloadMessages({ + send, + to, + payload, + baseOpts: { + verbose: false, + cfg, + mediaLocalRoots, + messageThreadId, + replyToMessageId, + accountId: accountId ?? undefined, + silent: silent ?? undefined, + }, + }); + return { channel: "telegram", ...result }; }, sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, silent }) => { const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram; diff --git a/extensions/tlon/src/onboarding.ts b/extensions/tlon/src/onboarding.ts index 6558dab0257..8207b190628 100644 --- a/extensions/tlon/src/onboarding.ts +++ b/extensions/tlon/src/onboarding.ts @@ -1,6 +1,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/tlon"; import { formatDocsLink, + patchScopedAccountConfig, resolveAccountIdForConfigure, DEFAULT_ACCOUNT_ID, type ChannelOnboardingAdapter, @@ -32,46 +33,30 @@ function applyAccountConfig(params: { }; }): OpenClawConfig { const { cfg, accountId, input } = params; - const useDefault = accountId === DEFAULT_ACCOUNT_ID; - const base = cfg.channels?.tlon ?? {}; const nextValues = { enabled: true, ...(input.name ? { name: input.name } : {}), ...buildTlonAccountFields(input), }; - - if (useDefault) { - return { - ...cfg, - channels: { - ...cfg.channels, - tlon: { - ...base, - ...nextValues, - }, - }, - }; + if (accountId === DEFAULT_ACCOUNT_ID) { + return patchScopedAccountConfig({ + cfg, + channelKey: channel, + accountId, + patch: nextValues, + ensureChannelEnabled: false, + ensureAccountEnabled: false, + }); } - - return { - ...cfg, - channels: { - ...cfg.channels, - tlon: { - ...base, - enabled: base.enabled ?? true, - accounts: { - ...(base as { accounts?: Record }).accounts, - [accountId]: { - ...(base as { accounts?: Record> }).accounts?.[ - accountId - ], - ...nextValues, - }, - }, - }, - }, - }; + return patchScopedAccountConfig({ + cfg, + channelKey: channel, + accountId, + patch: { enabled: cfg.channels?.tlon?.enabled ?? true }, + accountPatch: nextValues, + ensureChannelEnabled: false, + ensureAccountEnabled: false, + }); } async function noteTlonHelp(prompter: WizardPrompter): Promise { diff --git a/extensions/zalo/src/channel.sendpayload.test.ts b/extensions/zalo/src/channel.sendpayload.test.ts index 6cc072ac6dd..27acb737f9f 100644 --- a/extensions/zalo/src/channel.sendpayload.test.ts +++ b/extensions/zalo/src/channel.sendpayload.test.ts @@ -1,5 +1,9 @@ import type { ReplyPayload } from "openclaw/plugin-sdk/zalo"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + installSendPayloadContractSuite, + primeSendMock, +} from "../../../src/test-utils/send-payload-contract.js"; import { zaloPlugin } from "./channel.js"; vi.mock("./send.js", () => ({ @@ -25,78 +29,16 @@ describe("zaloPlugin outbound sendPayload", () => { mockedSend.mockResolvedValue({ ok: true, messageId: "zl-1" }); }); - it("text-only delegates to sendText", async () => { - mockedSend.mockResolvedValue({ ok: true, messageId: "zl-t1" }); - - const result = await zaloPlugin.outbound!.sendPayload!(baseCtx({ text: "hello" })); - - expect(mockedSend).toHaveBeenCalledWith("123456789", "hello", expect.any(Object)); - expect(result).toMatchObject({ channel: "zalo", messageId: "zl-t1" }); - }); - - it("single media delegates to sendMedia", async () => { - mockedSend.mockResolvedValue({ ok: true, messageId: "zl-m1" }); - - const result = await zaloPlugin.outbound!.sendPayload!( - baseCtx({ text: "cap", mediaUrl: "https://example.com/a.jpg" }), - ); - - expect(mockedSend).toHaveBeenCalledWith( - "123456789", - "cap", - expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }), - ); - expect(result).toMatchObject({ channel: "zalo" }); - }); - - it("multi-media iterates URLs with caption on first", async () => { - mockedSend - .mockResolvedValueOnce({ ok: true, messageId: "zl-1" }) - .mockResolvedValueOnce({ ok: true, messageId: "zl-2" }); - - const result = await zaloPlugin.outbound!.sendPayload!( - baseCtx({ - text: "caption", - mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"], - }), - ); - - expect(mockedSend).toHaveBeenCalledTimes(2); - expect(mockedSend).toHaveBeenNthCalledWith( - 1, - "123456789", - "caption", - expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }), - ); - expect(mockedSend).toHaveBeenNthCalledWith( - 2, - "123456789", - "", - expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }), - ); - expect(result).toMatchObject({ channel: "zalo", messageId: "zl-2" }); - }); - - it("empty payload returns no-op", async () => { - const result = await zaloPlugin.outbound!.sendPayload!(baseCtx({})); - - expect(mockedSend).not.toHaveBeenCalled(); - expect(result).toEqual({ channel: "zalo", messageId: "" }); - }); - - it("chunking splits long text", async () => { - mockedSend - .mockResolvedValueOnce({ ok: true, messageId: "zl-c1" }) - .mockResolvedValueOnce({ ok: true, messageId: "zl-c2" }); - - const longText = "a".repeat(3000); - const result = await zaloPlugin.outbound!.sendPayload!(baseCtx({ text: longText })); - - // textChunkLimit is 2000 with chunkTextForOutbound, so it should split - expect(mockedSend.mock.calls.length).toBeGreaterThanOrEqual(2); - for (const call of mockedSend.mock.calls) { - expect((call[1] as string).length).toBeLessThanOrEqual(2000); - } - expect(result).toMatchObject({ channel: "zalo" }); + installSendPayloadContractSuite({ + channel: "zalo", + chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 }, + createHarness: ({ payload, sendResults }) => { + primeSendMock(mockedSend, { ok: true, messageId: "zl-1" }, sendResults); + return { + run: async () => await zaloPlugin.outbound!.sendPayload!(baseCtx(payload)), + sendMock: mockedSend, + to: "123456789", + }; + }, }); }); diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index e4671bb90c1..b374ecfbd63 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -1,8 +1,9 @@ import { buildAccountScopedDmSecurityPolicy, - collectOpenProviderGroupPolicyWarnings, buildOpenGroupPolicyRestrictSendersWarning, buildOpenGroupPolicyWarning, + collectOpenProviderGroupPolicyWarnings, + createAccountStatusSink, mapAllowFromEntries, } from "openclaw/plugin-sdk/compat"; import type { @@ -357,6 +358,10 @@ export const zaloPlugin: ChannelPlugin = { `[${account.accountId}] Zalo probe threw before provider start: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`, ); } + const statusSink = createAccountStatusSink({ + accountId: ctx.accountId, + setStatus: ctx.setStatus, + }); ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel} mode=${mode}`); const { monitorZaloProvider } = await import("./monitor.js"); return monitorZaloProvider({ @@ -370,7 +375,7 @@ export const zaloPlugin: ChannelPlugin = { webhookSecret: normalizeSecretInputString(account.config.webhookSecret), webhookPath: account.config.webhookPath, fetcher, - statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }), + statusSink, }); }, }, diff --git a/extensions/zalo/src/config-schema.ts b/extensions/zalo/src/config-schema.ts index 5f4886cdaf9..253830eb858 100644 --- a/extensions/zalo/src/config-schema.ts +++ b/extensions/zalo/src/config-schema.ts @@ -1,6 +1,8 @@ import { - AllowFromEntrySchema, + AllowFromListSchema, buildCatchallMultiAccountChannelSchema, + DmPolicySchema, + GroupPolicySchema, } from "openclaw/plugin-sdk/compat"; import { MarkdownConfigSchema } from "openclaw/plugin-sdk/zalo"; import { z } from "zod"; @@ -15,10 +17,10 @@ const zaloAccountSchema = z.object({ webhookUrl: z.string().optional(), webhookSecret: buildSecretInputSchema().optional(), webhookPath: z.string().optional(), - dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), - allowFrom: z.array(AllowFromEntrySchema).optional(), - groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(), - groupAllowFrom: z.array(AllowFromEntrySchema).optional(), + dmPolicy: DmPolicySchema.optional(), + allowFrom: AllowFromListSchema, + groupPolicy: GroupPolicySchema.optional(), + groupAllowFrom: AllowFromListSchema, mediaMaxMb: z.number().optional(), proxy: z.string().optional(), responsePrefix: z.string().optional(), diff --git a/extensions/zalo/src/onboarding.ts b/extensions/zalo/src/onboarding.ts index e23765f4f7d..4c6f7cbe4de 100644 --- a/extensions/zalo/src/onboarding.ts +++ b/extensions/zalo/src/onboarding.ts @@ -12,6 +12,7 @@ import { mergeAllowFromEntries, normalizeAccountId, promptSingleChannelSecretInput, + runSingleChannelSecretStep, resolveAccountIdForConfigure, setTopLevelChannelDmPolicyWithAllowFrom, } from "openclaw/plugin-sdk/zalo"; @@ -255,80 +256,66 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = { const hasConfigToken = Boolean( hasConfiguredSecretInput(resolvedAccount.config.botToken) || resolvedAccount.config.tokenFile, ); - const tokenPromptState = buildSingleChannelSecretPromptState({ - accountConfigured, - hasConfigToken, - allowEnv, - envValue: process.env.ZALO_BOT_TOKEN, - }); - - let token: SecretInput | null = null; - if (!accountConfigured) { - await noteZaloTokenHelp(prompter); - } - const tokenResult = await promptSingleChannelSecretInput({ + const tokenStep = await runSingleChannelSecretStep({ cfg: next, prompter, providerHint: "zalo", credentialLabel: "bot token", - accountConfigured: tokenPromptState.accountConfigured, - canUseEnv: tokenPromptState.canUseEnv, - hasConfigToken: tokenPromptState.hasConfigToken, + accountConfigured, + hasConfigToken, + allowEnv, + envValue: process.env.ZALO_BOT_TOKEN, envPrompt: "ZALO_BOT_TOKEN detected. Use env var?", keepPrompt: "Zalo token already configured. Keep it?", inputPrompt: "Enter Zalo bot token", preferredEnvVar: "ZALO_BOT_TOKEN", - }); - if (tokenResult.action === "set") { - token = tokenResult.value; - } - if (tokenResult.action === "use-env" && zaloAccountId === DEFAULT_ACCOUNT_ID) { - next = { - ...next, - channels: { - ...next.channels, - zalo: { - ...next.channels?.zalo, - enabled: true, - }, - }, - } as OpenClawConfig; - } - - if (token) { - if (zaloAccountId === DEFAULT_ACCOUNT_ID) { - next = { - ...next, - channels: { - ...next.channels, - zalo: { - ...next.channels?.zalo, - enabled: true, - botToken: token, - }, - }, - } as OpenClawConfig; - } else { - next = { - ...next, - channels: { - ...next.channels, - zalo: { - ...next.channels?.zalo, - enabled: true, - accounts: { - ...next.channels?.zalo?.accounts, - [zaloAccountId]: { - ...next.channels?.zalo?.accounts?.[zaloAccountId], + onMissingConfigured: async () => await noteZaloTokenHelp(prompter), + applyUseEnv: async (cfg) => + zaloAccountId === DEFAULT_ACCOUNT_ID + ? ({ + ...cfg, + channels: { + ...cfg.channels, + zalo: { + ...cfg.channels?.zalo, enabled: true, - botToken: token, }, }, - }, - }, - } as OpenClawConfig; - } - } + } as OpenClawConfig) + : cfg, + applySet: async (cfg, value) => + zaloAccountId === DEFAULT_ACCOUNT_ID + ? ({ + ...cfg, + channels: { + ...cfg.channels, + zalo: { + ...cfg.channels?.zalo, + enabled: true, + botToken: value, + }, + }, + } as OpenClawConfig) + : ({ + ...cfg, + channels: { + ...cfg.channels, + zalo: { + ...cfg.channels?.zalo, + enabled: true, + accounts: { + ...cfg.channels?.zalo?.accounts, + [zaloAccountId]: { + ...cfg.channels?.zalo?.accounts?.[zaloAccountId], + enabled: true, + botToken: value, + }, + }, + }, + }, + } as OpenClawConfig), + }); + next = tokenStep.cfg; const wantsWebhook = await prompter.confirm({ message: "Use webhook mode for Zalo?", diff --git a/extensions/zalo/src/token.test.ts b/extensions/zalo/src/token.test.ts index d6b02f30483..ff3e84ce293 100644 --- a/extensions/zalo/src/token.test.ts +++ b/extensions/zalo/src/token.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import { resolveZaloToken } from "./token.js"; import type { ZaloConfig } from "./types.js"; @@ -55,4 +58,20 @@ describe("resolveZaloToken", () => { expect(res.token).toBe("work-token"); expect(res.source).toBe("config"); }); + + it.runIf(process.platform !== "win32")("rejects symlinked token files", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-zalo-token-")); + const tokenFile = path.join(dir, "token.txt"); + const tokenLink = path.join(dir, "token-link.txt"); + fs.writeFileSync(tokenFile, "file-token\n", "utf8"); + fs.symlinkSync(tokenFile, tokenLink); + + const cfg = { + tokenFile: tokenLink, + } as ZaloConfig; + const res = resolveZaloToken(cfg); + expect(res.token).toBe(""); + expect(res.source).toBe("none"); + fs.rmSync(dir, { recursive: true, force: true }); + }); }); diff --git a/extensions/zalo/src/token.ts b/extensions/zalo/src/token.ts index 00ed1d720f7..10a4aca6cd1 100644 --- a/extensions/zalo/src/token.ts +++ b/extensions/zalo/src/token.ts @@ -1,5 +1,5 @@ -import { readFileSync } from "node:fs"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; +import { tryReadSecretFileSync } from "openclaw/plugin-sdk/core"; import type { BaseTokenResolution } from "openclaw/plugin-sdk/zalo"; import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "./secret-input.js"; import type { ZaloConfig } from "./types.js"; @@ -9,16 +9,7 @@ export type ZaloTokenResolution = BaseTokenResolution & { }; function readTokenFromFile(tokenFile: string | undefined): string { - const trimmedPath = tokenFile?.trim(); - if (!trimmedPath) { - return ""; - } - try { - return readFileSync(trimmedPath, "utf8").trim(); - } catch { - // ignore read failures - return ""; - } + return tryReadSecretFileSync(tokenFile, "Zalo token file", { rejectSymlink: true }) ?? ""; } export function resolveZaloToken( diff --git a/extensions/zalouser/src/channel.sendpayload.test.ts b/extensions/zalouser/src/channel.sendpayload.test.ts index 534f9c39b95..0cef65f8c05 100644 --- a/extensions/zalouser/src/channel.sendpayload.test.ts +++ b/extensions/zalouser/src/channel.sendpayload.test.ts @@ -1,5 +1,9 @@ import type { ReplyPayload } from "openclaw/plugin-sdk/zalouser"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + installSendPayloadContractSuite, + primeSendMock, +} from "../../../src/test-utils/send-payload-contract.js"; import { zalouserPlugin } from "./channel.js"; vi.mock("./send.js", () => ({ @@ -40,15 +44,6 @@ describe("zalouserPlugin outbound sendPayload", () => { mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-1" }); }); - it("text-only delegates to sendText", async () => { - mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-t1" }); - - const result = await zalouserPlugin.outbound!.sendPayload!(baseCtx({ text: "hello" })); - - expect(mockedSend).toHaveBeenCalledWith("987654321", "hello", expect.any(Object)); - expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-t1" }); - }); - it("group target delegates with isGroup=true and stripped threadId", async () => { mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-g1" }); @@ -65,21 +60,6 @@ describe("zalouserPlugin outbound sendPayload", () => { expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-g1" }); }); - it("single media delegates to sendMedia", async () => { - mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-m1" }); - - const result = await zalouserPlugin.outbound!.sendPayload!( - baseCtx({ text: "cap", mediaUrl: "https://example.com/a.jpg" }), - ); - - expect(mockedSend).toHaveBeenCalledWith( - "987654321", - "cap", - expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }), - ); - expect(result).toMatchObject({ channel: "zalouser" }); - }); - it("treats bare numeric targets as direct chats for backward compatibility", async () => { mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-d1" }); @@ -112,55 +92,17 @@ describe("zalouserPlugin outbound sendPayload", () => { expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-g-native" }); }); - it("multi-media iterates URLs with caption on first", async () => { - mockedSend - .mockResolvedValueOnce({ ok: true, messageId: "zlu-1" }) - .mockResolvedValueOnce({ ok: true, messageId: "zlu-2" }); - - const result = await zalouserPlugin.outbound!.sendPayload!( - baseCtx({ - text: "caption", - mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"], - }), - ); - - expect(mockedSend).toHaveBeenCalledTimes(2); - expect(mockedSend).toHaveBeenNthCalledWith( - 1, - "987654321", - "caption", - expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }), - ); - expect(mockedSend).toHaveBeenNthCalledWith( - 2, - "987654321", - "", - expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }), - ); - expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-2" }); - }); - - it("empty payload returns no-op", async () => { - const result = await zalouserPlugin.outbound!.sendPayload!(baseCtx({})); - - expect(mockedSend).not.toHaveBeenCalled(); - expect(result).toEqual({ channel: "zalouser", messageId: "" }); - }); - - it("chunking splits long text", async () => { - mockedSend - .mockResolvedValueOnce({ ok: true, messageId: "zlu-c1" }) - .mockResolvedValueOnce({ ok: true, messageId: "zlu-c2" }); - - const longText = "a".repeat(3000); - const result = await zalouserPlugin.outbound!.sendPayload!(baseCtx({ text: longText })); - - // textChunkLimit is 2000 with chunkTextForOutbound, so it should split - expect(mockedSend.mock.calls.length).toBeGreaterThanOrEqual(2); - for (const call of mockedSend.mock.calls) { - expect((call[1] as string).length).toBeLessThanOrEqual(2000); - } - expect(result).toMatchObject({ channel: "zalouser" }); + installSendPayloadContractSuite({ + channel: "zalouser", + chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 }, + createHarness: ({ payload, sendResults }) => { + primeSendMock(mockedSend, { ok: true, messageId: "zlu-1" }, sendResults); + return { + run: async () => await zalouserPlugin.outbound!.sendPayload!(baseCtx(payload)), + sendMock: mockedSend, + to: "987654321", + }; + }, }); }); diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index e01775d0dbb..2091124be6e 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -1,5 +1,6 @@ import { buildAccountScopedDmSecurityPolicy, + createAccountStatusSink, mapAllowFromEntries, } from "openclaw/plugin-sdk/compat"; import type { @@ -682,6 +683,10 @@ export const zalouserPlugin: ChannelPlugin = { } catch { // ignore probe errors } + const statusSink = createAccountStatusSink({ + accountId: ctx.accountId, + setStatus: ctx.setStatus, + }); ctx.log?.info(`[${account.accountId}] starting zalouser provider${userLabel}`); const { monitorZalouserProvider } = await import("./monitor.js"); return monitorZalouserProvider({ @@ -689,7 +694,7 @@ export const zalouserPlugin: ChannelPlugin = { config: ctx.cfg, runtime: ctx.runtime, abortSignal: ctx.abortSignal, - statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }), + statusSink, }); }, loginWithQrStart: async (params) => { diff --git a/extensions/zalouser/src/config-schema.ts b/extensions/zalouser/src/config-schema.ts index e5cb64d012e..4879a2d46cd 100644 --- a/extensions/zalouser/src/config-schema.ts +++ b/extensions/zalouser/src/config-schema.ts @@ -1,6 +1,8 @@ import { - AllowFromEntrySchema, + AllowFromListSchema, buildCatchallMultiAccountChannelSchema, + DmPolicySchema, + GroupPolicySchema, } from "openclaw/plugin-sdk/compat"; import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/zalouser"; import { z } from "zod"; @@ -17,11 +19,11 @@ const zalouserAccountSchema = z.object({ enabled: z.boolean().optional(), markdown: MarkdownConfigSchema, profile: z.string().optional(), - dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), - allowFrom: z.array(AllowFromEntrySchema).optional(), + dmPolicy: DmPolicySchema.optional(), + allowFrom: AllowFromListSchema, historyLimit: z.number().int().min(0).optional(), - groupAllowFrom: z.array(AllowFromEntrySchema).optional(), - groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(), + groupAllowFrom: AllowFromListSchema, + groupPolicy: GroupPolicySchema.optional(), groups: z.object({}).catchall(groupConfigSchema).optional(), messagePrefix: z.string().optional(), responsePrefix: z.string().optional(), diff --git a/extensions/zalouser/src/onboarding.ts b/extensions/zalouser/src/onboarding.ts index ae8f53bf0d5..d5b828b6711 100644 --- a/extensions/zalouser/src/onboarding.ts +++ b/extensions/zalouser/src/onboarding.ts @@ -9,6 +9,7 @@ import { formatResolvedUnresolvedNote, mergeAllowFromEntries, normalizeAccountId, + patchScopedAccountConfig, promptChannelAccessConfig, resolveAccountIdForConfigure, setTopLevelChannelDmPolicyWithAllowFrom, @@ -36,37 +37,13 @@ function setZalouserAccountScopedConfig( defaultPatch: Record, accountPatch: Record = defaultPatch, ): OpenClawConfig { - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - channels: { - ...cfg.channels, - zalouser: { - ...cfg.channels?.zalouser, - enabled: true, - ...defaultPatch, - }, - }, - } as OpenClawConfig; - } - return { - ...cfg, - channels: { - ...cfg.channels, - zalouser: { - ...cfg.channels?.zalouser, - enabled: true, - accounts: { - ...cfg.channels?.zalouser?.accounts, - [accountId]: { - ...cfg.channels?.zalouser?.accounts?.[accountId], - enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true, - ...accountPatch, - }, - }, - }, - }, - } as OpenClawConfig; + return patchScopedAccountConfig({ + cfg, + channelKey: channel, + accountId, + patch: defaultPatch, + accountPatch, + }) as OpenClawConfig; } function setZalouserDmPolicy( diff --git a/package.json b/package.json index bc625b74e71..695bad9d076 100644 --- a/package.json +++ b/package.json @@ -295,9 +295,11 @@ "protocol:gen": "node --import tsx scripts/protocol-gen.ts", "protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts", "release:check": "node --import tsx scripts/release-check.ts", + "release:openclaw:npm:check": "node --import tsx scripts/openclaw-npm-release-check.ts", "start": "node scripts/run-node.mjs", "test": "node scripts/test-parallel.mjs", "test:all": "pnpm lint && pnpm build && pnpm test && pnpm test:e2e && pnpm test:live && pnpm test:docker:all", + "test:auth:compat": "vitest run --config vitest.gateway.config.ts src/gateway/server.auth.compat-baseline.test.ts src/gateway/client.test.ts src/gateway/reconnect-gating.test.ts src/gateway/protocol/connect-error-details.test.ts", "test:channels": "vitest run --config vitest.channels.config.ts", "test:coverage": "vitest run --config vitest.unit.config.ts --coverage", "test:docker:all": "pnpm test:docker:live-models && pnpm test:docker:live-gateway && pnpm test:docker:onboard && pnpm test:docker:gateway-network && pnpm test:docker:qr && pnpm test:docker:doctor-switch && pnpm test:docker:plugins && pnpm test:docker:cleanup", diff --git a/scripts/openclaw-npm-release-check.ts b/scripts/openclaw-npm-release-check.ts new file mode 100644 index 00000000000..267558a0d0d --- /dev/null +++ b/scripts/openclaw-npm-release-check.ts @@ -0,0 +1,251 @@ +#!/usr/bin/env -S node --import tsx + +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +type PackageJson = { + name?: string; + version?: string; + description?: string; + license?: string; + repository?: { url?: string } | string; + bin?: Record; +}; + +export type ParsedReleaseVersion = { + version: string; + channel: "stable" | "beta"; + year: number; + month: number; + day: number; + betaNumber?: number; + date: Date; +}; + +const STABLE_VERSION_REGEX = /^(?\d{4})\.(?[1-9]\d?)\.(?[1-9]\d?)$/; +const BETA_VERSION_REGEX = + /^(?\d{4})\.(?[1-9]\d?)\.(?[1-9]\d?)-beta\.(?[1-9]\d*)$/; +const EXPECTED_REPOSITORY_URL = "https://github.com/openclaw/openclaw"; +const MAX_CALVER_DISTANCE_DAYS = 2; + +function normalizeRepoUrl(value: unknown): string { + if (typeof value !== "string") { + return ""; + } + + return value + .trim() + .replace(/^git\+/, "") + .replace(/\.git$/i, "") + .replace(/\/+$/, ""); +} + +function parseDateParts( + version: string, + groups: Record, + channel: "stable" | "beta", +): ParsedReleaseVersion | null { + const year = Number.parseInt(groups.year ?? "", 10); + const month = Number.parseInt(groups.month ?? "", 10); + const day = Number.parseInt(groups.day ?? "", 10); + const betaNumber = channel === "beta" ? Number.parseInt(groups.beta ?? "", 10) : undefined; + + if ( + !Number.isInteger(year) || + !Number.isInteger(month) || + !Number.isInteger(day) || + month < 1 || + month > 12 || + day < 1 || + day > 31 + ) { + return null; + } + if (channel === "beta" && (!Number.isInteger(betaNumber) || (betaNumber ?? 0) < 1)) { + return null; + } + + const date = new Date(Date.UTC(year, month - 1, day)); + if ( + date.getUTCFullYear() !== year || + date.getUTCMonth() !== month - 1 || + date.getUTCDate() !== day + ) { + return null; + } + + return { + version, + channel, + year, + month, + day, + betaNumber, + date, + }; +} + +export function parseReleaseVersion(version: string): ParsedReleaseVersion | null { + const trimmed = version.trim(); + if (!trimmed) { + return null; + } + + const stableMatch = STABLE_VERSION_REGEX.exec(trimmed); + if (stableMatch?.groups) { + return parseDateParts(trimmed, stableMatch.groups, "stable"); + } + + const betaMatch = BETA_VERSION_REGEX.exec(trimmed); + if (betaMatch?.groups) { + return parseDateParts(trimmed, betaMatch.groups, "beta"); + } + + return null; +} + +function startOfUtcDay(date: Date): number { + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()); +} + +export function utcCalendarDayDistance(left: Date, right: Date): number { + return Math.round(Math.abs(startOfUtcDay(left) - startOfUtcDay(right)) / 86_400_000); +} + +export function collectReleasePackageMetadataErrors(pkg: PackageJson): string[] { + const actualRepositoryUrl = normalizeRepoUrl( + typeof pkg.repository === "string" ? pkg.repository : pkg.repository?.url, + ); + const errors: string[] = []; + + if (pkg.name !== "openclaw") { + errors.push(`package.json name must be "openclaw"; found "${pkg.name ?? ""}".`); + } + if (!pkg.description?.trim()) { + errors.push("package.json description must be non-empty."); + } + if (pkg.license !== "MIT") { + errors.push(`package.json license must be "MIT"; found "${pkg.license ?? ""}".`); + } + if (actualRepositoryUrl !== EXPECTED_REPOSITORY_URL) { + errors.push( + `package.json repository.url must resolve to ${EXPECTED_REPOSITORY_URL}; found ${ + actualRepositoryUrl || "" + }.`, + ); + } + if (pkg.bin?.openclaw !== "openclaw.mjs") { + errors.push( + `package.json bin.openclaw must be "openclaw.mjs"; found "${pkg.bin?.openclaw ?? ""}".`, + ); + } + + return errors; +} + +export function collectReleaseTagErrors(params: { + packageVersion: string; + releaseTag: string; + releaseSha?: string; + releaseMainRef?: string; + now?: Date; +}): string[] { + const errors: string[] = []; + const releaseTag = params.releaseTag.trim(); + const packageVersion = params.packageVersion.trim(); + const now = params.now ?? new Date(); + + const parsedVersion = parseReleaseVersion(packageVersion); + if (parsedVersion === null) { + errors.push( + `package.json version must match YYYY.M.D or YYYY.M.D-beta.N; found "${packageVersion || ""}".`, + ); + } + + if (!releaseTag.startsWith("v")) { + errors.push(`Release tag must start with "v"; found "${releaseTag || ""}".`); + } + + const tagVersion = releaseTag.startsWith("v") ? releaseTag.slice(1) : releaseTag; + const parsedTag = parseReleaseVersion(tagVersion); + if (parsedTag === null) { + errors.push( + `Release tag must match vYYYY.M.D or vYYYY.M.D-beta.N; found "${releaseTag || ""}".`, + ); + } + + const expectedTag = packageVersion ? `v${packageVersion}` : ""; + if (releaseTag !== expectedTag) { + errors.push( + `Release tag ${releaseTag || ""} does not match package.json version ${ + packageVersion || "" + }; expected ${expectedTag || ""}.`, + ); + } + + if (parsedVersion !== null) { + const dayDistance = utcCalendarDayDistance(parsedVersion.date, now); + if (dayDistance > MAX_CALVER_DISTANCE_DAYS) { + const nowLabel = now.toISOString().slice(0, 10); + const versionDate = parsedVersion.date.toISOString().slice(0, 10); + errors.push( + `Release version ${packageVersion} is ${dayDistance} days away from current UTC date ${nowLabel}; release CalVer date ${versionDate} must be within ${MAX_CALVER_DISTANCE_DAYS} days.`, + ); + } + } + + if (params.releaseSha?.trim() && params.releaseMainRef?.trim()) { + try { + execFileSync( + "git", + ["merge-base", "--is-ancestor", params.releaseSha, params.releaseMainRef], + { stdio: "ignore" }, + ); + } catch { + errors.push( + `Tagged commit ${params.releaseSha} is not contained in ${params.releaseMainRef}.`, + ); + } + } + + return errors; +} + +function loadPackageJson(): PackageJson { + return JSON.parse(readFileSync("package.json", "utf8")) as PackageJson; +} + +function main(): number { + const pkg = loadPackageJson(); + const metadataErrors = collectReleasePackageMetadataErrors(pkg); + const tagErrors = collectReleaseTagErrors({ + packageVersion: pkg.version ?? "", + releaseTag: process.env.RELEASE_TAG ?? "", + releaseSha: process.env.RELEASE_SHA, + releaseMainRef: process.env.RELEASE_MAIN_REF, + }); + const errors = [...metadataErrors, ...tagErrors]; + + if (errors.length > 0) { + for (const error of errors) { + console.error(`openclaw-npm-release-check: ${error}`); + } + return 1; + } + + const parsedVersion = parseReleaseVersion(pkg.version ?? ""); + const channel = parsedVersion?.channel ?? "unknown"; + const dayDistance = + parsedVersion === null + ? "unknown" + : String(utcCalendarDayDistance(parsedVersion.date, new Date())); + console.log( + `openclaw-npm-release-check: validated ${channel} release ${pkg.version} (${dayDistance} day UTC delta).`, + ); + return 0; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + process.exit(main()); +} diff --git a/src/acp/client.test.ts b/src/acp/client.test.ts index cbb52bd73cc..0cbc376720c 100644 --- a/src/acp/client.test.ts +++ b/src/acp/client.test.ts @@ -4,9 +4,11 @@ import type { RequestPermissionRequest } from "@agentclientprotocol/sdk"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js"; import { + buildAcpClientStripKeys, resolveAcpClientSpawnEnv, resolveAcpClientSpawnInvocation, resolvePermissionRequest, + shouldStripProviderAuthEnvVarsForAcpServer, } from "./client.js"; import { extractAttachmentsFromPrompt, extractTextFromPrompt } from "./event-mapper.js"; @@ -110,6 +112,120 @@ describe("resolveAcpClientSpawnEnv", () => { expect(env.OPENCLAW_SHELL).toBe("acp-client"); expect(env.OPENAI_API_KEY).toBeUndefined(); }); + + it("strips provider auth env vars for the default OpenClaw bridge", () => { + const stripKeys = new Set(["OPENAI_API_KEY", "GITHUB_TOKEN", "HF_TOKEN"]); + const env = resolveAcpClientSpawnEnv( + { + OPENAI_API_KEY: "openai-secret", // pragma: allowlist secret + GITHUB_TOKEN: "gh-secret", // pragma: allowlist secret + HF_TOKEN: "hf-secret", // pragma: allowlist secret + OPENCLAW_API_KEY: "keep-me", + PATH: "/usr/bin", + }, + { stripKeys }, + ); + + expect(env.OPENAI_API_KEY).toBeUndefined(); + expect(env.GITHUB_TOKEN).toBeUndefined(); + expect(env.HF_TOKEN).toBeUndefined(); + expect(env.OPENCLAW_API_KEY).toBe("keep-me"); + expect(env.PATH).toBe("/usr/bin"); + expect(env.OPENCLAW_SHELL).toBe("acp-client"); + }); + + it("strips provider auth env vars case-insensitively", () => { + const env = resolveAcpClientSpawnEnv( + { + OpenAI_Api_Key: "openai-secret", // pragma: allowlist secret + Github_Token: "gh-secret", // pragma: allowlist secret + OPENCLAW_API_KEY: "keep-me", + }, + { stripKeys: new Set(["OPENAI_API_KEY", "GITHUB_TOKEN"]) }, + ); + + expect(env.OpenAI_Api_Key).toBeUndefined(); + expect(env.Github_Token).toBeUndefined(); + expect(env.OPENCLAW_API_KEY).toBe("keep-me"); + expect(env.OPENCLAW_SHELL).toBe("acp-client"); + }); + + it("preserves provider auth env vars for explicit custom ACP servers", () => { + const env = resolveAcpClientSpawnEnv({ + OPENAI_API_KEY: "openai-secret", // pragma: allowlist secret + GITHUB_TOKEN: "gh-secret", // pragma: allowlist secret + HF_TOKEN: "hf-secret", // pragma: allowlist secret + OPENCLAW_API_KEY: "keep-me", + }); + + expect(env.OPENAI_API_KEY).toBe("openai-secret"); + expect(env.GITHUB_TOKEN).toBe("gh-secret"); + expect(env.HF_TOKEN).toBe("hf-secret"); + expect(env.OPENCLAW_API_KEY).toBe("keep-me"); + expect(env.OPENCLAW_SHELL).toBe("acp-client"); + }); +}); + +describe("shouldStripProviderAuthEnvVarsForAcpServer", () => { + it("strips provider auth env vars for the default bridge", () => { + expect(shouldStripProviderAuthEnvVarsForAcpServer()).toBe(true); + expect( + shouldStripProviderAuthEnvVarsForAcpServer({ + serverCommand: "openclaw", + serverArgs: ["acp"], + defaultServerCommand: "openclaw", + defaultServerArgs: ["acp"], + }), + ).toBe(true); + }); + + it("preserves provider auth env vars for explicit custom ACP servers", () => { + expect( + shouldStripProviderAuthEnvVarsForAcpServer({ + serverCommand: "custom-acp-server", + serverArgs: ["serve"], + defaultServerCommand: "openclaw", + defaultServerArgs: ["acp"], + }), + ).toBe(false); + }); + + it("preserves provider auth env vars when an explicit override uses the default executable with different args", () => { + expect( + shouldStripProviderAuthEnvVarsForAcpServer({ + serverCommand: process.execPath, + serverArgs: ["custom-entry.js"], + defaultServerCommand: process.execPath, + defaultServerArgs: ["dist/entry.js", "acp"], + }), + ).toBe(false); + }); +}); + +describe("buildAcpClientStripKeys", () => { + it("always includes active skill env keys", () => { + const stripKeys = buildAcpClientStripKeys({ + stripProviderAuthEnvVars: false, + activeSkillEnvKeys: ["SKILL_SECRET", "OPENAI_API_KEY"], + }); + + expect(stripKeys.has("SKILL_SECRET")).toBe(true); + expect(stripKeys.has("OPENAI_API_KEY")).toBe(true); + expect(stripKeys.has("GITHUB_TOKEN")).toBe(false); + }); + + it("adds provider auth env vars for the default bridge", () => { + const stripKeys = buildAcpClientStripKeys({ + stripProviderAuthEnvVars: true, + activeSkillEnvKeys: ["SKILL_SECRET"], + }); + + expect(stripKeys.has("SKILL_SECRET")).toBe(true); + expect(stripKeys.has("OPENAI_API_KEY")).toBe(true); + expect(stripKeys.has("GITHUB_TOKEN")).toBe(true); + expect(stripKeys.has("HF_TOKEN")).toBe(true); + expect(stripKeys.has("OPENCLAW_API_KEY")).toBe(false); + }); }); describe("resolveAcpClientSpawnInvocation", () => { diff --git a/src/acp/client.ts b/src/acp/client.ts index 54be5ffc455..2f3ac28641a 100644 --- a/src/acp/client.ts +++ b/src/acp/client.ts @@ -19,6 +19,10 @@ import { materializeWindowsSpawnProgram, resolveWindowsSpawnProgram, } from "../plugin-sdk/windows-spawn.js"; +import { + listKnownProviderAuthEnvVarNames, + omitEnvKeysCaseInsensitive, +} from "../secrets/provider-env-vars.js"; import { DANGEROUS_ACP_TOOLS } from "../security/dangerous-tools.js"; const SAFE_AUTO_APPROVE_TOOL_IDS = new Set(["read", "search", "web_search", "memory_search"]); @@ -346,20 +350,56 @@ function buildServerArgs(opts: AcpClientOptions): string[] { return args; } +type AcpClientSpawnEnvOptions = { + stripKeys?: Iterable; +}; + export function resolveAcpClientSpawnEnv( baseEnv: NodeJS.ProcessEnv = process.env, - options?: { stripKeys?: ReadonlySet }, + options: AcpClientSpawnEnvOptions = {}, ): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = { ...baseEnv }; - if (options?.stripKeys) { - for (const key of options.stripKeys) { - delete env[key]; - } - } + const env = omitEnvKeysCaseInsensitive(baseEnv, options.stripKeys ?? []); env.OPENCLAW_SHELL = "acp-client"; return env; } +export function shouldStripProviderAuthEnvVarsForAcpServer( + params: { + serverCommand?: string; + serverArgs?: string[]; + defaultServerCommand?: string; + defaultServerArgs?: string[]; + } = {}, +): boolean { + const serverCommand = params.serverCommand?.trim(); + if (!serverCommand) { + return true; + } + const defaultServerCommand = params.defaultServerCommand?.trim(); + if (!defaultServerCommand || serverCommand !== defaultServerCommand) { + return false; + } + const serverArgs = params.serverArgs ?? []; + const defaultServerArgs = params.defaultServerArgs ?? []; + return ( + serverArgs.length === defaultServerArgs.length && + serverArgs.every((arg, index) => arg === defaultServerArgs[index]) + ); +} + +export function buildAcpClientStripKeys(params: { + stripProviderAuthEnvVars?: boolean; + activeSkillEnvKeys?: Iterable; +}): Set { + const stripKeys = new Set(params.activeSkillEnvKeys ?? []); + if (params.stripProviderAuthEnvVars) { + for (const key of listKnownProviderAuthEnvVarNames()) { + stripKeys.add(key); + } + } + return stripKeys; +} + type AcpSpawnRuntime = { platform: NodeJS.Platform; env: NodeJS.ProcessEnv; @@ -456,12 +496,22 @@ export async function createAcpClient(opts: AcpClientOptions = {}): Promise tempDirs.make("openclaw-secret-file-test-"); - -afterEach(async () => { - await tempDirs.cleanup(); -}); - describe("readSecretFromFile", () => { - it("reads and trims a regular secret file", async () => { - const dir = await createTempDir(); - const file = path.join(dir, "secret.txt"); - await writeFile(file, " top-secret \n", "utf8"); - - expect(readSecretFromFile(file, "Gateway password")).toBe("top-secret"); + it("keeps the shared secret-file limit", () => { + expect(MAX_SECRET_FILE_BYTES).toBe(16 * 1024); }); - it("rejects files larger than the secret-file limit", async () => { - const dir = await createTempDir(); - const file = path.join(dir, "secret.txt"); - await writeFile(file, "x".repeat(MAX_SECRET_FILE_BYTES + 1), "utf8"); - - expect(() => readSecretFromFile(file, "Gateway password")).toThrow( - `Gateway password file at ${file} exceeds ${MAX_SECRET_FILE_BYTES} bytes.`, - ); - }); - - it("rejects non-regular files", async () => { - const dir = await createTempDir(); - const nestedDir = path.join(dir, "secret-dir"); - await mkdir(nestedDir); - - expect(() => readSecretFromFile(nestedDir, "Gateway password")).toThrow( - `Gateway password file at ${nestedDir} must be a regular file.`, - ); - }); - - it("rejects symlinks", async () => { - const dir = await createTempDir(); - const target = path.join(dir, "target.txt"); - const link = path.join(dir, "secret-link.txt"); - await writeFile(target, "top-secret\n", "utf8"); - await symlink(target, link); - - expect(() => readSecretFromFile(link, "Gateway password")).toThrow( - `Gateway password file at ${link} must not be a symlink.`, - ); + it("exposes the hardened secret reader", () => { + expect(typeof readSecretFromFile).toBe("function"); }); }); diff --git a/src/acp/secret-file.ts b/src/acp/secret-file.ts index 45ec36d28cb..902e0fc0627 100644 --- a/src/acp/secret-file.ts +++ b/src/acp/secret-file.ts @@ -1,43 +1,10 @@ -import fs from "node:fs"; -import { resolveUserPath } from "../utils.js"; +import { DEFAULT_SECRET_FILE_MAX_BYTES, readSecretFileSync } from "../infra/secret-file.js"; -export const MAX_SECRET_FILE_BYTES = 16 * 1024; +export const MAX_SECRET_FILE_BYTES = DEFAULT_SECRET_FILE_MAX_BYTES; export function readSecretFromFile(filePath: string, label: string): string { - const resolvedPath = resolveUserPath(filePath.trim()); - if (!resolvedPath) { - throw new Error(`${label} file path is empty.`); - } - - let stat: fs.Stats; - try { - stat = fs.lstatSync(resolvedPath); - } catch (err) { - throw new Error(`Failed to inspect ${label} file at ${resolvedPath}: ${String(err)}`, { - cause: err, - }); - } - if (stat.isSymbolicLink()) { - throw new Error(`${label} file at ${resolvedPath} must not be a symlink.`); - } - if (!stat.isFile()) { - throw new Error(`${label} file at ${resolvedPath} must be a regular file.`); - } - if (stat.size > MAX_SECRET_FILE_BYTES) { - throw new Error(`${label} file at ${resolvedPath} exceeds ${MAX_SECRET_FILE_BYTES} bytes.`); - } - - let raw = ""; - try { - raw = fs.readFileSync(resolvedPath, "utf8"); - } catch (err) { - throw new Error(`Failed to read ${label} file at ${resolvedPath}: ${String(err)}`, { - cause: err, - }); - } - const secret = raw.trim(); - if (!secret) { - throw new Error(`${label} file at ${resolvedPath} is empty.`); - } - return secret; + return readSecretFileSync(filePath, label, { + maxBytes: MAX_SECRET_FILE_BYTES, + rejectSymlink: true, + }); } diff --git a/src/acp/translator.cancel-scoping.test.ts b/src/acp/translator.cancel-scoping.test.ts new file mode 100644 index 00000000000..c84832369a0 --- /dev/null +++ b/src/acp/translator.cancel-scoping.test.ts @@ -0,0 +1,274 @@ +import type { CancelNotification, PromptRequest, PromptResponse } from "@agentclientprotocol/sdk"; +import { describe, expect, it, vi } from "vitest"; +import type { GatewayClient } from "../gateway/client.js"; +import type { EventFrame } from "../gateway/protocol/index.js"; +import { createInMemorySessionStore } from "./session.js"; +import { AcpGatewayAgent } from "./translator.js"; +import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js"; + +type Harness = { + agent: AcpGatewayAgent; + requestSpy: ReturnType; + sessionUpdateSpy: ReturnType; + sessionStore: ReturnType; + sentRunIds: string[]; +}; + +function createPromptRequest(sessionId: string): PromptRequest { + return { + sessionId, + prompt: [{ type: "text", text: "hello" }], + _meta: {}, + } as unknown as PromptRequest; +} + +function createChatEvent(payload: Record): EventFrame { + return { + type: "event", + event: "chat", + payload, + } as EventFrame; +} + +function createToolEvent(payload: Record): EventFrame { + return { + type: "event", + event: "agent", + payload, + } as EventFrame; +} + +function createHarness(sessions: Array<{ sessionId: string; sessionKey: string }>): Harness { + const sentRunIds: string[] = []; + const requestSpy = vi.fn(async (method: string, params?: Record) => { + if (method === "chat.send") { + const runId = params?.idempotencyKey; + if (typeof runId === "string") { + sentRunIds.push(runId); + } + return new Promise(() => {}); + } + return {}; + }); + const connection = createAcpConnection(); + const sessionStore = createInMemorySessionStore(); + for (const session of sessions) { + sessionStore.createSession({ + sessionId: session.sessionId, + sessionKey: session.sessionKey, + cwd: "/tmp", + }); + } + + const agent = new AcpGatewayAgent( + connection, + createAcpGateway(requestSpy as unknown as GatewayClient["request"]), + { sessionStore }, + ); + + return { + agent, + requestSpy, + // eslint-disable-next-line @typescript-eslint/unbound-method + sessionUpdateSpy: connection.sessionUpdate as unknown as ReturnType, + sessionStore, + sentRunIds, + }; +} + +async function startPendingPrompt( + harness: Harness, + sessionId: string, +): Promise<{ promptPromise: Promise; runId: string }> { + const before = harness.sentRunIds.length; + const promptPromise = harness.agent.prompt(createPromptRequest(sessionId)); + await vi.waitFor(() => { + expect(harness.sentRunIds.length).toBe(before + 1); + }); + return { + promptPromise, + runId: harness.sentRunIds[before], + }; +} + +describe("acp translator cancel and run scoping", () => { + it("cancel passes active runId to chat.abort", async () => { + const sessionKey = "agent:main:shared"; + const harness = createHarness([{ sessionId: "session-1", sessionKey }]); + const pending = await startPendingPrompt(harness, "session-1"); + + await harness.agent.cancel({ sessionId: "session-1" } as CancelNotification); + + expect(harness.requestSpy).toHaveBeenCalledWith("chat.abort", { + sessionKey, + runId: pending.runId, + }); + await expect(pending.promptPromise).resolves.toEqual({ stopReason: "cancelled" }); + }); + + it("cancel uses pending runId when there is no active run", async () => { + const sessionKey = "agent:main:shared"; + const harness = createHarness([{ sessionId: "session-1", sessionKey }]); + const pending = await startPendingPrompt(harness, "session-1"); + harness.sessionStore.clearActiveRun("session-1"); + + await harness.agent.cancel({ sessionId: "session-1" } as CancelNotification); + + expect(harness.requestSpy).toHaveBeenCalledWith("chat.abort", { + sessionKey, + runId: pending.runId, + }); + await expect(pending.promptPromise).resolves.toEqual({ stopReason: "cancelled" }); + }); + + it("cancel skips chat.abort when there is no active run and no pending prompt", async () => { + const sessionKey = "agent:main:shared"; + const harness = createHarness([{ sessionId: "session-1", sessionKey }]); + + await harness.agent.cancel({ sessionId: "session-1" } as CancelNotification); + + const abortCalls = harness.requestSpy.mock.calls.filter(([method]) => method === "chat.abort"); + expect(abortCalls).toHaveLength(0); + }); + + it("cancel from a session without active run does not abort another session sharing the same key", async () => { + const sessionKey = "agent:main:shared"; + const harness = createHarness([ + { sessionId: "session-1", sessionKey }, + { sessionId: "session-2", sessionKey }, + ]); + const pending2 = await startPendingPrompt(harness, "session-2"); + + await harness.agent.cancel({ sessionId: "session-1" } as CancelNotification); + + const abortCalls = harness.requestSpy.mock.calls.filter(([method]) => method === "chat.abort"); + expect(abortCalls).toHaveLength(0); + expect(harness.sessionStore.getSession("session-2")?.activeRunId).toBe(pending2.runId); + + await harness.agent.handleGatewayEvent( + createChatEvent({ + runId: pending2.runId, + sessionKey, + seq: 1, + state: "final", + }), + ); + await expect(pending2.promptPromise).resolves.toEqual({ stopReason: "end_turn" }); + }); + + it("drops chat events when runId does not match the active prompt", async () => { + const sessionKey = "agent:main:shared"; + const harness = createHarness([{ sessionId: "session-1", sessionKey }]); + const pending = await startPendingPrompt(harness, "session-1"); + + await harness.agent.handleGatewayEvent( + createChatEvent({ + runId: "run-other", + sessionKey, + seq: 1, + state: "final", + }), + ); + expect(harness.sessionStore.getSession("session-1")?.activeRunId).toBe(pending.runId); + + await harness.agent.handleGatewayEvent( + createChatEvent({ + runId: pending.runId, + sessionKey, + seq: 2, + state: "final", + }), + ); + await expect(pending.promptPromise).resolves.toEqual({ stopReason: "end_turn" }); + }); + + it("drops tool events when runId does not match the active prompt", async () => { + const sessionKey = "agent:main:shared"; + const harness = createHarness([{ sessionId: "session-1", sessionKey }]); + const pending = await startPendingPrompt(harness, "session-1"); + harness.sessionUpdateSpy.mockClear(); + + await harness.agent.handleGatewayEvent( + createToolEvent({ + runId: "run-other", + sessionKey, + stream: "tool", + data: { + phase: "start", + name: "read_file", + toolCallId: "tool-1", + args: { path: "README.md" }, + }, + }), + ); + + expect(harness.sessionUpdateSpy).not.toHaveBeenCalled(); + + await harness.agent.handleGatewayEvent( + createChatEvent({ + runId: pending.runId, + sessionKey, + seq: 1, + state: "final", + }), + ); + await expect(pending.promptPromise).resolves.toEqual({ stopReason: "end_turn" }); + }); + + it("routes events to the pending prompt that matches runId when session keys are shared", async () => { + const sessionKey = "agent:main:shared"; + const harness = createHarness([ + { sessionId: "session-1", sessionKey }, + { sessionId: "session-2", sessionKey }, + ]); + const pending1 = await startPendingPrompt(harness, "session-1"); + const pending2 = await startPendingPrompt(harness, "session-2"); + harness.sessionUpdateSpy.mockClear(); + + await harness.agent.handleGatewayEvent( + createToolEvent({ + runId: pending2.runId, + sessionKey, + stream: "tool", + data: { + phase: "start", + name: "read_file", + toolCallId: "tool-2", + args: { path: "notes.txt" }, + }, + }), + ); + expect(harness.sessionUpdateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "session-2", + update: expect.objectContaining({ + sessionUpdate: "tool_call", + toolCallId: "tool-2", + status: "in_progress", + }), + }), + ); + expect(harness.sessionUpdateSpy).toHaveBeenCalledTimes(1); + + await harness.agent.handleGatewayEvent( + createChatEvent({ + runId: pending2.runId, + sessionKey, + seq: 1, + state: "final", + }), + ); + await expect(pending2.promptPromise).resolves.toEqual({ stopReason: "end_turn" }); + expect(harness.sessionStore.getSession("session-1")?.activeRunId).toBe(pending1.runId); + + await harness.agent.handleGatewayEvent( + createChatEvent({ + runId: pending1.runId, + sessionKey, + seq: 2, + state: "final", + }), + ); + await expect(pending1.promptPromise).resolves.toEqual({ stopReason: "end_turn" }); + }); +}); diff --git a/src/acp/translator.ts b/src/acp/translator.ts index 667c075e9c0..585f97c8f43 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -633,14 +633,25 @@ export class AcpGatewayAgent implements Agent { if (!session) { return; } + // Capture runId before cancelActiveRun clears session.activeRunId. + const activeRunId = session.activeRunId; + this.sessionStore.cancelActiveRun(params.sessionId); + const pending = this.pendingPrompts.get(params.sessionId); + const scopedRunId = activeRunId ?? pending?.idempotencyKey; + if (!scopedRunId) { + return; + } + try { - await this.gateway.request("chat.abort", { sessionKey: session.sessionKey }); + await this.gateway.request("chat.abort", { + sessionKey: session.sessionKey, + runId: scopedRunId, + }); } catch (err) { this.log(`cancel error: ${String(err)}`); } - const pending = this.pendingPrompts.get(params.sessionId); if (pending) { this.pendingPrompts.delete(params.sessionId); pending.resolve({ stopReason: "cancelled" }); @@ -672,6 +683,7 @@ export class AcpGatewayAgent implements Agent { return; } const stream = payload.stream as string | undefined; + const runId = payload.runId as string | undefined; const data = payload.data as Record | undefined; const sessionKey = payload.sessionKey as string | undefined; if (!stream || !data || !sessionKey) { @@ -688,7 +700,7 @@ export class AcpGatewayAgent implements Agent { return; } - const pending = this.findPendingBySessionKey(sessionKey); + const pending = this.findPendingBySessionKey(sessionKey, runId); if (!pending) { return; } @@ -774,13 +786,10 @@ export class AcpGatewayAgent implements Agent { return; } - const pending = this.findPendingBySessionKey(sessionKey); + const pending = this.findPendingBySessionKey(sessionKey, runId); if (!pending) { return; } - if (runId && pending.idempotencyKey !== runId) { - return; - } if (state === "delta" && messageData) { await this.handleDeltaEvent(pending.sessionId, messageData); @@ -853,11 +862,15 @@ export class AcpGatewayAgent implements Agent { pending.resolve({ stopReason }); } - private findPendingBySessionKey(sessionKey: string): PendingPrompt | undefined { + private findPendingBySessionKey(sessionKey: string, runId?: string): PendingPrompt | undefined { for (const pending of this.pendingPrompts.values()) { - if (pending.sessionKey === sessionKey) { - return pending; + if (pending.sessionKey !== sessionKey) { + continue; } + if (runId && pending.idempotencyKey !== runId) { + continue; + } + return pending; } return undefined; } diff --git a/src/agents/acp-spawn-parent-stream.ts b/src/agents/acp-spawn-parent-stream.ts index 94f04ce3940..36b113386c2 100644 --- a/src/agents/acp-spawn-parent-stream.ts +++ b/src/agents/acp-spawn-parent-stream.ts @@ -180,7 +180,9 @@ export function startAcpSpawnParentStreamRelay(params: { }; const wake = () => { requestHeartbeatNow( - scopedHeartbeatWakeOptions(parentSessionKey, { reason: "acp:spawn:stream" }), + scopedHeartbeatWakeOptions(parentSessionKey, { + reason: "acp:spawn:stream", + }), ); }; const emit = (text: string, contextKey: string) => { diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index 0f28b709792..c53584cdf55 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -38,6 +38,7 @@ const hoisted = vi.hoisted(() => { const loadSessionStoreMock = vi.fn(); const resolveStorePathMock = vi.fn(); const resolveSessionTranscriptFileMock = vi.fn(); + const areHeartbeatsEnabledMock = vi.fn(); const state = { cfg: createDefaultSpawnConfig(), }; @@ -55,6 +56,7 @@ const hoisted = vi.hoisted(() => { loadSessionStoreMock, resolveStorePathMock, resolveSessionTranscriptFileMock, + areHeartbeatsEnabledMock, state, }; }); @@ -128,6 +130,14 @@ vi.mock("../infra/outbound/session-binding-service.js", async (importOriginal) = }; }); +vi.mock("../infra/heartbeat-wake.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + areHeartbeatsEnabled: () => hoisted.areHeartbeatsEnabledMock(), + }; +}); + vi.mock("./acp-spawn-parent-stream.js", () => ({ startAcpSpawnParentStreamRelay: (...args: unknown[]) => hoisted.startAcpSpawnParentStreamRelayMock(...args), @@ -192,6 +202,7 @@ function expectResolvedIntroTextInBindMetadata(): void { describe("spawnAcpDirect", () => { beforeEach(() => { hoisted.state.cfg = createDefaultSpawnConfig(); + hoisted.areHeartbeatsEnabledMock.mockReset().mockReturnValue(true); hoisted.callGatewayMock.mockReset().mockImplementation(async (argsUnknown: unknown) => { const args = argsUnknown as { method?: string }; @@ -393,6 +404,8 @@ describe("spawnAcpDirect", () => { expect(result.status).toBe("accepted"); expect(result.mode).toBe("run"); + expect(result.streamLogPath).toBeUndefined(); + expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); expect(hoisted.resolveSessionTranscriptFileMock).toHaveBeenCalledWith( expect.objectContaining({ sessionId: "sess-123", @@ -633,6 +646,290 @@ describe("spawnAcpDirect", () => { expect(secondHandle.notifyStarted).toHaveBeenCalledTimes(1); }); + it("implicitly streams mode=run ACP spawns for subagent requester sessions", async () => { + hoisted.state.cfg = { + ...hoisted.state.cfg, + agents: { + defaults: { + heartbeat: { + every: "30m", + target: "last", + }, + }, + }, + }; + const firstHandle = createRelayHandle(); + const secondHandle = createRelayHandle(); + hoisted.startAcpSpawnParentStreamRelayMock + .mockReset() + .mockReturnValueOnce(firstHandle) + .mockReturnValueOnce(secondHandle); + hoisted.loadSessionStoreMock.mockReset().mockImplementation(() => { + const store: Record< + string, + { sessionId: string; updatedAt: number; deliveryContext?: unknown } + > = { + "agent:main:subagent:parent": { + sessionId: "parent-sess-1", + updatedAt: Date.now(), + deliveryContext: { + channel: "discord", + to: "channel:parent-channel", + accountId: "default", + }, + }, + }; + return new Proxy(store, { + get(target, prop) { + if (typeof prop === "string" && prop.startsWith("agent:codex:acp:")) { + return { sessionId: "sess-123", updatedAt: Date.now() }; + } + return target[prop as keyof typeof target]; + }, + }); + }); + + const result = await spawnAcpDirect( + { + task: "Investigate flaky tests", + agentId: "codex", + }, + { + agentSessionKey: "agent:main:subagent:parent", + agentChannel: "discord", + agentAccountId: "default", + agentTo: "channel:parent-channel", + }, + ); + + expect(result.status).toBe("accepted"); + expect(result.mode).toBe("run"); + expect(result.streamLogPath).toBe("/tmp/sess-main.acp-stream.jsonl"); + const agentCall = hoisted.callGatewayMock.mock.calls + .map((call: unknown[]) => call[0] as { method?: string; params?: Record }) + .find((request) => request.method === "agent"); + expect(agentCall?.params?.deliver).toBe(false); + expect(agentCall?.params?.channel).toBeUndefined(); + expect(agentCall?.params?.to).toBeUndefined(); + expect(agentCall?.params?.threadId).toBeUndefined(); + expect(hoisted.startAcpSpawnParentStreamRelayMock).toHaveBeenCalledWith( + expect.objectContaining({ + parentSessionKey: "agent:main:subagent:parent", + agentId: "codex", + logPath: "/tmp/sess-main.acp-stream.jsonl", + emitStartNotice: false, + }), + ); + expect(firstHandle.dispose).toHaveBeenCalledTimes(1); + expect(secondHandle.notifyStarted).toHaveBeenCalledTimes(1); + }); + + it("does not implicitly stream when heartbeat target is not session-local", async () => { + hoisted.state.cfg = { + ...hoisted.state.cfg, + agents: { + defaults: { + heartbeat: { + every: "30m", + target: "discord", + to: "channel:ops-room", + }, + }, + }, + }; + + const result = await spawnAcpDirect( + { + task: "Investigate flaky tests", + agentId: "codex", + }, + { + agentSessionKey: "agent:main:subagent:fixed-target", + }, + ); + + expect(result.status).toBe("accepted"); + expect(result.mode).toBe("run"); + expect(result.streamLogPath).toBeUndefined(); + expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); + }); + + it("does not implicitly stream when session scope is global", async () => { + hoisted.state.cfg = { + ...hoisted.state.cfg, + session: { + ...hoisted.state.cfg.session, + scope: "global", + }, + agents: { + defaults: { + heartbeat: { + every: "30m", + target: "last", + }, + }, + }, + }; + + const result = await spawnAcpDirect( + { + task: "Investigate flaky tests", + agentId: "codex", + }, + { + agentSessionKey: "agent:main:subagent:global-scope", + }, + ); + + expect(result.status).toBe("accepted"); + expect(result.mode).toBe("run"); + expect(result.streamLogPath).toBeUndefined(); + expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); + }); + + it("does not implicitly stream for subagent requester sessions when heartbeat is disabled", async () => { + hoisted.state.cfg = { + ...hoisted.state.cfg, + agents: { + list: [{ id: "main", heartbeat: { every: "30m" } }, { id: "research" }], + }, + }; + + const result = await spawnAcpDirect( + { + task: "Investigate flaky tests", + agentId: "codex", + }, + { + agentSessionKey: "agent:research:subagent:orchestrator", + }, + ); + + expect(result.status).toBe("accepted"); + expect(result.mode).toBe("run"); + expect(result.streamLogPath).toBeUndefined(); + expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); + }); + + it("does not implicitly stream for subagent requester sessions when heartbeat cadence is invalid", async () => { + hoisted.state.cfg = { + ...hoisted.state.cfg, + agents: { + list: [ + { + id: "research", + heartbeat: { every: "0m" }, + }, + ], + }, + }; + + const result = await spawnAcpDirect( + { + task: "Investigate flaky tests", + agentId: "codex", + }, + { + agentSessionKey: "agent:research:subagent:invalid-heartbeat", + }, + ); + + expect(result.status).toBe("accepted"); + expect(result.mode).toBe("run"); + expect(result.streamLogPath).toBeUndefined(); + expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); + }); + + it("does not implicitly stream when heartbeats are runtime-disabled", async () => { + hoisted.areHeartbeatsEnabledMock.mockReturnValue(false); + + const result = await spawnAcpDirect( + { + task: "Investigate flaky tests", + agentId: "codex", + }, + { + agentSessionKey: "agent:main:subagent:runtime-disabled", + }, + ); + + expect(result.status).toBe("accepted"); + expect(result.mode).toBe("run"); + expect(result.streamLogPath).toBeUndefined(); + expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); + }); + + it("does not implicitly stream for legacy subagent requester session keys", async () => { + const result = await spawnAcpDirect( + { + task: "Investigate flaky tests", + agentId: "codex", + }, + { + agentSessionKey: "subagent:legacy-worker", + }, + ); + + expect(result.status).toBe("accepted"); + expect(result.mode).toBe("run"); + expect(result.streamLogPath).toBeUndefined(); + expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); + }); + + it("does not implicitly stream for subagent requester sessions with thread context", async () => { + const result = await spawnAcpDirect( + { + task: "Investigate flaky tests", + agentId: "codex", + }, + { + agentSessionKey: "agent:main:subagent:thread-context", + agentChannel: "discord", + agentAccountId: "default", + agentTo: "channel:parent-channel", + agentThreadId: "requester-thread", + }, + ); + + expect(result.status).toBe("accepted"); + expect(result.mode).toBe("run"); + expect(result.streamLogPath).toBeUndefined(); + expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); + }); + + it("does not implicitly stream for thread-bound subagent requester sessions", async () => { + hoisted.sessionBindingListBySessionMock.mockImplementation((targetSessionKey: string) => { + if (targetSessionKey === "agent:main:subagent:thread-bound") { + return [ + createSessionBinding({ + targetSessionKey, + targetKind: "subagent", + status: "active", + }), + ]; + } + return []; + }); + + const result = await spawnAcpDirect( + { + task: "Investigate flaky tests", + agentId: "codex", + }, + { + agentSessionKey: "agent:main:subagent:thread-bound", + agentChannel: "discord", + agentAccountId: "default", + agentTo: "channel:parent-channel", + }, + ); + + expect(result.status).toBe("accepted"); + expect(result.mode).toBe("run"); + expect(result.streamLogPath).toBeUndefined(); + expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); + }); + it("announces parent relay start only after successful child dispatch", async () => { const firstHandle = createRelayHandle(); const secondHandle = createRelayHandle(); diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index 5d305b25f27..9d68a234aea 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -10,6 +10,7 @@ import { resolveAcpThreadSessionDetailLines, } from "../acp/runtime/session-identifiers.js"; import type { AcpRuntimeSessionMode } from "../acp/runtime/types.js"; +import { DEFAULT_HEARTBEAT_EVERY } from "../auto-reply/heartbeat.js"; import { resolveThreadBindingIntroText, resolveThreadBindingThreadName, @@ -21,11 +22,13 @@ import { resolveThreadBindingMaxAgeMsForChannel, resolveThreadBindingSpawnPolicy, } from "../channels/thread-bindings-policy.js"; +import { parseDurationMs } from "../cli/parse-duration.js"; import { loadConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadSessionStore, resolveStorePath, type SessionEntry } from "../config/sessions.js"; import { resolveSessionTranscriptFile } from "../config/sessions/transcript.js"; import { callGateway } from "../gateway/call.js"; +import { areHeartbeatsEnabled } from "../infra/heartbeat-wake.js"; import { resolveConversationIdFromTargets } from "../infra/outbound/conversation-id.js"; import { getSessionBindingService, @@ -33,13 +36,18 @@ import { type SessionBindingRecord, } from "../infra/outbound/session-binding-service.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { normalizeAgentId } from "../routing/session-key.js"; -import { normalizeDeliveryContext } from "../utils/delivery-context.js"; +import { + isSubagentSessionKey, + normalizeAgentId, + parseAgentSessionKey, +} from "../routing/session-key.js"; +import { deliveryContextFromSession, normalizeDeliveryContext } from "../utils/delivery-context.js"; import { type AcpSpawnParentRelayHandle, resolveAcpSpawnStreamLogPath, startAcpSpawnParentStreamRelay, } from "./acp-spawn-parent-stream.js"; +import { resolveAgentConfig, resolveDefaultAgentId } from "./agent-scope.js"; import { resolveSandboxRuntimeStatus } from "./sandbox/runtime-status.js"; import { resolveInternalSessionKey, resolveMainSessionAlias } from "./tools/sessions-helpers.js"; @@ -130,6 +138,95 @@ function resolveAcpSessionMode(mode: SpawnAcpMode): AcpRuntimeSessionMode { return mode === "session" ? "persistent" : "oneshot"; } +function isHeartbeatEnabledForSessionAgent(params: { + cfg: OpenClawConfig; + sessionKey?: string; +}): boolean { + if (!areHeartbeatsEnabled()) { + return false; + } + const requesterAgentId = parseAgentSessionKey(params.sessionKey)?.agentId; + if (!requesterAgentId) { + return true; + } + + const agentEntries = params.cfg.agents?.list ?? []; + const hasExplicitHeartbeatAgents = agentEntries.some((entry) => Boolean(entry?.heartbeat)); + const enabledByPolicy = hasExplicitHeartbeatAgents + ? agentEntries.some( + (entry) => Boolean(entry?.heartbeat) && normalizeAgentId(entry?.id) === requesterAgentId, + ) + : requesterAgentId === resolveDefaultAgentId(params.cfg); + if (!enabledByPolicy) { + return false; + } + + const heartbeatEvery = + resolveAgentConfig(params.cfg, requesterAgentId)?.heartbeat?.every ?? + params.cfg.agents?.defaults?.heartbeat?.every ?? + DEFAULT_HEARTBEAT_EVERY; + const trimmedEvery = typeof heartbeatEvery === "string" ? heartbeatEvery.trim() : ""; + if (!trimmedEvery) { + return false; + } + try { + return parseDurationMs(trimmedEvery, { defaultUnit: "m" }) > 0; + } catch { + return false; + } +} + +function resolveHeartbeatConfigForAgent(params: { + cfg: OpenClawConfig; + agentId: string; +}): NonNullable["defaults"]>["heartbeat"] { + const defaults = params.cfg.agents?.defaults?.heartbeat; + const overrides = resolveAgentConfig(params.cfg, params.agentId)?.heartbeat; + if (!defaults && !overrides) { + return undefined; + } + return { + ...defaults, + ...overrides, + }; +} + +function hasSessionLocalHeartbeatRelayRoute(params: { + cfg: OpenClawConfig; + parentSessionKey: string; + requesterAgentId: string; +}): boolean { + const scope = params.cfg.session?.scope ?? "per-sender"; + if (scope === "global") { + return false; + } + + const heartbeat = resolveHeartbeatConfigForAgent({ + cfg: params.cfg, + agentId: params.requesterAgentId, + }); + if ((heartbeat?.target ?? "none") !== "last") { + return false; + } + + // Explicit delivery overrides are not session-local and can route updates + // to unrelated destinations (for example a pinned ops channel). + if (typeof heartbeat?.to === "string" && heartbeat.to.trim().length > 0) { + return false; + } + if (typeof heartbeat?.accountId === "string" && heartbeat.accountId.trim().length > 0) { + return false; + } + + const storePath = resolveStorePath(params.cfg.session?.store, { + agentId: params.requesterAgentId, + }); + const sessionStore = loadSessionStore(storePath); + const parentEntry = sessionStore[params.parentSessionKey]; + const parentDeliveryContext = deliveryContextFromSession(parentEntry); + return Boolean(parentDeliveryContext?.channel && parentDeliveryContext.to); +} + function resolveTargetAcpAgentId(params: { requestedAgentId?: string; cfg: OpenClawConfig; @@ -326,6 +423,8 @@ export async function spawnAcpDirect( error: 'sessions_spawn streamTo="parent" requires an active requester session context.', }; } + + const requestThreadBinding = params.thread === true; const runtimePolicyError = resolveAcpSpawnRuntimePolicyError({ cfg, requesterSessionKey: ctx.agentSessionKey, @@ -339,7 +438,6 @@ export async function spawnAcpDirect( }; } - const requestThreadBinding = params.thread === true; const spawnMode = resolveSpawnMode({ requestedMode: params.mode, threadRequested: requestThreadBinding, @@ -351,6 +449,52 @@ export async function spawnAcpDirect( }; } + const bindingService = getSessionBindingService(); + const requesterParsedSession = parseAgentSessionKey(parentSessionKey); + const requesterIsSubagentSession = + Boolean(requesterParsedSession) && isSubagentSessionKey(parentSessionKey); + const requesterHasActiveSubagentBinding = + requesterIsSubagentSession && parentSessionKey + ? bindingService + .listBySession(parentSessionKey) + .some((record) => record.targetKind === "subagent" && record.status !== "ended") + : false; + const requesterHasThreadContext = + typeof ctx.agentThreadId === "string" + ? ctx.agentThreadId.trim().length > 0 + : ctx.agentThreadId != null; + const requesterHeartbeatEnabled = isHeartbeatEnabledForSessionAgent({ + cfg, + sessionKey: parentSessionKey, + }); + const requesterAgentId = requesterParsedSession?.agentId; + const requesterHeartbeatRelayRouteUsable = + parentSessionKey && requesterAgentId + ? hasSessionLocalHeartbeatRelayRoute({ + cfg, + parentSessionKey, + requesterAgentId, + }) + : false; + + // For mode=run without thread binding, implicitly route output to parent + // only for spawned subagent orchestrator sessions with heartbeat enabled + // AND a session-local heartbeat delivery route (target=last + usable last route). + // Skip requester sessions that are thread-bound (or carrying thread context) + // so user-facing threads do not receive unsolicited ACP progress chatter + // unless streamTo="parent" is explicitly requested. Use resolved spawnMode + // (not params.mode) so default mode selection works. + const implicitStreamToParent = + !streamToParentRequested && + spawnMode === "run" && + !requestThreadBinding && + requesterIsSubagentSession && + !requesterHasActiveSubagentBinding && + !requesterHasThreadContext && + requesterHeartbeatEnabled && + requesterHeartbeatRelayRouteUsable; + const effectiveStreamToParent = streamToParentRequested || implicitStreamToParent; + const targetAgentResult = resolveTargetAcpAgentId({ requestedAgentId: params.agentId, cfg, @@ -392,7 +536,6 @@ export async function spawnAcpDirect( } const acpManager = getAcpSessionManager(); - const bindingService = getSessionBindingService(); let binding: SessionBindingRecord | null = null; let sessionCreated = false; let initializedRuntime: AcpSpawnRuntimeCloseHandle | undefined; @@ -530,17 +673,17 @@ export async function spawnAcpDirect( // Fresh one-shot ACP runs should bootstrap the worker first, then let higher layers // decide how to relay status. Inline delivery is reserved for thread-bound sessions. const useInlineDelivery = - hasDeliveryTarget && spawnMode === "session" && !streamToParentRequested; + hasDeliveryTarget && spawnMode === "session" && !effectiveStreamToParent; const childIdem = crypto.randomUUID(); let childRunId: string = childIdem; const streamLogPath = - streamToParentRequested && parentSessionKey + effectiveStreamToParent && parentSessionKey ? resolveAcpSpawnStreamLogPath({ childSessionKey: sessionKey, }) : undefined; let parentRelay: AcpSpawnParentRelayHandle | undefined; - if (streamToParentRequested && parentSessionKey) { + if (effectiveStreamToParent && parentSessionKey) { // Register relay before dispatch so fast lifecycle failures are not missed. parentRelay = startAcpSpawnParentStreamRelay({ runId: childIdem, @@ -585,7 +728,7 @@ export async function spawnAcpDirect( }; } - if (streamToParentRequested && parentSessionKey) { + if (effectiveStreamToParent && parentSessionKey) { if (parentRelay && childRunId !== childIdem) { parentRelay.dispose(); // Defensive fallback if gateway returns a runId that differs from idempotency key. diff --git a/src/agents/lanes.test.ts b/src/agents/lanes.test.ts new file mode 100644 index 00000000000..9538de70d26 --- /dev/null +++ b/src/agents/lanes.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { AGENT_LANE_NESTED, resolveNestedAgentLane } from "./lanes.js"; + +describe("resolveNestedAgentLane", () => { + it("defaults to the nested lane when no lane is provided", () => { + expect(resolveNestedAgentLane()).toBe(AGENT_LANE_NESTED); + }); + + it("moves cron lane callers onto the nested lane", () => { + expect(resolveNestedAgentLane("cron")).toBe(AGENT_LANE_NESTED); + expect(resolveNestedAgentLane(" cron ")).toBe(AGENT_LANE_NESTED); + }); + + it("preserves non-cron lanes", () => { + expect(resolveNestedAgentLane("subagent")).toBe("subagent"); + expect(resolveNestedAgentLane(" custom-lane ")).toBe("custom-lane"); + }); +}); diff --git a/src/agents/lanes.ts b/src/agents/lanes.ts index 1688a4b8b9a..e9fa2217cf7 100644 --- a/src/agents/lanes.ts +++ b/src/agents/lanes.ts @@ -2,3 +2,13 @@ import { CommandLane } from "../process/lanes.js"; export const AGENT_LANE_NESTED = CommandLane.Nested; export const AGENT_LANE_SUBAGENT = CommandLane.Subagent; + +export function resolveNestedAgentLane(lane?: string): string { + const trimmed = lane?.trim(); + // Nested agent runs should not inherit the cron execution lane. Cron jobs + // already occupy that lane while they dispatch inner work. + if (!trimmed || trimmed === "cron") { + return AGENT_LANE_NESTED; + } + return trimmed; +} diff --git a/src/agents/model-auth-env-vars.ts b/src/agents/model-auth-env-vars.ts index c366138207c..0f387bf3ce3 100644 --- a/src/agents/model-auth-env-vars.ts +++ b/src/agents/model-auth-env-vars.ts @@ -32,6 +32,7 @@ export const PROVIDER_ENV_API_KEY_CANDIDATES: Record = { mistral: ["MISTRAL_API_KEY"], together: ["TOGETHER_API_KEY"], qianfan: ["QIANFAN_API_KEY"], + modelstudio: ["MODELSTUDIO_API_KEY"], ollama: ["OLLAMA_API_KEY"], vllm: ["VLLM_API_KEY"], kilocode: ["KILOCODE_API_KEY"], diff --git a/src/agents/model-auth-label.test.ts b/src/agents/model-auth-label.test.ts index a46eebbbc34..41afd4bb426 100644 --- a/src/agents/model-auth-label.test.ts +++ b/src/agents/model-auth-label.test.ts @@ -12,7 +12,7 @@ vi.mock("./auth-profiles.js", () => ({ })); vi.mock("./model-auth.js", () => ({ - getCustomProviderApiKey: () => undefined, + resolveUsableCustomProviderApiKey: () => null, resolveEnvApiKey: () => null, })); diff --git a/src/agents/model-auth-label.ts b/src/agents/model-auth-label.ts index ca564ab4dec..f28013c9825 100644 --- a/src/agents/model-auth-label.ts +++ b/src/agents/model-auth-label.ts @@ -5,7 +5,7 @@ import { resolveAuthProfileDisplayLabel, resolveAuthProfileOrder, } from "./auth-profiles.js"; -import { getCustomProviderApiKey, resolveEnvApiKey } from "./model-auth.js"; +import { resolveEnvApiKey, resolveUsableCustomProviderApiKey } from "./model-auth.js"; import { normalizeProviderId } from "./model-selection.js"; export function resolveModelAuthLabel(params: { @@ -59,7 +59,10 @@ export function resolveModelAuthLabel(params: { return `api-key (${envKey.source})`; } - const customKey = getCustomProviderApiKey(params.cfg, providerKey); + const customKey = resolveUsableCustomProviderApiKey({ + cfg: params.cfg, + provider: providerKey, + }); if (customKey) { return `api-key (models.json)`; } diff --git a/src/agents/model-auth-markers.test.ts b/src/agents/model-auth-markers.test.ts index e2225588df7..b90f1fd9ffa 100644 --- a/src/agents/model-auth-markers.test.ts +++ b/src/agents/model-auth-markers.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import { listKnownProviderEnvApiKeyNames } from "./model-auth-env-vars.js"; -import { isNonSecretApiKeyMarker, NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; +import { + isKnownEnvApiKeyMarker, + isNonSecretApiKeyMarker, + NON_ENV_SECRETREF_MARKER, +} from "./model-auth-markers.js"; describe("model auth markers", () => { it("recognizes explicit non-secret markers", () => { @@ -23,4 +27,9 @@ describe("model auth markers", () => { it("can exclude env marker-name interpretation for display-only paths", () => { expect(isNonSecretApiKeyMarker("OPENAI_API_KEY", { includeEnvVarName: false })).toBe(false); }); + + it("excludes aws-sdk env markers from known api key env marker helper", () => { + expect(isKnownEnvApiKeyMarker("OPENAI_API_KEY")).toBe(true); + expect(isKnownEnvApiKeyMarker("AWS_PROFILE")).toBe(false); + }); }); diff --git a/src/agents/model-auth-markers.ts b/src/agents/model-auth-markers.ts index 0b3b4960eb8..e888f06d0c5 100644 --- a/src/agents/model-auth-markers.ts +++ b/src/agents/model-auth-markers.ts @@ -35,6 +35,11 @@ export function isAwsSdkAuthMarker(value: string): boolean { return AWS_SDK_ENV_MARKERS.has(value.trim()); } +export function isKnownEnvApiKeyMarker(value: string): boolean { + const trimmed = value.trim(); + return KNOWN_ENV_API_KEY_MARKERS.has(trimmed) && !isAwsSdkAuthMarker(trimmed); +} + export function resolveNonEnvSecretRefApiKeyMarker(_source: SecretRefSource): string { return NON_ENV_SECRETREF_MARKER; } diff --git a/src/agents/model-auth.profiles.test.ts b/src/agents/model-auth.profiles.test.ts index 5fabcf2dcc6..24a881a63cd 100644 --- a/src/agents/model-auth.profiles.test.ts +++ b/src/agents/model-auth.profiles.test.ts @@ -230,6 +230,21 @@ describe("getApiKeyForModel", () => { }); }); + it("resolves Model Studio API key from env", async () => { + await withEnvAsync( + { [envVar("MODELSTUDIO", "API", "KEY")]: "modelstudio-test-key" }, + async () => { + // pragma: allowlist secret + const resolved = await resolveApiKeyForProvider({ + provider: "modelstudio", + store: { version: 1, profiles: {} }, + }); + expect(resolved.apiKey).toBe("modelstudio-test-key"); + expect(resolved.source).toContain("MODELSTUDIO_API_KEY"); + }, + ); + }); + it("resolves synthetic local auth key for configured ollama provider without apiKey", async () => { await withEnvAsync({ OLLAMA_API_KEY: undefined }, async () => { const resolved = await resolveApiKeyForProvider({ diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts index 943070960d3..2deaeb7dbf6 100644 --- a/src/agents/model-auth.test.ts +++ b/src/agents/model-auth.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; import type { AuthProfileStore } from "./auth-profiles.js"; -import { requireApiKey, resolveAwsSdkEnvVarName, resolveModelAuthMode } from "./model-auth.js"; +import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; +import { + hasUsableCustomProviderApiKey, + requireApiKey, + resolveAwsSdkEnvVarName, + resolveModelAuthMode, + resolveUsableCustomProviderApiKey, +} from "./model-auth.js"; describe("resolveAwsSdkEnvVarName", () => { it("prefers bearer token over access keys and profile", () => { @@ -117,3 +124,102 @@ describe("requireApiKey", () => { ).toThrow('No API key resolved for provider "openai"'); }); }); + +describe("resolveUsableCustomProviderApiKey", () => { + it("returns literal custom provider keys", () => { + const resolved = resolveUsableCustomProviderApiKey({ + cfg: { + models: { + providers: { + custom: { + baseUrl: "https://example.com/v1", + apiKey: "sk-custom-runtime", // pragma: allowlist secret + models: [], + }, + }, + }, + }, + provider: "custom", + }); + expect(resolved).toEqual({ + apiKey: "sk-custom-runtime", + source: "models.json", + }); + }); + + it("does not treat non-env markers as usable credentials", () => { + const resolved = resolveUsableCustomProviderApiKey({ + cfg: { + models: { + providers: { + custom: { + baseUrl: "https://example.com/v1", + apiKey: NON_ENV_SECRETREF_MARKER, + models: [], + }, + }, + }, + }, + provider: "custom", + }); + expect(resolved).toBeNull(); + }); + + it("resolves known env marker names from process env for custom providers", () => { + const previous = process.env.OPENAI_API_KEY; + process.env.OPENAI_API_KEY = "sk-from-env"; // pragma: allowlist secret + try { + const resolved = resolveUsableCustomProviderApiKey({ + cfg: { + models: { + providers: { + custom: { + baseUrl: "https://example.com/v1", + apiKey: "OPENAI_API_KEY", + models: [], + }, + }, + }, + }, + provider: "custom", + }); + expect(resolved?.apiKey).toBe("sk-from-env"); + expect(resolved?.source).toContain("OPENAI_API_KEY"); + } finally { + if (previous === undefined) { + delete process.env.OPENAI_API_KEY; + } else { + process.env.OPENAI_API_KEY = previous; + } + } + }); + + it("does not treat known env marker names as usable when env value is missing", () => { + const previous = process.env.OPENAI_API_KEY; + delete process.env.OPENAI_API_KEY; + try { + expect( + hasUsableCustomProviderApiKey( + { + models: { + providers: { + custom: { + baseUrl: "https://example.com/v1", + apiKey: "OPENAI_API_KEY", + models: [], + }, + }, + }, + }, + "custom", + ), + ).toBe(false); + } finally { + if (previous === undefined) { + delete process.env.OPENAI_API_KEY; + } else { + process.env.OPENAI_API_KEY = previous; + } + } + }); +}); diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 51ba332ed7f..ffc7c1e2e9d 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -4,6 +4,7 @@ import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig } from "../config/config.js"; import type { ModelProviderAuthMode, ModelProviderConfig } from "../config/types.js"; import { getShellEnvAppliedKeys } from "../infra/shell-env.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; import { normalizeOptionalSecretInput, normalizeSecretInput, @@ -17,11 +18,17 @@ import { resolveAuthStorePathForDisplay, } from "./auth-profiles.js"; import { PROVIDER_ENV_API_KEY_CANDIDATES } from "./model-auth-env-vars.js"; -import { OLLAMA_LOCAL_AUTH_MARKER } from "./model-auth-markers.js"; +import { + isKnownEnvApiKeyMarker, + isNonSecretApiKeyMarker, + OLLAMA_LOCAL_AUTH_MARKER, +} from "./model-auth-markers.js"; import { normalizeProviderId } from "./model-selection.js"; export { ensureAuthProfileStore, resolveAuthProfileOrder } from "./auth-profiles.js"; +const log = createSubsystemLogger("model-auth"); + const AWS_BEARER_ENV = "AWS_BEARER_TOKEN_BEDROCK"; const AWS_ACCESS_KEY_ENV = "AWS_ACCESS_KEY_ID"; const AWS_SECRET_KEY_ENV = "AWS_SECRET_ACCESS_KEY"; @@ -57,6 +64,49 @@ export function getCustomProviderApiKey( return normalizeOptionalSecretInput(entry?.apiKey); } +type ResolvedCustomProviderApiKey = { + apiKey: string; + source: string; +}; + +export function resolveUsableCustomProviderApiKey(params: { + cfg: OpenClawConfig | undefined; + provider: string; + env?: NodeJS.ProcessEnv; +}): ResolvedCustomProviderApiKey | null { + const customKey = getCustomProviderApiKey(params.cfg, params.provider); + if (!customKey) { + return null; + } + if (!isNonSecretApiKeyMarker(customKey)) { + return { apiKey: customKey, source: "models.json" }; + } + if (!isKnownEnvApiKeyMarker(customKey)) { + return null; + } + const envValue = normalizeOptionalSecretInput((params.env ?? process.env)[customKey]); + if (!envValue) { + return null; + } + const applied = new Set(getShellEnvAppliedKeys()); + return { + apiKey: envValue, + source: resolveEnvSourceLabel({ + applied, + envVars: [customKey], + label: `${customKey} (models.json marker)`, + }), + }; +} + +export function hasUsableCustomProviderApiKey( + cfg: OpenClawConfig | undefined, + provider: string, + env?: NodeJS.ProcessEnv, +): boolean { + return Boolean(resolveUsableCustomProviderApiKey({ cfg, provider, env })); +} + function resolveProviderAuthOverride( cfg: OpenClawConfig | undefined, provider: string, @@ -221,7 +271,9 @@ export async function resolveApiKeyForProvider(params: { mode: mode === "oauth" ? "oauth" : mode === "token" ? "token" : "api-key", }; } - } catch {} + } catch (err) { + log.debug?.(`auth profile "${candidate}" failed for provider "${provider}": ${String(err)}`); + } } const envResolved = resolveEnvApiKey(provider); @@ -233,9 +285,9 @@ export async function resolveApiKeyForProvider(params: { }; } - const customKey = getCustomProviderApiKey(cfg, provider); + const customKey = resolveUsableCustomProviderApiKey({ cfg, provider }); if (customKey) { - return { apiKey: customKey, source: "models.json", mode: "api-key" }; + return { apiKey: customKey.apiKey, source: customKey.source, mode: "api-key" }; } const syntheticLocalAuth = resolveSyntheticLocalProviderAuth({ cfg, provider }); @@ -355,7 +407,7 @@ export function resolveModelAuthMode( return envKey.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key"; } - if (getCustomProviderApiKey(cfg, resolved)) { + if (hasUsableCustomProviderApiKey(cfg, resolved)) { return "api-key"; } diff --git a/src/agents/models-config.e2e-harness.ts b/src/agents/models-config.e2e-harness.ts index 71577b27e69..81518ec9aee 100644 --- a/src/agents/models-config.e2e-harness.ts +++ b/src/agents/models-config.e2e-harness.ts @@ -101,6 +101,7 @@ export const MODELS_CONFIG_IMPLICIT_ENV_VARS = [ "OPENROUTER_API_KEY", "PI_CODING_AGENT_DIR", "QIANFAN_API_KEY", + "MODELSTUDIO_API_KEY", "QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY", "SYNTHETIC_API_KEY", diff --git a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts index ef03fb3863b..1d214e2cc1a 100644 --- a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts +++ b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts @@ -477,6 +477,51 @@ describe("models-config", () => { }); }); + it("replaces stale merged apiKey when config key normalizes to a known env marker", async () => { + await withEnvVar("OPENAI_API_KEY", "sk-plaintext-should-not-appear", async () => { + await withTempHome(async () => { + await writeAgentModelsJson({ + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: "STALE_AGENT_KEY", // pragma: allowlist secret + api: "openai-completions", + models: [{ id: "gpt-4.1", name: "GPT-4.1", input: ["text"] }], + }, + }, + }); + const cfg: OpenClawConfig = { + models: { + mode: "merge", + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-plaintext-should-not-appear", // pragma: allowlist secret; simulates resolved ${OPENAI_API_KEY} + api: "openai-completions", + models: [ + { + id: "gpt-4.1", + name: "GPT-4.1", + input: ["text"], + reasoning: false, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 16384, + }, + ], + }, + }, + }, + }; + await ensureOpenClawModelsJson(cfg); + const result = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(result.providers.openai?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret + }); + }); + }); + it("preserves explicit larger token limits when they exceed implicit catalog defaults", async () => { await withTempHome(async () => { await withEnvVar("MOONSHOT_API_KEY", "sk-moonshot-test", async () => { diff --git a/src/agents/models-config.merge.test.ts b/src/agents/models-config.merge.test.ts index 5e0483fdb59..60c3624c3c1 100644 --- a/src/agents/models-config.merge.test.ts +++ b/src/agents/models-config.merge.test.ts @@ -92,4 +92,25 @@ describe("models-config merge helpers", () => { }), ); }); + + it("does not preserve stale plaintext apiKey when next entry is a marker", () => { + const merged = mergeWithExistingProviderSecrets({ + nextProviders: { + custom: { + apiKey: "OPENAI_API_KEY", // pragma: allowlist secret + models: [{ id: "model", api: "openai-responses" }], + } as ProviderConfig, + }, + existingProviders: { + custom: { + apiKey: preservedApiKey, + models: [{ id: "model", api: "openai-responses" }], + } as ExistingProviderConfig, + }, + secretRefManagedProviders: new Set(), + explicitBaseUrlProviders: new Set(), + }); + + expect(merged.custom?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret + }); }); diff --git a/src/agents/models-config.merge.ts b/src/agents/models-config.merge.ts index da8a4abdaa2..e227ee413d5 100644 --- a/src/agents/models-config.merge.ts +++ b/src/agents/models-config.merge.ts @@ -148,9 +148,14 @@ function resolveProviderApiSurface( function shouldPreserveExistingApiKey(params: { providerKey: string; existing: ExistingProviderConfig; + nextEntry: ProviderConfig; secretRefManagedProviders: ReadonlySet; }): boolean { - const { providerKey, existing, secretRefManagedProviders } = params; + const { providerKey, existing, nextEntry, secretRefManagedProviders } = params; + const nextApiKey = typeof nextEntry.apiKey === "string" ? nextEntry.apiKey : ""; + if (nextApiKey && isNonSecretApiKeyMarker(nextApiKey)) { + return false; + } return ( !secretRefManagedProviders.has(providerKey) && typeof existing.apiKey === "string" && @@ -198,7 +203,14 @@ export function mergeWithExistingProviderSecrets(params: { continue; } const preserved: Record = {}; - if (shouldPreserveExistingApiKey({ providerKey: key, existing, secretRefManagedProviders })) { + if ( + shouldPreserveExistingApiKey({ + providerKey: key, + existing, + nextEntry: newEntry, + secretRefManagedProviders, + }) + ) { preserved.apiKey = existing.apiKey; } if ( diff --git a/src/agents/models-config.providers.modelstudio.test.ts b/src/agents/models-config.providers.modelstudio.test.ts new file mode 100644 index 00000000000..df4000cc27d --- /dev/null +++ b/src/agents/models-config.providers.modelstudio.test.ts @@ -0,0 +1,32 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { withEnvAsync } from "../test-utils/env.js"; +import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; +import { buildModelStudioProvider } from "./models-config.providers.js"; + +const modelStudioApiKeyEnv = ["MODELSTUDIO_API", "KEY"].join("_"); + +describe("Model Studio implicit provider", () => { + it("should include modelstudio when MODELSTUDIO_API_KEY is configured", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const modelStudioApiKey = "test-key"; // pragma: allowlist secret + await withEnvAsync({ [modelStudioApiKeyEnv]: modelStudioApiKey }, async () => { + const providers = await resolveImplicitProvidersForTest({ agentDir }); + expect(providers?.modelstudio).toBeDefined(); + expect(providers?.modelstudio?.apiKey).toBe("MODELSTUDIO_API_KEY"); + expect(providers?.modelstudio?.baseUrl).toBe("https://coding-intl.dashscope.aliyuncs.com/v1"); + }); + }); + + it("should build the static Model Studio provider catalog", () => { + const provider = buildModelStudioProvider(); + const modelIds = provider.models.map((model) => model.id); + expect(provider.api).toBe("openai-completions"); + expect(provider.baseUrl).toBe("https://coding-intl.dashscope.aliyuncs.com/v1"); + expect(modelIds).toContain("qwen3.5-plus"); + expect(modelIds).toContain("qwen3-coder-plus"); + expect(modelIds).toContain("kimi-k2.5"); + }); +}); diff --git a/src/agents/models-config.providers.normalize-keys.test.ts b/src/agents/models-config.providers.normalize-keys.test.ts index be92bbcd474..f8422d797dd 100644 --- a/src/agents/models-config.providers.normalize-keys.test.ts +++ b/src/agents/models-config.providers.normalize-keys.test.ts @@ -78,6 +78,7 @@ describe("normalizeProviders", () => { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-")); const original = process.env.OPENAI_API_KEY; process.env.OPENAI_API_KEY = "sk-test-secret-value-12345"; // pragma: allowlist secret + const secretRefManagedProviders = new Set(); try { const providers: NonNullable["providers"]> = { openai: { @@ -97,8 +98,9 @@ describe("normalizeProviders", () => { ], }, }; - const normalized = normalizeProviders({ providers, agentDir }); + const normalized = normalizeProviders({ providers, agentDir, secretRefManagedProviders }); expect(normalized?.openai?.apiKey).toBe("OPENAI_API_KEY"); + expect(secretRefManagedProviders.has("openai")).toBe(true); } finally { if (original === undefined) { delete process.env.OPENAI_API_KEY; diff --git a/src/agents/models-config.providers.static.ts b/src/agents/models-config.providers.static.ts index 0a766fe983e..08b3d1c2a66 100644 --- a/src/agents/models-config.providers.static.ts +++ b/src/agents/models-config.providers.static.ts @@ -137,6 +137,90 @@ const QIANFAN_DEFAULT_COST = { cacheWrite: 0, }; +export const MODELSTUDIO_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1"; +export const MODELSTUDIO_DEFAULT_MODEL_ID = "qwen3.5-plus"; +const MODELSTUDIO_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +const MODELSTUDIO_MODEL_CATALOG: ReadonlyArray = [ + { + id: "qwen3.5-plus", + name: "qwen3.5-plus", + reasoning: false, + input: ["text", "image"], + cost: MODELSTUDIO_DEFAULT_COST, + contextWindow: 1_000_000, + maxTokens: 65_536, + }, + { + id: "qwen3-max-2026-01-23", + name: "qwen3-max-2026-01-23", + reasoning: false, + input: ["text"], + cost: MODELSTUDIO_DEFAULT_COST, + contextWindow: 262_144, + maxTokens: 65_536, + }, + { + id: "qwen3-coder-next", + name: "qwen3-coder-next", + reasoning: false, + input: ["text"], + cost: MODELSTUDIO_DEFAULT_COST, + contextWindow: 262_144, + maxTokens: 65_536, + }, + { + id: "qwen3-coder-plus", + name: "qwen3-coder-plus", + reasoning: false, + input: ["text"], + cost: MODELSTUDIO_DEFAULT_COST, + contextWindow: 1_000_000, + maxTokens: 65_536, + }, + { + id: "MiniMax-M2.5", + name: "MiniMax-M2.5", + reasoning: false, + input: ["text"], + cost: MODELSTUDIO_DEFAULT_COST, + contextWindow: 1_000_000, + maxTokens: 65_536, + }, + { + id: "glm-5", + name: "glm-5", + reasoning: false, + input: ["text"], + cost: MODELSTUDIO_DEFAULT_COST, + contextWindow: 202_752, + maxTokens: 16_384, + }, + { + id: "glm-4.7", + name: "glm-4.7", + reasoning: false, + input: ["text"], + cost: MODELSTUDIO_DEFAULT_COST, + contextWindow: 202_752, + maxTokens: 16_384, + }, + { + id: "kimi-k2.5", + name: "kimi-k2.5", + reasoning: false, + input: ["text", "image"], + cost: MODELSTUDIO_DEFAULT_COST, + contextWindow: 262_144, + maxTokens: 32_768, + }, +]; + const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1"; const NVIDIA_DEFAULT_MODEL_ID = "nvidia/llama-3.1-nemotron-70b-instruct"; const NVIDIA_DEFAULT_CONTEXT_WINDOW = 131072; @@ -384,6 +468,14 @@ export function buildQianfanProvider(): ProviderConfig { }; } +export function buildModelStudioProvider(): ProviderConfig { + return { + baseUrl: MODELSTUDIO_BASE_URL, + api: "openai-completions", + models: MODELSTUDIO_MODEL_CATALOG.map((model) => ({ ...model })), + }; +} + export function buildNvidiaProvider(): ProviderConfig { return { baseUrl: NVIDIA_BASE_URL, diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 8f8ffb9201c..c63ed6865a8 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -29,6 +29,7 @@ import { buildKilocodeProvider, buildMinimaxPortalProvider, buildMinimaxProvider, + buildModelStudioProvider, buildMoonshotProvider, buildNvidiaProvider, buildOpenAICodexProvider, @@ -46,8 +47,11 @@ export { buildKimiCodingProvider, buildKilocodeProvider, buildNvidiaProvider, + buildModelStudioProvider, buildQianfanProvider, buildXiaomiProvider, + MODELSTUDIO_BASE_URL, + MODELSTUDIO_DEFAULT_MODEL_ID, QIANFAN_BASE_URL, QIANFAN_DEFAULT_MODEL_ID, XIAOMI_DEFAULT_MODEL_ID, @@ -343,6 +347,9 @@ export function normalizeProviders(params: { apiKey: normalizedConfiguredApiKey, }; } + if (isNonSecretApiKeyMarker(normalizedConfiguredApiKey)) { + params.secretRefManagedProviders?.add(normalizedKey); + } if ( profileApiKey && profileApiKey.source !== "plaintext" && @@ -366,6 +373,7 @@ export function normalizeProviders(params: { if (envVarName && env[envVarName] === currentApiKey) { mutated = true; normalizedProvider = { ...normalizedProvider, apiKey: envVarName }; + params.secretRefManagedProviders?.add(normalizedKey); } } @@ -512,6 +520,7 @@ const SIMPLE_IMPLICIT_PROVIDER_LOADERS: ImplicitProviderLoader[] = [ apiKey, })), withApiKey("qianfan", async ({ apiKey }) => ({ ...buildQianfanProvider(), apiKey })), + withApiKey("modelstudio", async ({ apiKey }) => ({ ...buildModelStudioProvider(), apiKey })), withApiKey("openrouter", async ({ apiKey }) => ({ ...buildOpenrouterProvider(), apiKey })), withApiKey("nvidia", async ({ apiKey }) => ({ ...buildNvidiaProvider(), apiKey })), withApiKey("kilocode", async ({ apiKey }) => ({ diff --git a/src/agents/models-config.runtime-source-snapshot.test.ts b/src/agents/models-config.runtime-source-snapshot.test.ts index 6d6ea0284ee..4c5889769cc 100644 --- a/src/agents/models-config.runtime-source-snapshot.test.ts +++ b/src/agents/models-config.runtime-source-snapshot.test.ts @@ -101,6 +101,56 @@ describe("models-config runtime source snapshot", () => { }); }); + it("projects cloned runtime configs onto source snapshot when preserving provider auth", async () => { + await withTempHome(async () => { + const sourceConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret + api: "openai-completions" as const, + models: [], + }, + }, + }, + }; + const runtimeConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-runtime-resolved", // pragma: allowlist secret + api: "openai-completions" as const, + models: [], + }, + }, + }, + }; + const clonedRuntimeConfig: OpenClawConfig = { + ...runtimeConfig, + agents: { + defaults: { + imageModel: "openai/gpt-image-1", + }, + }, + }; + + try { + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + await ensureOpenClawModelsJson(clonedRuntimeConfig); + + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers.openai?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret + } finally { + clearRuntimeConfigSnapshot(); + clearConfigCache(); + } + }); + }); + it("uses header markers from runtime source snapshot instead of resolved runtime values", async () => { await withTempHome(async () => { const sourceConfig: OpenClawConfig = { diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index b9b8a7316d3..99714a1a792 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -1,8 +1,8 @@ import fs from "node:fs/promises"; import path from "node:path"; import { - getRuntimeConfigSnapshot, getRuntimeConfigSourceSnapshot, + projectConfigOntoRuntimeSourceSnapshot, type OpenClawConfig, loadConfig, } from "../config/config.js"; @@ -44,17 +44,13 @@ async function writeModelsFileAtomic(targetPath: string, contents: string): Prom function resolveModelsConfigInput(config?: OpenClawConfig): OpenClawConfig { const runtimeSource = getRuntimeConfigSourceSnapshot(); - if (!runtimeSource) { - return config ?? loadConfig(); - } if (!config) { - return runtimeSource; + return runtimeSource ?? loadConfig(); } - const runtimeResolved = getRuntimeConfigSnapshot(); - if (runtimeResolved && config === runtimeResolved) { - return runtimeSource; + if (!runtimeSource) { + return config; } - return config; + return projectConfigOntoRuntimeSourceSnapshot(config); } async function withModelsJsonWriteLock(targetPath: string, run: () => Promise): Promise { diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts index dd361b70e67..db45e8d48b8 100644 --- a/src/agents/openclaw-tools.session-status.test.ts +++ b/src/agents/openclaw-tools.session-status.test.ts @@ -63,7 +63,7 @@ vi.mock("../agents/auth-profiles.js", () => ({ vi.mock("../agents/model-auth.js", () => ({ resolveEnvApiKey: () => null, - getCustomProviderApiKey: () => null, + resolveUsableCustomProviderApiKey: () => null, resolveModelAuthMode: () => "api-key", })); diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index 3500df63876..608483b99bf 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -501,6 +501,26 @@ describe("isFailoverErrorMessage", () => { expect(isFailoverErrorMessage(sample)).toBe(true); } }); + + it("matches Gemini MALFORMED_RESPONSE stop reason as timeout (#42149)", () => { + const samples = [ + "Unhandled stop reason: MALFORMED_RESPONSE", + "Unhandled stop reason: malformed_response", + "stop reason: MALFORMED_RESPONSE", + ]; + for (const sample of samples) { + expect(isTimeoutErrorMessage(sample)).toBe(true); + expect(classifyFailoverReason(sample)).toBe("timeout"); + expect(isFailoverErrorMessage(sample)).toBe(true); + } + }); + + it("does not classify MALFORMED_FUNCTION_CALL as timeout", () => { + const sample = "Unhandled stop reason: MALFORMED_FUNCTION_CALL"; + expect(isTimeoutErrorMessage(sample)).toBe(false); + expect(classifyFailoverReason(sample)).toBe(null); + expect(isFailoverErrorMessage(sample)).toBe(false); + }); }); describe("parseImageSizeError", () => { @@ -646,6 +666,12 @@ describe("classifyFailoverReason", () => { expect(classifyFailoverReason("402 Payment Required: Weekly/Monthly Limit Exhausted")).toBe( "billing", ); + // Poe returns 402 without "payment required"; must be recognized for fallback + expect( + classifyFailoverReason( + "402 You've used up your points! Visit https://poe.com/api/keys to get more.", + ), + ).toBe("billing"); expect(classifyFailoverReason(INSUFFICIENT_QUOTA_PAYLOAD)).toBe("billing"); expect(classifyFailoverReason("deadline exceeded")).toBe("timeout"); expect(classifyFailoverReason("request ended without sending any chunks")).toBe("timeout"); diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 9ab52c04355..181ba89d8ce 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -237,7 +237,7 @@ const RETRYABLE_402_SCOPED_RESULT_HINTS = [ "exhausted", ] as const; const RAW_402_MARKER_RE = - /["']?(?:status|code)["']?\s*[:=]\s*402\b|\bhttp\s*402\b|\berror(?:\s+code)?\s*[:=]?\s*402\b|\b(?:got|returned|received)\s+(?:a\s+)?402\b|^\s*402\s+payment required\b/i; + /["']?(?:status|code)["']?\s*[:=]\s*402\b|\bhttp\s*402\b|\berror(?:\s+code)?\s*[:=]?\s*402\b|\b(?:got|returned|received)\s+(?:a\s+)?402\b|^\s*402\s+payment required\b|^\s*402\s+.*used up your points\b/i; const LEADING_402_WRAPPER_RE = /^(?:error[:\s-]+)?(?:(?:http\s*)?402(?:\s+payment required)?|payment required)(?:[:\s-]+|$)/i; diff --git a/src/agents/pi-embedded-helpers/failover-matches.ts b/src/agents/pi-embedded-helpers/failover-matches.ts index f2e0e3870ab..a7948703f39 100644 --- a/src/agents/pi-embedded-helpers/failover-matches.ts +++ b/src/agents/pi-embedded-helpers/failover-matches.ts @@ -40,9 +40,9 @@ const ERROR_PATTERNS = { /\benotfound\b/i, /\beai_again\b/i, /without sending (?:any )?chunks?/i, - /\bstop reason:\s*(?:abort|error)\b/i, - /\breason:\s*(?:abort|error)\b/i, - /\bunhandled stop reason:\s*(?:abort|error)\b/i, + /\bstop reason:\s*(?:abort|error|malformed_response)\b/i, + /\breason:\s*(?:abort|error|malformed_response)\b/i, + /\bunhandled stop reason:\s*(?:abort|error|malformed_response)\b/i, ], billing: [ /["']?(?:status|code)["']?\s*[:=]\s*402\b|\bhttp\s*402\b|\berror(?:\s+code)?\s*[:=]?\s*402\b|\b(?:got|returned|received)\s+(?:a\s+)?402\b|^\s*402\s+payment/i, diff --git a/src/agents/pi-embedded-runner/model.provider-normalization.ts b/src/agents/pi-embedded-runner/model.provider-normalization.ts index ecf1a25e7d3..82dabff7c1b 100644 --- a/src/agents/pi-embedded-runner/model.provider-normalization.ts +++ b/src/agents/pi-embedded-runner/model.provider-normalization.ts @@ -54,9 +54,33 @@ function normalizeOpenAICodexTransport(params: { } as Model; } +function normalizeOpenAITransport(params: { provider: string; model: Model }): Model { + if (normalizeProviderId(params.provider) !== "openai") { + return params.model; + } + + const useResponsesTransport = + params.model.api === "openai-completions" && + (!params.model.baseUrl || isOpenAIApiBaseUrl(params.model.baseUrl)); + + if (!useResponsesTransport) { + return params.model; + } + + return { + ...params.model, + api: "openai-responses", + } as Model; +} + export function normalizeResolvedProviderModel(params: { provider: string; model: Model; }): Model { - return normalizeModelCompat(normalizeOpenAICodexTransport(params)); + const normalizedOpenAI = normalizeOpenAITransport(params); + const normalizedCodex = normalizeOpenAICodexTransport({ + provider: params.provider, + model: normalizedOpenAI, + }); + return normalizeModelCompat(normalizedCodex); } diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index e67fb2c2898..105f929b9b6 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -180,7 +180,7 @@ describe("buildInlineProviderModels", () => { expect(result[0].headers).toBeUndefined(); }); - it("preserves literal marker-shaped headers in inline provider models", () => { + it("drops SecretRef marker headers in inline provider models", () => { const providers: Parameters[0] = { custom: { headers: { @@ -196,8 +196,6 @@ describe("buildInlineProviderModels", () => { expect(result).toHaveLength(1); expect(result[0].headers).toEqual({ - Authorization: "secretref-env:OPENAI_HEADER_TOKEN", - "X-Managed": "secretref-managed", "X-Static": "tenant-a", }); }); @@ -245,7 +243,7 @@ describe("resolveModel", () => { }); }); - it("preserves literal marker-shaped provider headers in fallback models", () => { + it("drops SecretRef marker provider headers in fallback models", () => { const cfg = { models: { providers: { @@ -266,8 +264,6 @@ describe("resolveModel", () => { expect(result.error).toBeUndefined(); expect((result.model as unknown as { headers?: Record }).headers).toEqual({ - Authorization: "secretref-env:OPENAI_HEADER_TOKEN", - "X-Managed": "secretref-managed", "X-Custom-Auth": "token-123", }); }); @@ -518,6 +514,54 @@ describe("resolveModel", () => { }); }); + it("normalizes stale native openai gpt-5.4 completions transport to responses", () => { + mockDiscoveredModel({ + provider: "openai", + modelId: "gpt-5.4", + templateModel: buildForwardCompatTemplate({ + id: "gpt-5.4", + name: "GPT-5.4", + provider: "openai", + api: "openai-completions", + baseUrl: "https://api.openai.com/v1", + }), + }); + + const result = resolveModel("openai", "gpt-5.4", "/tmp/agent"); + + expect(result.error).toBeUndefined(); + expect(result.model).toMatchObject({ + provider: "openai", + id: "gpt-5.4", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }); + }); + + it("keeps proxied openai completions transport untouched", () => { + mockDiscoveredModel({ + provider: "openai", + modelId: "gpt-5.4", + templateModel: buildForwardCompatTemplate({ + id: "gpt-5.4", + name: "GPT-5.4", + provider: "openai", + api: "openai-completions", + baseUrl: "https://proxy.example.com/v1", + }), + }); + + const result = resolveModel("openai", "gpt-5.4", "/tmp/agent"); + + expect(result.error).toBeUndefined(); + expect(result.model).toMatchObject({ + provider: "openai", + id: "gpt-5.4", + api: "openai-completions", + baseUrl: "https://proxy.example.com/v1", + }); + }); + it("builds an anthropic forward-compat fallback for claude-opus-4-6", () => { mockDiscoveredModel({ provider: "anthropic", diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index 638d66f787f..6f2852203bd 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -81,8 +81,12 @@ function applyConfiguredProviderOverrides(params: { const discoveredHeaders = sanitizeModelHeaders(discoveredModel.headers, { stripSecretRefMarkers: true, }); - const providerHeaders = sanitizeModelHeaders(providerConfig.headers); - const configuredHeaders = sanitizeModelHeaders(configuredModel?.headers); + const providerHeaders = sanitizeModelHeaders(providerConfig.headers, { + stripSecretRefMarkers: true, + }); + const configuredHeaders = sanitizeModelHeaders(configuredModel?.headers, { + stripSecretRefMarkers: true, + }); if (!configuredModel && !providerConfig.baseUrl && !providerConfig.api && !providerHeaders) { return { ...discoveredModel, @@ -118,14 +122,18 @@ export function buildInlineProviderModels( if (!trimmed) { return []; } - const providerHeaders = sanitizeModelHeaders(entry?.headers); + const providerHeaders = sanitizeModelHeaders(entry?.headers, { + stripSecretRefMarkers: true, + }); return (entry?.models ?? []).map((model) => ({ ...model, provider: trimmed, baseUrl: entry?.baseUrl, api: model.api ?? entry?.api, headers: (() => { - const modelHeaders = sanitizeModelHeaders((model as InlineModelEntry).headers); + const modelHeaders = sanitizeModelHeaders((model as InlineModelEntry).headers, { + stripSecretRefMarkers: true, + }); if (!providerHeaders && !modelHeaders) { return undefined; } @@ -205,8 +213,12 @@ export function resolveModelWithRegistry(params: { } const configuredModel = providerConfig?.models?.find((candidate) => candidate.id === modelId); - const providerHeaders = sanitizeModelHeaders(providerConfig?.headers); - const modelHeaders = sanitizeModelHeaders(configuredModel?.headers); + const providerHeaders = sanitizeModelHeaders(providerConfig?.headers, { + stripSecretRefMarkers: true, + }); + const modelHeaders = sanitizeModelHeaders(configuredModel?.headers, { + stripSecretRefMarkers: true, + }); if (providerConfig || modelId.startsWith("mock-")) { return normalizeResolvedModel({ provider, diff --git a/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts b/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts index b93cf43cebe..911b124113a 100644 --- a/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts @@ -70,7 +70,7 @@ describe("handleAgentEnd", () => { }); }); - it("attaches raw provider error metadata without changing the console message", () => { + it("attaches raw provider error metadata and includes model/provider in console output", () => { const ctx = createContext({ role: "assistant", stopReason: "error", @@ -91,9 +91,35 @@ describe("handleAgentEnd", () => { error: "The AI service is temporarily overloaded. Please try again in a moment.", failoverReason: "overloaded", providerErrorType: "overloaded_error", + consoleMessage: + "embedded run agent end: runId=run-1 isError=true model=claude-test provider=anthropic error=The AI service is temporarily overloaded. Please try again in a moment.", }); }); + it("sanitizes model and provider before writing consoleMessage", () => { + const ctx = createContext({ + role: "assistant", + stopReason: "error", + provider: "anthropic\u001b]8;;https://evil.test\u0007", + model: "claude\tsonnet\n4", + errorMessage: "connection refused", + content: [{ type: "text", text: "" }], + }); + + handleAgentEnd(ctx); + + const warn = vi.mocked(ctx.log.warn); + const meta = warn.mock.calls[0]?.[1]; + expect(meta).toMatchObject({ + consoleMessage: + "embedded run agent end: runId=run-1 isError=true model=claude sonnet 4 provider=anthropic]8;;https://evil.test error=connection refused", + }); + expect(meta?.consoleMessage).not.toContain("\n"); + expect(meta?.consoleMessage).not.toContain("\r"); + expect(meta?.consoleMessage).not.toContain("\t"); + expect(meta?.consoleMessage).not.toContain("\u001b"); + }); + it("redacts logged error text before emitting lifecycle events", () => { const onAgentEvent = vi.fn(); const ctx = createContext( diff --git a/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts b/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts index c666784ff8e..973de1ebefc 100644 --- a/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts +++ b/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts @@ -48,6 +48,8 @@ export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext) { const safeErrorText = buildTextObservationFields(errorText).textPreview ?? "LLM request failed."; const safeRunId = sanitizeForConsole(ctx.params.runId) ?? "-"; + const safeModel = sanitizeForConsole(lastAssistant.model) ?? "unknown"; + const safeProvider = sanitizeForConsole(lastAssistant.provider) ?? "unknown"; ctx.log.warn("embedded run agent end", { event: "embedded_run_agent_end", tags: ["error_handling", "lifecycle", "agent_end", "assistant_error"], @@ -55,10 +57,10 @@ export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext) { isError: true, error: safeErrorText, failoverReason, - provider: lastAssistant.provider, model: lastAssistant.model, + provider: lastAssistant.provider, ...observedError, - consoleMessage: `embedded run agent end: runId=${safeRunId} isError=true error=${safeErrorText}`, + consoleMessage: `embedded run agent end: runId=${safeRunId} isError=true model=${safeModel} provider=${safeProvider} error=${safeErrorText}`, }); emitAgentEvent({ runId: ctx.params.runId, diff --git a/src/agents/skills-install-extract.ts b/src/agents/skills-install-extract.ts index 4578935378f..02a5b22c3d5 100644 --- a/src/agents/skills-install-extract.ts +++ b/src/agents/skills-install-extract.ts @@ -1,14 +1,21 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; import { - createTarEntrySafetyChecker, + createTarEntryPreflightChecker, extractArchive as extractArchiveSafe, + mergeExtractedTreeIntoDestination, + prepareArchiveDestinationDir, + withStagedArchiveDestination, } from "../infra/archive.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { parseTarVerboseMetadata } from "./skills-install-tar-verbose.js"; import { hasBinary } from "./skills.js"; export type ArchiveExtractResult = { stdout: string; stderr: string; code: number | null }; +type TarPreflightResult = { + entries: string[]; + metadata: ReturnType; +}; async function hashFileSha256(filePath: string): Promise { const hash = createHash("sha256"); @@ -24,6 +31,112 @@ async function hashFileSha256(filePath: string): Promise { }); } +function commandFailureResult( + result: { stdout: string; stderr: string; code: number | null }, + fallbackStderr: string, +): ArchiveExtractResult { + return { + stdout: result.stdout, + stderr: result.stderr || fallbackStderr, + code: result.code, + }; +} + +function buildTarExtractArgv(params: { + archivePath: string; + targetDir: string; + stripComponents: number; +}): string[] { + const argv = ["tar", "xf", params.archivePath, "-C", params.targetDir]; + if (params.stripComponents > 0) { + argv.push("--strip-components", String(params.stripComponents)); + } + return argv; +} + +async function readTarPreflight(params: { + archivePath: string; + timeoutMs: number; +}): Promise { + const listResult = await runCommandWithTimeout(["tar", "tf", params.archivePath], { + timeoutMs: params.timeoutMs, + }); + if (listResult.code !== 0) { + return commandFailureResult(listResult, "tar list failed"); + } + const entries = listResult.stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + + const verboseResult = await runCommandWithTimeout(["tar", "tvf", params.archivePath], { + timeoutMs: params.timeoutMs, + }); + if (verboseResult.code !== 0) { + return commandFailureResult(verboseResult, "tar verbose list failed"); + } + const metadata = parseTarVerboseMetadata(verboseResult.stdout); + if (metadata.length !== entries.length) { + return { + stdout: verboseResult.stdout, + stderr: `tar verbose/list entry count mismatch (${metadata.length} vs ${entries.length})`, + code: 1, + }; + } + return { entries, metadata }; +} + +function isArchiveExtractFailure( + value: TarPreflightResult | ArchiveExtractResult, +): value is ArchiveExtractResult { + return "code" in value; +} + +async function verifyArchiveHashStable(params: { + archivePath: string; + expectedHash: string; +}): Promise { + const postPreflightHash = await hashFileSha256(params.archivePath); + if (postPreflightHash === params.expectedHash) { + return null; + } + return { + stdout: "", + stderr: "tar archive changed during safety preflight; refusing to extract", + code: 1, + }; +} + +async function extractTarBz2WithStaging(params: { + archivePath: string; + destinationRealDir: string; + stripComponents: number; + timeoutMs: number; +}): Promise { + return await withStagedArchiveDestination({ + destinationRealDir: params.destinationRealDir, + run: async (stagingDir) => { + const extractResult = await runCommandWithTimeout( + buildTarExtractArgv({ + archivePath: params.archivePath, + targetDir: stagingDir, + stripComponents: params.stripComponents, + }), + { timeoutMs: params.timeoutMs }, + ); + if (extractResult.code !== 0) { + return extractResult; + } + await mergeExtractedTreeIntoDestination({ + sourceDir: stagingDir, + destinationDir: params.destinationRealDir, + destinationRealDir: params.destinationRealDir, + }); + return extractResult; + }, + }); +} + export async function extractArchive(params: { archivePath: string; archiveType: string; @@ -66,49 +179,25 @@ export async function extractArchive(params: { return { stdout: "", stderr: "tar not found on PATH", code: null }; } + const destinationRealDir = await prepareArchiveDestinationDir(targetDir); const preflightHash = await hashFileSha256(archivePath); // Preflight list to prevent zip-slip style traversal before extraction. - const listResult = await runCommandWithTimeout(["tar", "tf", archivePath], { timeoutMs }); - if (listResult.code !== 0) { - return { - stdout: listResult.stdout, - stderr: listResult.stderr || "tar list failed", - code: listResult.code, - }; + const preflight = await readTarPreflight({ archivePath, timeoutMs }); + if (isArchiveExtractFailure(preflight)) { + return preflight; } - const entries = listResult.stdout - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - - const verboseResult = await runCommandWithTimeout(["tar", "tvf", archivePath], { timeoutMs }); - if (verboseResult.code !== 0) { - return { - stdout: verboseResult.stdout, - stderr: verboseResult.stderr || "tar verbose list failed", - code: verboseResult.code, - }; - } - const metadata = parseTarVerboseMetadata(verboseResult.stdout); - if (metadata.length !== entries.length) { - return { - stdout: verboseResult.stdout, - stderr: `tar verbose/list entry count mismatch (${metadata.length} vs ${entries.length})`, - code: 1, - }; - } - const checkTarEntrySafety = createTarEntrySafetyChecker({ - rootDir: targetDir, + const checkTarEntrySafety = createTarEntryPreflightChecker({ + rootDir: destinationRealDir, stripComponents: strip, escapeLabel: "targetDir", }); - for (let i = 0; i < entries.length; i += 1) { - const entryPath = entries[i]; - const entryMeta = metadata[i]; + for (let i = 0; i < preflight.entries.length; i += 1) { + const entryPath = preflight.entries[i]; + const entryMeta = preflight.metadata[i]; if (!entryPath || !entryMeta) { return { - stdout: verboseResult.stdout, + stdout: "", stderr: "tar metadata parse failure", code: 1, }; @@ -120,20 +209,20 @@ export async function extractArchive(params: { }); } - const postPreflightHash = await hashFileSha256(archivePath); - if (postPreflightHash !== preflightHash) { - return { - stdout: "", - stderr: "tar archive changed during safety preflight; refusing to extract", - code: 1, - }; + const hashFailure = await verifyArchiveHashStable({ + archivePath, + expectedHash: preflightHash, + }); + if (hashFailure) { + return hashFailure; } - const argv = ["tar", "xf", archivePath, "-C", targetDir]; - if (strip > 0) { - argv.push("--strip-components", String(strip)); - } - return await runCommandWithTimeout(argv, { timeoutMs }); + return await extractTarBz2WithStaging({ + archivePath, + destinationRealDir, + stripComponents: strip, + timeoutMs, + }); } return { stdout: "", stderr: `unsupported archive type: ${archiveType}`, code: null }; diff --git a/src/agents/skills-install.download.test.ts b/src/agents/skills-install.download.test.ts index 0c357089678..cee0d37b876 100644 --- a/src/agents/skills-install.download.test.ts +++ b/src/agents/skills-install.download.test.ts @@ -425,4 +425,47 @@ describe("installDownloadSpec extraction safety (tar.bz2)", () => { .some((call) => (call[0] as string[])[1] === "xf"); expect(extractionAttempted).toBe(false); }); + + it("rejects tar.bz2 entries that traverse pre-existing targetDir symlinks", async () => { + const entry = buildEntry("tbz2-targetdir-symlink"); + const targetDir = path.join(resolveSkillToolsRootDir(entry), "target"); + const outsideDir = path.join(workspaceDir, "tbz2-targetdir-outside"); + await fs.mkdir(targetDir, { recursive: true }); + await fs.mkdir(outsideDir, { recursive: true }); + await fs.symlink( + outsideDir, + path.join(targetDir, "escape"), + process.platform === "win32" ? "junction" : undefined, + ); + + mockArchiveResponse(new Uint8Array([1, 2, 3])); + + runCommandWithTimeoutMock.mockImplementation(async (...argv: unknown[]) => { + const cmd = (argv[0] ?? []) as string[]; + if (cmd[0] === "tar" && cmd[1] === "tf") { + return runCommandResult({ stdout: "escape/pwn.txt\n" }); + } + if (cmd[0] === "tar" && cmd[1] === "tvf") { + return runCommandResult({ stdout: "-rw-r--r-- 0 0 0 0 Jan 1 00:00 escape/pwn.txt\n" }); + } + if (cmd[0] === "tar" && cmd[1] === "xf") { + const stagingDir = String(cmd[cmd.indexOf("-C") + 1] ?? ""); + await fs.mkdir(path.join(stagingDir, "escape"), { recursive: true }); + await fs.writeFile(path.join(stagingDir, "escape", "pwn.txt"), "owned"); + return runCommandResult({ stdout: "ok" }); + } + return runCommandResult(); + }); + + const result = await installDownloadSkill({ + name: "tbz2-targetdir-symlink", + url: "https://example.invalid/evil.tbz2", + archive: "tar.bz2", + targetDir, + }); + + expect(result.ok).toBe(false); + expect(result.stderr.toLowerCase()).toContain("archive entry traverses symlink in destination"); + expect(await fileExists(path.join(outsideDir, "pwn.txt"))).toBe(false); + }); }); diff --git a/src/agents/tools/pdf-native-providers.ts b/src/agents/tools/pdf-native-providers.ts index 36d43ffb9f7..70a1e2e0e94 100644 --- a/src/agents/tools/pdf-native-providers.ts +++ b/src/agents/tools/pdf-native-providers.ts @@ -137,10 +137,9 @@ export async function geminiAnalyzePdf(params: { } parts.push({ text: params.prompt }); - const baseUrl = (params.baseUrl ?? "https://generativelanguage.googleapis.com").replace( - /\/+$/, - "", - ); + const baseUrl = (params.baseUrl ?? "https://generativelanguage.googleapis.com") + .replace(/\/+$/, "") + .replace(/\/v1beta$/, ""); const url = `${baseUrl}/v1beta/models/${encodeURIComponent(params.modelId)}:generateContent?key=${encodeURIComponent(apiKey)}`; const res = await fetch(url, { diff --git a/src/agents/tools/pdf-tool.test.ts b/src/agents/tools/pdf-tool.test.ts index 6cbc6ca54d1..381fc53c4b9 100644 --- a/src/agents/tools/pdf-tool.test.ts +++ b/src/agents/tools/pdf-tool.test.ts @@ -711,6 +711,26 @@ describe("native PDF provider API calls", () => { "apiKey required", ); }); + + it("geminiAnalyzePdf does not duplicate /v1beta when baseUrl already includes it", async () => { + const { geminiAnalyzePdf } = await import("./pdf-native-providers.js"); + const fetchMock = mockFetchResponse({ + ok: true, + json: async () => ({ + candidates: [{ content: { parts: [{ text: "ok" }] } }], + }), + }); + + await geminiAnalyzePdf( + makeGeminiAnalyzeParams({ + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + }), + ); + + const [url] = fetchMock.mock.calls[0]; + expect(url).toContain("/v1beta/models/"); + expect(url).not.toContain("/v1beta/v1beta"); + }); }); // --------------------------------------------------------------------------- diff --git a/src/agents/tools/telegram-actions.test.ts b/src/agents/tools/telegram-actions.test.ts index eeeb7bbf35b..e15b4bd2e17 100644 --- a/src/agents/tools/telegram-actions.test.ts +++ b/src/agents/tools/telegram-actions.test.ts @@ -18,6 +18,16 @@ const sendStickerTelegram = vi.fn(async () => ({ chatId: "123", })); const deleteMessageTelegram = vi.fn(async () => ({ ok: true })); +const editMessageTelegram = vi.fn(async () => ({ + ok: true, + messageId: "456", + chatId: "123", +})); +const createForumTopicTelegram = vi.fn(async () => ({ + topicId: 99, + name: "Topic", + chatId: "123", +})); let envSnapshot: ReturnType; vi.mock("../../telegram/send.js", () => ({ @@ -30,6 +40,10 @@ vi.mock("../../telegram/send.js", () => ({ sendStickerTelegram(...args), deleteMessageTelegram: (...args: Parameters) => deleteMessageTelegram(...args), + editMessageTelegram: (...args: Parameters) => + editMessageTelegram(...args), + createForumTopicTelegram: (...args: Parameters) => + createForumTopicTelegram(...args), })); describe("handleTelegramAction", () => { @@ -90,6 +104,8 @@ describe("handleTelegramAction", () => { sendPollTelegram.mockClear(); sendStickerTelegram.mockClear(); deleteMessageTelegram.mockClear(); + editMessageTelegram.mockClear(); + createForumTopicTelegram.mockClear(); process.env.TELEGRAM_BOT_TOKEN = "tok"; }); @@ -379,6 +395,85 @@ describe("handleTelegramAction", () => { ); }); + it.each([ + { + name: "react", + params: { action: "react", chatId: "123", messageId: 456, emoji: "✅" }, + cfg: reactionConfig("minimal"), + assertCall: ( + readCallOpts: (calls: unknown[][], argIndex: number) => Record, + ) => readCallOpts(reactMessageTelegram.mock.calls as unknown[][], 3), + }, + { + name: "sendMessage", + params: { action: "sendMessage", to: "123", content: "hello" }, + cfg: telegramConfig(), + assertCall: ( + readCallOpts: (calls: unknown[][], argIndex: number) => Record, + ) => readCallOpts(sendMessageTelegram.mock.calls as unknown[][], 2), + }, + { + name: "poll", + params: { + action: "poll", + to: "123", + question: "Q?", + answers: ["A", "B"], + }, + cfg: telegramConfig(), + assertCall: ( + readCallOpts: (calls: unknown[][], argIndex: number) => Record, + ) => readCallOpts(sendPollTelegram.mock.calls as unknown[][], 2), + }, + { + name: "deleteMessage", + params: { action: "deleteMessage", chatId: "123", messageId: 1 }, + cfg: telegramConfig(), + assertCall: ( + readCallOpts: (calls: unknown[][], argIndex: number) => Record, + ) => readCallOpts(deleteMessageTelegram.mock.calls as unknown[][], 2), + }, + { + name: "editMessage", + params: { action: "editMessage", chatId: "123", messageId: 1, content: "updated" }, + cfg: telegramConfig(), + assertCall: ( + readCallOpts: (calls: unknown[][], argIndex: number) => Record, + ) => readCallOpts(editMessageTelegram.mock.calls as unknown[][], 3), + }, + { + name: "sendSticker", + params: { action: "sendSticker", to: "123", fileId: "sticker-1" }, + cfg: telegramConfig({ actions: { sticker: true } }), + assertCall: ( + readCallOpts: (calls: unknown[][], argIndex: number) => Record, + ) => readCallOpts(sendStickerTelegram.mock.calls as unknown[][], 2), + }, + { + name: "createForumTopic", + params: { action: "createForumTopic", chatId: "123", name: "Topic" }, + cfg: telegramConfig({ actions: { createForumTopic: true } }), + assertCall: ( + readCallOpts: (calls: unknown[][], argIndex: number) => Record, + ) => readCallOpts(createForumTopicTelegram.mock.calls as unknown[][], 2), + }, + ])("forwards resolved cfg for $name action", async ({ params, cfg, assertCall }) => { + const readCallOpts = (calls: unknown[][], argIndex: number): Record => { + const args = calls[0]; + if (!Array.isArray(args)) { + throw new Error("Expected Telegram action call args"); + } + const opts = args[argIndex]; + if (!opts || typeof opts !== "object") { + throw new Error("Expected Telegram action options object"); + } + return opts as Record; + }; + await handleTelegramAction(params as Record, cfg); + const opts = assertCall(readCallOpts); + expect(opts.cfg).toBe(cfg); + }); + it.each([ { name: "media", diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index 30c07530159..143d154e633 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -154,6 +154,7 @@ export async function handleTelegramAction( let reactionResult: Awaited>; try { reactionResult = await reactMessageTelegram(chatId ?? "", messageId ?? 0, emoji ?? "", { + cfg, token, remove, accountId: accountId ?? undefined, @@ -237,6 +238,7 @@ export async function handleTelegramAction( ); } const result = await sendMessageTelegram(to, content, { + cfg, token, accountId: accountId ?? undefined, mediaUrl: mediaUrl || undefined, @@ -293,6 +295,7 @@ export async function handleTelegramAction( durationHours: durationHours ?? undefined, }, { + cfg, token, accountId: accountId ?? undefined, replyToMessageId: replyToMessageId ?? undefined, @@ -327,6 +330,7 @@ export async function handleTelegramAction( ); } await deleteMessageTelegram(chatId ?? "", messageId ?? 0, { + cfg, token, accountId: accountId ?? undefined, }); @@ -367,6 +371,7 @@ export async function handleTelegramAction( ); } const result = await editMessageTelegram(chatId ?? "", messageId ?? 0, content, { + cfg, token, accountId: accountId ?? undefined, buttons, @@ -399,6 +404,7 @@ export async function handleTelegramAction( ); } const result = await sendStickerTelegram(to, fileId, { + cfg, token, accountId: accountId ?? undefined, replyToMessageId: replyToMessageId ?? undefined, @@ -454,6 +460,7 @@ export async function handleTelegramAction( ); } const result = await createForumTopicTelegram(chatId ?? "", name, { + cfg, token, accountId: accountId ?? undefined, iconColor: iconColor ?? undefined, diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts index 599a8fd6a48..6bebdc6a390 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts @@ -1255,6 +1255,79 @@ describe("runReplyAgent typing (heartbeat)", () => { }); }); + it("clears stale runtime model fields when resetSession retries after compaction failure", async () => { + await withTempStateDir(async (stateDir) => { + const sessionId = "session-stale-model"; + const storePath = path.join(stateDir, "sessions", "sessions.json"); + const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId); + const sessionEntry: SessionEntry = { + sessionId, + updatedAt: Date.now(), + sessionFile: transcriptPath, + modelProvider: "qwencode", + model: "qwen3.5-plus-2026-02-15", + contextTokens: 123456, + systemPromptReport: { + source: "run", + generatedAt: Date.now(), + sessionId, + sessionKey: "main", + provider: "qwencode", + model: "qwen3.5-plus-2026-02-15", + workspaceDir: stateDir, + bootstrapMaxChars: 1000, + bootstrapTotalMaxChars: 2000, + systemPrompt: { + chars: 10, + projectContextChars: 5, + nonProjectContextChars: 5, + }, + injectedWorkspaceFiles: [], + skills: { + promptChars: 0, + entries: [], + }, + tools: { + listChars: 0, + schemaChars: 0, + entries: [], + }, + }, + }; + const sessionStore = { main: sessionEntry }; + + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + await fs.writeFile(transcriptPath, "ok", "utf-8"); + + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => { + throw new Error( + 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', + ); + }); + + const { run } = createMinimalRun({ + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + await run(); + + expect(sessionStore.main.modelProvider).toBeUndefined(); + expect(sessionStore.main.model).toBeUndefined(); + expect(sessionStore.main.contextTokens).toBeUndefined(); + expect(sessionStore.main.systemPromptReport).toBeUndefined(); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(persisted.main.modelProvider).toBeUndefined(); + expect(persisted.main.model).toBeUndefined(); + expect(persisted.main.contextTokens).toBeUndefined(); + expect(persisted.main.systemPromptReport).toBeUndefined(); + }); + }); + it("surfaces overflow fallback when embedded run returns empty payloads", async () => { state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ payloads: [], diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index b6dcd7dcd91..edc441a2552 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -278,6 +278,10 @@ export async function runReplyAgent(params: { updatedAt: Date.now(), systemSent: false, abortedLastRun: false, + modelProvider: undefined, + model: undefined, + contextTokens: undefined, + systemPromptReport: undefined, fallbackNoticeSelectedModel: undefined, fallbackNoticeActiveModel: undefined, fallbackNoticeReason: undefined, diff --git a/src/auto-reply/reply/directive-handling.auth.test.ts b/src/auto-reply/reply/directive-handling.auth.test.ts index 04249b88795..4faad0c3ee6 100644 --- a/src/auto-reply/reply/directive-handling.auth.test.ts +++ b/src/auto-reply/reply/directive-handling.auth.test.ts @@ -32,7 +32,7 @@ vi.mock("../../agents/model-selection.js", () => ({ vi.mock("../../agents/model-auth.js", () => ({ ensureAuthProfileStore: () => mockStore, - getCustomProviderApiKey: () => undefined, + resolveUsableCustomProviderApiKey: () => null, resolveAuthProfileOrder: () => mockOrder, resolveEnvApiKey: () => null, })); diff --git a/src/auto-reply/reply/directive-handling.auth.ts b/src/auto-reply/reply/directive-handling.auth.ts index dd33ed6ae73..26647d77c68 100644 --- a/src/auto-reply/reply/directive-handling.auth.ts +++ b/src/auto-reply/reply/directive-handling.auth.ts @@ -6,9 +6,9 @@ import { } from "../../agents/auth-profiles.js"; import { ensureAuthProfileStore, - getCustomProviderApiKey, resolveAuthProfileOrder, resolveEnvApiKey, + resolveUsableCustomProviderApiKey, } from "../../agents/model-auth.js"; import { findNormalizedProviderValue, normalizeProviderId } from "../../agents/model-selection.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -204,7 +204,7 @@ export const resolveAuthLabel = async ( const label = isOAuthEnv ? "OAuth (env)" : maskApiKey(envKey.apiKey); return { label, source: mode === "verbose" ? envKey.source : "" }; } - const customKey = getCustomProviderApiKey(cfg, provider); + const customKey = resolveUsableCustomProviderApiKey({ cfg, provider })?.apiKey; if (customKey) { return { label: maskApiKey(customKey), diff --git a/src/browser/cdp.helpers.ts b/src/browser/cdp.helpers.ts index 5749a591fd6..44f689e8706 100644 --- a/src/browser/cdp.helpers.ts +++ b/src/browser/cdp.helpers.ts @@ -3,6 +3,7 @@ import { isLoopbackHost } from "../gateway/net.js"; import { rawDataToString } from "../infra/ws.js"; import { getDirectAgentForCdp, withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js"; import { CDP_HTTP_REQUEST_TIMEOUT_MS, CDP_WS_HANDSHAKE_TIMEOUT_MS } from "./cdp-timeouts.js"; +import { resolveBrowserRateLimitMessage } from "./client-fetch.js"; import { getChromeExtensionRelayAuthHeaders } from "./extension-relay.js"; export { isLoopbackHost }; @@ -172,6 +173,10 @@ export async function fetchCdpChecked( fetch(url, { ...init, headers, signal: ctrl.signal }), ); if (!res.ok) { + if (res.status === 429) { + // Do not reflect upstream response text into the error surface (log/agent injection risk) + throw new Error(`${resolveBrowserRateLimitMessage(url)} Do NOT retry the browser tool.`); + } throw new Error(`HTTP ${res.status}`); } return res; diff --git a/src/browser/client-fetch.loopback-auth.test.ts b/src/browser/client-fetch.loopback-auth.test.ts index cda6d29d4e3..7967d11c76e 100644 --- a/src/browser/client-fetch.loopback-auth.test.ts +++ b/src/browser/client-fetch.loopback-auth.test.ts @@ -1,4 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { BrowserDispatchResponse } from "./routes/dispatcher.js"; + +function okDispatchResponse(): BrowserDispatchResponse { + return { status: 200, body: { ok: true } }; +} const mocks = vi.hoisted(() => ({ loadConfig: vi.fn(() => ({ @@ -9,7 +14,7 @@ const mocks = vi.hoisted(() => ({ }, })), startBrowserControlServiceFromConfig: vi.fn(async () => ({ ok: true })), - dispatch: vi.fn(async () => ({ status: 200, body: { ok: true } })), + dispatch: vi.fn(async (): Promise => okDispatchResponse()), })); vi.mock("../config/config.js", async (importOriginal) => { @@ -57,7 +62,7 @@ describe("fetchBrowserJson loopback auth", () => { }, }); mocks.startBrowserControlServiceFromConfig.mockReset().mockResolvedValue({ ok: true }); - mocks.dispatch.mockReset().mockResolvedValue({ status: 200, body: { ok: true } }); + mocks.dispatch.mockReset().mockResolvedValue(okDispatchResponse()); }); afterEach(() => { @@ -133,6 +138,102 @@ describe("fetchBrowserJson loopback auth", () => { expect(thrown.message).not.toContain("Can't reach the OpenClaw browser control service"); }); + it("surfaces 429 from HTTP URL as rate-limit error with no-retry hint", async () => { + const response = new Response("max concurrent sessions exceeded", { status: 429 }); + const text = vi.spyOn(response, "text"); + const cancel = vi.spyOn(response.body!, "cancel").mockResolvedValue(undefined); + vi.stubGlobal( + "fetch", + vi.fn(async () => response), + ); + + const thrown = await fetchBrowserJson<{ ok: boolean }>("http://127.0.0.1:18888/").catch( + (err: unknown) => err, + ); + + expect(thrown).toBeInstanceOf(Error); + if (!(thrown instanceof Error)) { + throw new Error(`Expected Error, got ${String(thrown)}`); + } + expect(thrown.message).toContain("Browser service rate limit reached"); + expect(thrown.message).toContain("Do NOT retry the browser tool"); + expect(thrown.message).not.toContain("max concurrent sessions exceeded"); + expect(text).not.toHaveBeenCalled(); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it("surfaces 429 from HTTP URL without body detail when empty", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("", { status: 429 })), + ); + + const thrown = await fetchBrowserJson<{ ok: boolean }>("http://127.0.0.1:18888/").catch( + (err: unknown) => err, + ); + + expect(thrown).toBeInstanceOf(Error); + if (!(thrown instanceof Error)) { + throw new Error(`Expected Error, got ${String(thrown)}`); + } + expect(thrown.message).toContain("rate limit reached"); + expect(thrown.message).toContain("Do NOT retry the browser tool"); + }); + + it("keeps Browserbase-specific wording for Browserbase 429 responses", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("max concurrent sessions exceeded", { status: 429 })), + ); + + const thrown = await fetchBrowserJson<{ ok: boolean }>( + "https://connect.browserbase.com/session", + ).catch((err: unknown) => err); + + expect(thrown).toBeInstanceOf(Error); + if (!(thrown instanceof Error)) { + throw new Error(`Expected Error, got ${String(thrown)}`); + } + expect(thrown.message).toContain("Browserbase rate limit reached"); + expect(thrown.message).toContain("upgrade your plan"); + expect(thrown.message).not.toContain("max concurrent sessions exceeded"); + }); + + it("non-429 errors still produce generic messages", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("internal error", { status: 500 })), + ); + + const thrown = await fetchBrowserJson<{ ok: boolean }>("http://127.0.0.1:18888/").catch( + (err: unknown) => err, + ); + + expect(thrown).toBeInstanceOf(Error); + if (!(thrown instanceof Error)) { + throw new Error(`Expected Error, got ${String(thrown)}`); + } + expect(thrown.message).toContain("internal error"); + expect(thrown.message).not.toContain("rate limit"); + }); + + it("surfaces 429 from dispatcher path as rate-limit error", async () => { + mocks.dispatch.mockResolvedValueOnce({ + status: 429, + body: { error: "too many sessions" }, + }); + + const thrown = await fetchBrowserJson<{ ok: boolean }>("/tabs").catch((err: unknown) => err); + + expect(thrown).toBeInstanceOf(Error); + if (!(thrown instanceof Error)) { + throw new Error(`Expected Error, got ${String(thrown)}`); + } + expect(thrown.message).toContain("Browser service rate limit reached"); + expect(thrown.message).toContain("Do NOT retry the browser tool"); + expect(thrown.message).not.toContain("too many sessions"); + }); + it("keeps absolute URL failures wrapped as reachability errors", async () => { vi.stubGlobal( "fetch", diff --git a/src/browser/client-fetch.ts b/src/browser/client-fetch.ts index 8f13da4e1aa..e321c5a1e62 100644 --- a/src/browser/client-fetch.ts +++ b/src/browser/client-fetch.ts @@ -102,6 +102,36 @@ const BROWSER_TOOL_MODEL_HINT = "Do NOT retry the browser tool — it will keep failing. " + "Use an alternative approach or inform the user that the browser is currently unavailable."; +const BROWSER_SERVICE_RATE_LIMIT_MESSAGE = + "Browser service rate limit reached. " + + "Wait for the current session to complete, or retry later."; + +const BROWSERBASE_RATE_LIMIT_MESSAGE = + "Browserbase rate limit reached (max concurrent sessions). " + + "Wait for the current session to complete, or upgrade your plan."; + +function isRateLimitStatus(status: number): boolean { + return status === 429; +} + +function isBrowserbaseUrl(url: string): boolean { + if (!isAbsoluteHttp(url)) { + return false; + } + try { + const host = new URL(url).hostname.toLowerCase(); + return host === "browserbase.com" || host.endsWith(".browserbase.com"); + } catch { + return false; + } +} + +export function resolveBrowserRateLimitMessage(url: string): string { + return isBrowserbaseUrl(url) + ? BROWSERBASE_RATE_LIMIT_MESSAGE + : BROWSER_SERVICE_RATE_LIMIT_MESSAGE; +} + function resolveBrowserFetchOperatorHint(url: string): string { const isLocal = !isAbsoluteHttp(url); return isLocal @@ -123,6 +153,14 @@ function appendBrowserToolModelHint(message: string): string { return `${message} ${BROWSER_TOOL_MODEL_HINT}`; } +async function discardResponseBody(res: Response): Promise { + try { + await res.body?.cancel(); + } catch { + // Best effort only; we're already returning a stable error message. + } +} + function enhanceDispatcherPathError(url: string, err: unknown): Error { const msg = normalizeErrorMessage(err); const suffix = `${resolveBrowserFetchOperatorHint(url)} ${BROWSER_TOOL_MODEL_HINT}`; @@ -175,6 +213,13 @@ async function fetchHttpJson( try { const res = await fetch(url, { ...init, signal: ctrl.signal }); if (!res.ok) { + if (isRateLimitStatus(res.status)) { + // Do not reflect upstream response text into the error surface (log/agent injection risk) + await discardResponseBody(res); + throw new BrowserServiceError( + `${resolveBrowserRateLimitMessage(url)} ${BROWSER_TOOL_MODEL_HINT}`, + ); + } const text = await res.text().catch(() => ""); throw new BrowserServiceError(text || `HTTP ${res.status}`); } @@ -269,6 +314,12 @@ export async function fetchBrowserJson( }); if (result.status >= 400) { + if (isRateLimitStatus(result.status)) { + // Do not reflect upstream response text into the error surface (log/agent injection risk) + throw new BrowserServiceError( + `${resolveBrowserRateLimitMessage(url)} ${BROWSER_TOOL_MODEL_HINT}`, + ); + } const message = result.body && typeof result.body === "object" && "error" in result.body ? String((result.body as { error?: unknown }).error) diff --git a/src/browser/pw-session.ts b/src/browser/pw-session.ts index a7103c1174c..2e63d190dea 100644 --- a/src/browser/pw-session.ts +++ b/src/browser/pw-session.ts @@ -365,6 +365,11 @@ async function connectBrowser(cdpUrl: string): Promise { return connected; } catch (err) { lastErr = err; + // Don't retry rate-limit errors; retrying worsens the 429. + const errMsg = err instanceof Error ? err.message : String(err); + if (errMsg.includes("rate limit")) { + break; + } const delay = 250 + attempt * 250; await new Promise((r) => setTimeout(r, delay)); } diff --git a/src/channels/allowlist-match.test.ts b/src/channels/allowlist-match.test.ts new file mode 100644 index 00000000000..9a55e593e57 --- /dev/null +++ b/src/channels/allowlist-match.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { + resolveAllowlistMatchByCandidates, + resolveAllowlistMatchSimple, +} from "./allowlist-match.js"; + +describe("channels/allowlist-match", () => { + it("reflects in-place allowFrom edits even when array length stays the same", () => { + const allowFrom = ["alice", "bob"]; + + expect(resolveAllowlistMatchSimple({ allowFrom, senderId: "bob" })).toEqual({ + allowed: true, + matchKey: "bob", + matchSource: "id", + }); + + allowFrom[1] = "mallory"; + + expect(resolveAllowlistMatchSimple({ allowFrom, senderId: "bob" })).toEqual({ + allowed: false, + }); + expect(resolveAllowlistMatchSimple({ allowFrom, senderId: "mallory" })).toEqual({ + allowed: true, + matchKey: "mallory", + matchSource: "id", + }); + }); + + it("drops wildcard access after in-place wildcard replacement", () => { + const allowFrom = ["*"]; + + expect(resolveAllowlistMatchSimple({ allowFrom, senderId: "eve" })).toEqual({ + allowed: true, + matchKey: "*", + matchSource: "wildcard", + }); + + allowFrom[0] = "alice"; + + expect(resolveAllowlistMatchSimple({ allowFrom, senderId: "eve" })).toEqual({ + allowed: false, + }); + expect(resolveAllowlistMatchSimple({ allowFrom, senderId: "alice" })).toEqual({ + allowed: true, + matchKey: "alice", + matchSource: "id", + }); + }); + + it("recomputes candidate allowlist sets after in-place replacement", () => { + const allowList = ["user:alice", "user:bob"]; + + expect( + resolveAllowlistMatchByCandidates({ + allowList, + candidates: [{ value: "user:bob", source: "prefixed-user" }], + }), + ).toEqual({ + allowed: true, + matchKey: "user:bob", + matchSource: "prefixed-user", + }); + + allowList[1] = "user:mallory"; + + expect( + resolveAllowlistMatchByCandidates({ + allowList, + candidates: [{ value: "user:bob", source: "prefixed-user" }], + }), + ).toEqual({ + allowed: false, + }); + expect( + resolveAllowlistMatchByCandidates({ + allowList, + candidates: [{ value: "user:mallory", source: "prefixed-user" }], + }), + ).toEqual({ + allowed: true, + matchKey: "user:mallory", + matchSource: "prefixed-user", + }); + }); +}); diff --git a/src/channels/allowlist-match.ts b/src/channels/allowlist-match.ts index b30ef119c84..f32d5a2487c 100644 --- a/src/channels/allowlist-match.ts +++ b/src/channels/allowlist-match.ts @@ -16,33 +16,40 @@ export type AllowlistMatch = { matchSource?: TSource; }; -type CachedAllowListSet = { - size: number; - set: Set; +export type CompiledAllowlist = { + set: ReadonlySet; + wildcard: boolean; }; -const ALLOWLIST_SET_CACHE = new WeakMap(); -const SIMPLE_ALLOWLIST_CACHE = new WeakMap< - Array, - { normalized: string[]; size: number; wildcard: boolean; set: Set } ->(); - export function formatAllowlistMatchMeta( match?: { matchKey?: string; matchSource?: string } | null, ): string { return `matchKey=${match?.matchKey ?? "none"} matchSource=${match?.matchSource ?? "none"}`; } -export function resolveAllowlistMatchByCandidates(params: { - allowList: string[]; +export function compileAllowlist(entries: ReadonlyArray): CompiledAllowlist { + const set = new Set(entries.filter(Boolean)); + return { + set, + wildcard: set.has("*"), + }; +} + +function compileSimpleAllowlist(entries: ReadonlyArray): CompiledAllowlist { + return compileAllowlist( + entries.map((entry) => String(entry).trim().toLowerCase()).filter(Boolean), + ); +} + +export function resolveAllowlistCandidates(params: { + compiledAllowlist: CompiledAllowlist; candidates: Array<{ value?: string; source: TSource }>; }): AllowlistMatch { - const allowSet = resolveAllowListSet(params.allowList); for (const candidate of params.candidates) { if (!candidate.value) { continue; } - if (allowSet.has(candidate.value)) { + if (params.compiledAllowlist.set.has(candidate.value)) { return { allowed: true, matchKey: candidate.value, @@ -53,15 +60,25 @@ export function resolveAllowlistMatchByCandidates(params return { allowed: false }; } +export function resolveAllowlistMatchByCandidates(params: { + allowList: ReadonlyArray; + candidates: Array<{ value?: string; source: TSource }>; +}): AllowlistMatch { + return resolveAllowlistCandidates({ + compiledAllowlist: compileAllowlist(params.allowList), + candidates: params.candidates, + }); +} + export function resolveAllowlistMatchSimple(params: { - allowFrom: Array; + allowFrom: ReadonlyArray; senderId: string; senderName?: string | null; allowNameMatching?: boolean; }): AllowlistMatch<"wildcard" | "id" | "name"> { - const allowFrom = resolveSimpleAllowFrom(params.allowFrom); + const allowFrom = compileSimpleAllowlist(params.allowFrom); - if (allowFrom.size === 0) { + if (allowFrom.set.size === 0) { return { allowed: false }; } if (allowFrom.wildcard) { @@ -69,47 +86,17 @@ export function resolveAllowlistMatchSimple(params: { } const senderId = params.senderId.toLowerCase(); - if (allowFrom.set.has(senderId)) { - return { allowed: true, matchKey: senderId, matchSource: "id" }; - } - const senderName = params.senderName?.toLowerCase(); - if (params.allowNameMatching === true && senderName && allowFrom.set.has(senderName)) { - return { allowed: true, matchKey: senderName, matchSource: "name" }; - } - - return { allowed: false }; -} - -function resolveAllowListSet(allowList: string[]): Set { - const cached = ALLOWLIST_SET_CACHE.get(allowList); - if (cached && cached.size === allowList.length) { - return cached.set; - } - const set = new Set(allowList); - ALLOWLIST_SET_CACHE.set(allowList, { size: allowList.length, set }); - return set; -} - -function resolveSimpleAllowFrom(allowFrom: Array): { - normalized: string[]; - size: number; - wildcard: boolean; - set: Set; -} { - const cached = SIMPLE_ALLOWLIST_CACHE.get(allowFrom); - if (cached && cached.size === allowFrom.length) { - return cached; - } - - const normalized = allowFrom.map((entry) => String(entry).trim().toLowerCase()).filter(Boolean); - const set = new Set(normalized); - const built = { - normalized, - size: allowFrom.length, - wildcard: set.has("*"), - set, - }; - SIMPLE_ALLOWLIST_CACHE.set(allowFrom, built); - return built; + return resolveAllowlistCandidates({ + compiledAllowlist: allowFrom, + candidates: [ + { value: senderId, source: "id" }, + ...(params.allowNameMatching === true && senderName + ? ([{ value: senderName, source: "name" as const }] satisfies Array<{ + value?: string; + source: "id" | "name"; + }>) + : []), + ], + }); } diff --git a/src/channels/plugins/config-schema.ts b/src/channels/plugins/config-schema.ts index 35be4c9d388..5ae166aa5a7 100644 --- a/src/channels/plugins/config-schema.ts +++ b/src/channels/plugins/config-schema.ts @@ -1,4 +1,5 @@ import { z, type ZodTypeAny } from "zod"; +import { DmPolicySchema } from "../../config/zod-schema.core.js"; import type { ChannelConfigSchema } from "./types.plugin.js"; type ZodSchemaWithToJsonSchema = ZodTypeAny & { @@ -10,6 +11,17 @@ type ExtendableZodObject = ZodTypeAny & { }; export const AllowFromEntrySchema = z.union([z.string(), z.number()]); +export const AllowFromListSchema = z.array(AllowFromEntrySchema).optional(); + +export function buildNestedDmConfigSchema() { + return z + .object({ + enabled: z.boolean().optional(), + policy: DmPolicySchema.optional(), + allowFrom: AllowFromListSchema, + }) + .optional(); +} export function buildCatchallMultiAccountChannelSchema( accountSchema: T, diff --git a/src/channels/plugins/onboarding/discord.ts b/src/channels/plugins/onboarding/discord.ts index 52f0d2b1373..d6a8c8df370 100644 --- a/src/channels/plugins/onboarding/discord.ts +++ b/src/channels/plugins/onboarding/discord.ts @@ -20,15 +20,14 @@ import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onb import { configureChannelAccessWithAllowlist } from "./channel-access-configure.js"; import { applySingleTokenPromptResult, - buildSingleChannelSecretPromptState, parseMentionOrPrefixedId, noteChannelLookupFailure, noteChannelLookupSummary, patchChannelConfigForAccount, promptLegacyChannelAllowFrom, - promptSingleChannelSecretInput, resolveAccountIdForConfigure, resolveOnboardingAccountId, + runSingleChannelSecretStep, setAccountGroupPolicyForChannel, setLegacyChannelDmPolicyWithAllowFrom, setOnboardingChannelEnabled, @@ -179,52 +178,39 @@ export const discordOnboardingAdapter: ChannelOnboardingAdapter = { accountId: discordAccountId, }); const allowEnv = discordAccountId === DEFAULT_ACCOUNT_ID; - const tokenPromptState = buildSingleChannelSecretPromptState({ - accountConfigured: Boolean(resolvedAccount.token), - hasConfigToken: hasConfiguredSecretInput(resolvedAccount.config.token), - allowEnv, - envValue: process.env.DISCORD_BOT_TOKEN, - }); - - if (!tokenPromptState.accountConfigured) { - await noteDiscordTokenHelp(prompter); - } - - const tokenResult = await promptSingleChannelSecretInput({ + const tokenStep = await runSingleChannelSecretStep({ cfg: next, prompter, providerHint: "discord", credentialLabel: "Discord bot token", secretInputMode: options?.secretInputMode, - accountConfigured: tokenPromptState.accountConfigured, - canUseEnv: tokenPromptState.canUseEnv, - hasConfigToken: tokenPromptState.hasConfigToken, + accountConfigured: Boolean(resolvedAccount.token), + hasConfigToken: hasConfiguredSecretInput(resolvedAccount.config.token), + allowEnv, + envValue: process.env.DISCORD_BOT_TOKEN, envPrompt: "DISCORD_BOT_TOKEN detected. Use env var?", keepPrompt: "Discord token already configured. Keep it?", inputPrompt: "Enter Discord bot token", preferredEnvVar: allowEnv ? "DISCORD_BOT_TOKEN" : undefined, + onMissingConfigured: async () => await noteDiscordTokenHelp(prompter), + applyUseEnv: async (cfg) => + applySingleTokenPromptResult({ + cfg, + channel: "discord", + accountId: discordAccountId, + tokenPatchKey: "token", + tokenResult: { useEnv: true, token: null }, + }), + applySet: async (cfg, value) => + applySingleTokenPromptResult({ + cfg, + channel: "discord", + accountId: discordAccountId, + tokenPatchKey: "token", + tokenResult: { useEnv: false, token: value }, + }), }); - - let resolvedTokenForAllowlist: string | undefined; - if (tokenResult.action === "use-env") { - next = applySingleTokenPromptResult({ - cfg: next, - channel: "discord", - accountId: discordAccountId, - tokenPatchKey: "token", - tokenResult: { useEnv: true, token: null }, - }); - resolvedTokenForAllowlist = process.env.DISCORD_BOT_TOKEN?.trim() || undefined; - } else if (tokenResult.action === "set") { - next = applySingleTokenPromptResult({ - cfg: next, - channel: "discord", - accountId: discordAccountId, - tokenPatchKey: "token", - tokenResult: { useEnv: false, token: tokenResult.value }, - }); - resolvedTokenForAllowlist = tokenResult.resolvedValue; - } + next = tokenStep.cfg; const currentEntries = Object.entries(resolvedAccount.config.guilds ?? {}).flatMap( ([guildKey, value]) => { @@ -261,7 +247,7 @@ export const discordOnboardingAdapter: ChannelOnboardingAdapter = { input, resolved: false, })); - const activeToken = accountWithTokens.token || resolvedTokenForAllowlist || ""; + const activeToken = accountWithTokens.token || tokenStep.resolvedValue || ""; if (activeToken && entries.length > 0) { try { resolved = await resolveDiscordChannelAllowlist({ diff --git a/src/channels/plugins/onboarding/helpers.ts b/src/channels/plugins/onboarding/helpers.ts index 31ba023ba2f..6eab25fd239 100644 --- a/src/channels/plugins/onboarding/helpers.ts +++ b/src/channels/plugins/onboarding/helpers.ts @@ -9,7 +9,10 @@ import { promptAccountId as promptAccountIdSdk } from "../../../plugin-sdk/onboa import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js"; import type { WizardPrompter } from "../../../wizard/prompts.js"; import type { PromptAccountId, PromptAccountIdParams } from "../onboarding-types.js"; -import { moveSingleAccountChannelSectionToDefaultAccount } from "../setup-helpers.js"; +import { + moveSingleAccountChannelSectionToDefaultAccount, + patchScopedAccountConfig, +} from "../setup-helpers.js"; export const promptAccountId: PromptAccountId = async (params: PromptAccountIdParams) => { return await promptAccountIdSdk(params); @@ -364,50 +367,14 @@ function patchConfigForScopedAccount(params: { cfg, channelKey: channel, }); - const channelConfig = - (seededCfg.channels?.[channel] as Record | undefined) ?? {}; - - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...seededCfg, - channels: { - ...seededCfg.channels, - [channel]: { - ...channelConfig, - ...(ensureEnabled ? { enabled: true } : {}), - ...patch, - }, - }, - }; - } - - const accounts = - (channelConfig.accounts as Record> | undefined) ?? {}; - const existingAccount = accounts[accountId] ?? {}; - - return { - ...seededCfg, - channels: { - ...seededCfg.channels, - [channel]: { - ...channelConfig, - ...(ensureEnabled ? { enabled: true } : {}), - accounts: { - ...accounts, - [accountId]: { - ...existingAccount, - ...(ensureEnabled - ? { - enabled: - typeof existingAccount.enabled === "boolean" ? existingAccount.enabled : true, - } - : {}), - ...patch, - }, - }, - }, - }, - }; + return patchScopedAccountConfig({ + cfg: seededCfg, + channelKey: channel, + accountId, + patch, + ensureChannelEnabled: ensureEnabled, + ensureAccountEnabled: ensureEnabled, + }); } export function patchChannelConfigForAccount(params: { @@ -515,6 +482,82 @@ export type SingleChannelSecretInputPromptResult = | { action: "use-env" } | { action: "set"; value: SecretInput; resolvedValue: string }; +export async function runSingleChannelSecretStep(params: { + cfg: OpenClawConfig; + prompter: Pick; + providerHint: string; + credentialLabel: string; + secretInputMode?: "plaintext" | "ref"; + accountConfigured: boolean; + hasConfigToken: boolean; + allowEnv: boolean; + envValue?: string; + envPrompt: string; + keepPrompt: string; + inputPrompt: string; + preferredEnvVar?: string; + onMissingConfigured?: () => Promise; + applyUseEnv?: (cfg: OpenClawConfig) => OpenClawConfig | Promise; + applySet?: ( + cfg: OpenClawConfig, + value: SecretInput, + resolvedValue: string, + ) => OpenClawConfig | Promise; +}): Promise<{ + cfg: OpenClawConfig; + action: SingleChannelSecretInputPromptResult["action"]; + resolvedValue?: string; +}> { + const promptState = buildSingleChannelSecretPromptState({ + accountConfigured: params.accountConfigured, + hasConfigToken: params.hasConfigToken, + allowEnv: params.allowEnv, + envValue: params.envValue, + }); + + if (!promptState.accountConfigured && params.onMissingConfigured) { + await params.onMissingConfigured(); + } + + const result = await promptSingleChannelSecretInput({ + cfg: params.cfg, + prompter: params.prompter, + providerHint: params.providerHint, + credentialLabel: params.credentialLabel, + secretInputMode: params.secretInputMode, + accountConfigured: promptState.accountConfigured, + canUseEnv: promptState.canUseEnv, + hasConfigToken: promptState.hasConfigToken, + envPrompt: params.envPrompt, + keepPrompt: params.keepPrompt, + inputPrompt: params.inputPrompt, + preferredEnvVar: params.preferredEnvVar, + }); + + if (result.action === "use-env") { + return { + cfg: params.applyUseEnv ? await params.applyUseEnv(params.cfg) : params.cfg, + action: result.action, + resolvedValue: params.envValue?.trim() || undefined, + }; + } + + if (result.action === "set") { + return { + cfg: params.applySet + ? await params.applySet(params.cfg, result.value, result.resolvedValue) + : params.cfg, + action: result.action, + resolvedValue: result.resolvedValue, + }; + } + + return { + cfg: params.cfg, + action: result.action, + }; +} + export async function promptSingleChannelSecretInput(params: { cfg: OpenClawConfig; prompter: Pick; diff --git a/src/channels/plugins/onboarding/slack.ts b/src/channels/plugins/onboarding/slack.ts index cc683477c09..0cceb859e4d 100644 --- a/src/channels/plugins/onboarding/slack.ts +++ b/src/channels/plugins/onboarding/slack.ts @@ -14,15 +14,14 @@ import type { WizardPrompter } from "../../../wizard/prompts.js"; import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js"; import { configureChannelAccessWithAllowlist } from "./channel-access-configure.js"; import { - buildSingleChannelSecretPromptState, parseMentionOrPrefixedId, noteChannelLookupFailure, noteChannelLookupSummary, patchChannelConfigForAccount, promptLegacyChannelAllowFrom, - promptSingleChannelSecretInput, resolveAccountIdForConfigure, resolveOnboardingAccountId, + runSingleChannelSecretStep, setAccountGroupPolicyForChannel, setLegacyChannelDmPolicyWithAllowFrom, setOnboardingChannelEnabled, @@ -235,18 +234,6 @@ export const slackOnboardingAdapter: ChannelOnboardingAdapter = { const accountConfigured = Boolean(resolvedAccount.botToken && resolvedAccount.appToken) || hasConfigTokens; const allowEnv = slackAccountId === DEFAULT_ACCOUNT_ID; - const botPromptState = buildSingleChannelSecretPromptState({ - accountConfigured: Boolean(resolvedAccount.botToken) || hasConfiguredBotToken, - hasConfigToken: hasConfiguredBotToken, - allowEnv, - envValue: process.env.SLACK_BOT_TOKEN, - }); - const appPromptState = buildSingleChannelSecretPromptState({ - accountConfigured: Boolean(resolvedAccount.appToken) || hasConfiguredAppToken, - hasConfigToken: hasConfiguredAppToken, - allowEnv, - envValue: process.env.SLACK_APP_TOKEN, - }); let resolvedBotTokenForAllowlist = resolvedAccount.botToken; const slackBotName = String( await prompter.text({ @@ -257,54 +244,56 @@ export const slackOnboardingAdapter: ChannelOnboardingAdapter = { if (!accountConfigured) { await noteSlackTokenHelp(prompter, slackBotName); } - const botTokenResult = await promptSingleChannelSecretInput({ + const botTokenStep = await runSingleChannelSecretStep({ cfg: next, prompter, providerHint: "slack-bot", credentialLabel: "Slack bot token", secretInputMode: options?.secretInputMode, - accountConfigured: botPromptState.accountConfigured, - canUseEnv: botPromptState.canUseEnv, - hasConfigToken: botPromptState.hasConfigToken, + accountConfigured: Boolean(resolvedAccount.botToken) || hasConfiguredBotToken, + hasConfigToken: hasConfiguredBotToken, + allowEnv, + envValue: process.env.SLACK_BOT_TOKEN, envPrompt: "SLACK_BOT_TOKEN detected. Use env var?", keepPrompt: "Slack bot token already configured. Keep it?", inputPrompt: "Enter Slack bot token (xoxb-...)", preferredEnvVar: allowEnv ? "SLACK_BOT_TOKEN" : undefined, + applySet: async (cfg, value) => + patchChannelConfigForAccount({ + cfg, + channel: "slack", + accountId: slackAccountId, + patch: { botToken: value }, + }), }); - if (botTokenResult.action === "use-env") { - resolvedBotTokenForAllowlist = process.env.SLACK_BOT_TOKEN?.trim() || undefined; - } else if (botTokenResult.action === "set") { - next = patchChannelConfigForAccount({ - cfg: next, - channel: "slack", - accountId: slackAccountId, - patch: { botToken: botTokenResult.value }, - }); - resolvedBotTokenForAllowlist = botTokenResult.resolvedValue; + next = botTokenStep.cfg; + if (botTokenStep.resolvedValue) { + resolvedBotTokenForAllowlist = botTokenStep.resolvedValue; } - const appTokenResult = await promptSingleChannelSecretInput({ + const appTokenStep = await runSingleChannelSecretStep({ cfg: next, prompter, providerHint: "slack-app", credentialLabel: "Slack app token", secretInputMode: options?.secretInputMode, - accountConfigured: appPromptState.accountConfigured, - canUseEnv: appPromptState.canUseEnv, - hasConfigToken: appPromptState.hasConfigToken, + accountConfigured: Boolean(resolvedAccount.appToken) || hasConfiguredAppToken, + hasConfigToken: hasConfiguredAppToken, + allowEnv, + envValue: process.env.SLACK_APP_TOKEN, envPrompt: "SLACK_APP_TOKEN detected. Use env var?", keepPrompt: "Slack app token already configured. Keep it?", inputPrompt: "Enter Slack app token (xapp-...)", preferredEnvVar: allowEnv ? "SLACK_APP_TOKEN" : undefined, + applySet: async (cfg, value) => + patchChannelConfigForAccount({ + cfg, + channel: "slack", + accountId: slackAccountId, + patch: { appToken: value }, + }), }); - if (appTokenResult.action === "set") { - next = patchChannelConfigForAccount({ - cfg: next, - channel: "slack", - accountId: slackAccountId, - patch: { appToken: appTokenResult.value }, - }); - } + next = appTokenStep.cfg; next = await configureChannelAccessWithAllowlist({ cfg: next, diff --git a/src/channels/plugins/onboarding/telegram.ts b/src/channels/plugins/onboarding/telegram.ts index 22a173d47fe..2c37c24bcee 100644 --- a/src/channels/plugins/onboarding/telegram.ts +++ b/src/channels/plugins/onboarding/telegram.ts @@ -14,12 +14,11 @@ import { fetchTelegramChatId } from "../../telegram/api.js"; import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js"; import { applySingleTokenPromptResult, - buildSingleChannelSecretPromptState, patchChannelConfigForAccount, - promptSingleChannelSecretInput, promptResolvedAllowFrom, resolveAccountIdForConfigure, resolveOnboardingAccountId, + runSingleChannelSecretStep, setChannelDmPolicyWithAllowFrom, setOnboardingChannelEnabled, splitOnboardingEntries, @@ -194,59 +193,46 @@ export const telegramOnboardingAdapter: ChannelOnboardingAdapter = { const hasConfigToken = hasConfiguredBotToken || Boolean(resolvedAccount.config.tokenFile?.trim()); const allowEnv = telegramAccountId === DEFAULT_ACCOUNT_ID; - const tokenPromptState = buildSingleChannelSecretPromptState({ - accountConfigured: Boolean(resolvedAccount.token) || hasConfigToken, - hasConfigToken, - allowEnv, - envValue: process.env.TELEGRAM_BOT_TOKEN, - }); - - if (!tokenPromptState.accountConfigured) { - await noteTelegramTokenHelp(prompter); - } - - const tokenResult = await promptSingleChannelSecretInput({ + const tokenStep = await runSingleChannelSecretStep({ cfg: next, prompter, providerHint: "telegram", credentialLabel: "Telegram bot token", secretInputMode: options?.secretInputMode, - accountConfigured: tokenPromptState.accountConfigured, - canUseEnv: tokenPromptState.canUseEnv, - hasConfigToken: tokenPromptState.hasConfigToken, + accountConfigured: Boolean(resolvedAccount.token) || hasConfigToken, + hasConfigToken, + allowEnv, + envValue: process.env.TELEGRAM_BOT_TOKEN, envPrompt: "TELEGRAM_BOT_TOKEN detected. Use env var?", keepPrompt: "Telegram token already configured. Keep it?", inputPrompt: "Enter Telegram bot token", preferredEnvVar: allowEnv ? "TELEGRAM_BOT_TOKEN" : undefined, + onMissingConfigured: async () => await noteTelegramTokenHelp(prompter), + applyUseEnv: async (cfg) => + applySingleTokenPromptResult({ + cfg, + channel: "telegram", + accountId: telegramAccountId, + tokenPatchKey: "botToken", + tokenResult: { useEnv: true, token: null }, + }), + applySet: async (cfg, value) => + applySingleTokenPromptResult({ + cfg, + channel: "telegram", + accountId: telegramAccountId, + tokenPatchKey: "botToken", + tokenResult: { useEnv: false, token: value }, + }), }); - - let resolvedTokenForAllowFrom: string | undefined; - if (tokenResult.action === "use-env") { - next = applySingleTokenPromptResult({ - cfg: next, - channel: "telegram", - accountId: telegramAccountId, - tokenPatchKey: "botToken", - tokenResult: { useEnv: true, token: null }, - }); - resolvedTokenForAllowFrom = process.env.TELEGRAM_BOT_TOKEN?.trim() || undefined; - } else if (tokenResult.action === "set") { - next = applySingleTokenPromptResult({ - cfg: next, - channel: "telegram", - accountId: telegramAccountId, - tokenPatchKey: "botToken", - tokenResult: { useEnv: false, token: tokenResult.value }, - }); - resolvedTokenForAllowFrom = tokenResult.resolvedValue; - } + next = tokenStep.cfg; if (forceAllowFrom) { next = await promptTelegramAllowFrom({ cfg: next, prompter, accountId: telegramAccountId, - tokenOverride: resolvedTokenForAllowFrom, + tokenOverride: tokenStep.resolvedValue, }); } diff --git a/src/channels/plugins/outbound/direct-text-media.sendpayload.test.ts b/src/channels/plugins/outbound/direct-text-media.sendpayload.test.ts index 0e5c2ba01db..42971f1e89c 100644 --- a/src/channels/plugins/outbound/direct-text-media.sendpayload.test.ts +++ b/src/channels/plugins/outbound/direct-text-media.sendpayload.test.ts @@ -1,9 +1,17 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, vi } from "vitest"; import type { ReplyPayload } from "../../../auto-reply/types.js"; +import { + installSendPayloadContractSuite, + primeSendMock, +} from "../../../test-utils/send-payload-contract.js"; import { createDirectTextMediaOutbound } from "./direct-text-media.js"; -function makeOutbound() { - const sendFn = vi.fn().mockResolvedValue({ messageId: "m1" }); +function createDirectHarness(params: { + payload: ReplyPayload; + sendResults?: Array<{ messageId: string }>; +}) { + const sendFn = vi.fn(); + primeSendMock(sendFn, { messageId: "m1" }, params.sendResults); const outbound = createDirectTextMediaOutbound({ channel: "imessage", resolveSender: () => sendFn, @@ -24,94 +32,16 @@ function baseCtx(payload: ReplyPayload) { } describe("createDirectTextMediaOutbound sendPayload", () => { - it("text-only delegates to sendText", async () => { - const { outbound, sendFn } = makeOutbound(); - const result = await outbound.sendPayload!(baseCtx({ text: "hello" })); - - expect(sendFn).toHaveBeenCalledTimes(1); - expect(sendFn).toHaveBeenCalledWith("user1", "hello", expect.any(Object)); - expect(result).toMatchObject({ channel: "imessage", messageId: "m1" }); - }); - - it("single media delegates to sendMedia", async () => { - const { outbound, sendFn } = makeOutbound(); - const result = await outbound.sendPayload!( - baseCtx({ text: "cap", mediaUrl: "https://example.com/a.jpg" }), - ); - - expect(sendFn).toHaveBeenCalledTimes(1); - expect(sendFn).toHaveBeenCalledWith( - "user1", - "cap", - expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }), - ); - expect(result).toMatchObject({ channel: "imessage", messageId: "m1" }); - }); - - it("multi-media iterates URLs with caption on first", async () => { - const sendFn = vi - .fn() - .mockResolvedValueOnce({ messageId: "m1" }) - .mockResolvedValueOnce({ messageId: "m2" }); - const outbound = createDirectTextMediaOutbound({ - channel: "imessage", - resolveSender: () => sendFn, - resolveMaxBytes: () => undefined, - buildTextOptions: (opts) => opts as never, - buildMediaOptions: (opts) => opts as never, - }); - const result = await outbound.sendPayload!( - baseCtx({ - text: "caption", - mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"], - }), - ); - - expect(sendFn).toHaveBeenCalledTimes(2); - expect(sendFn).toHaveBeenNthCalledWith( - 1, - "user1", - "caption", - expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }), - ); - expect(sendFn).toHaveBeenNthCalledWith( - 2, - "user1", - "", - expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }), - ); - expect(result).toMatchObject({ channel: "imessage", messageId: "m2" }); - }); - - it("empty payload returns no-op", async () => { - const { outbound, sendFn } = makeOutbound(); - const result = await outbound.sendPayload!(baseCtx({})); - - expect(sendFn).not.toHaveBeenCalled(); - expect(result).toEqual({ channel: "imessage", messageId: "" }); - }); - - it("chunking splits long text", async () => { - const sendFn = vi - .fn() - .mockResolvedValueOnce({ messageId: "c1" }) - .mockResolvedValueOnce({ messageId: "c2" }); - const outbound = createDirectTextMediaOutbound({ - channel: "signal", - resolveSender: () => sendFn, - resolveMaxBytes: () => undefined, - buildTextOptions: (opts) => opts as never, - buildMediaOptions: (opts) => opts as never, - }); - // textChunkLimit is 4000; generate text exceeding that - const longText = "a".repeat(5000); - const result = await outbound.sendPayload!(baseCtx({ text: longText })); - - expect(sendFn.mock.calls.length).toBeGreaterThanOrEqual(2); - // Each chunk should be within the limit - for (const call of sendFn.mock.calls) { - expect((call[1] as string).length).toBeLessThanOrEqual(4000); - } - expect(result).toMatchObject({ channel: "signal" }); + installSendPayloadContractSuite({ + channel: "imessage", + chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 }, + createHarness: ({ payload, sendResults }) => { + const { outbound, sendFn } = createDirectHarness({ payload, sendResults }); + return { + run: async () => await outbound.sendPayload!(baseCtx(payload)), + sendMock: sendFn, + to: "user1", + }; + }, }); }); diff --git a/src/channels/plugins/outbound/discord.sendpayload.test.ts b/src/channels/plugins/outbound/discord.sendpayload.test.ts index 07c821d6e79..168f8d8d927 100644 --- a/src/channels/plugins/outbound/discord.sendpayload.test.ts +++ b/src/channels/plugins/outbound/discord.sendpayload.test.ts @@ -1,98 +1,37 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, vi } from "vitest"; import type { ReplyPayload } from "../../../auto-reply/types.js"; +import { + installSendPayloadContractSuite, + primeSendMock, +} from "../../../test-utils/send-payload-contract.js"; import { discordOutbound } from "./discord.js"; -function baseCtx(payload: ReplyPayload) { - return { +function createHarness(params: { + payload: ReplyPayload; + sendResults?: Array<{ messageId: string }>; +}) { + const sendDiscord = vi.fn(); + primeSendMock(sendDiscord, { messageId: "dc-1", channelId: "123456" }, params.sendResults); + const ctx = { cfg: {}, to: "channel:123456", text: "", - payload, + payload: params.payload, deps: { - sendDiscord: vi.fn().mockResolvedValue({ messageId: "dc-1", channelId: "123456" }), + sendDiscord, }, }; + return { + run: async () => await discordOutbound.sendPayload!(ctx), + sendMock: sendDiscord, + to: ctx.to, + }; } describe("discordOutbound sendPayload", () => { - it("text-only delegates to sendText", async () => { - const ctx = baseCtx({ text: "hello" }); - const result = await discordOutbound.sendPayload!(ctx); - - expect(ctx.deps.sendDiscord).toHaveBeenCalledTimes(1); - expect(ctx.deps.sendDiscord).toHaveBeenCalledWith( - "channel:123456", - "hello", - expect.any(Object), - ); - expect(result).toMatchObject({ channel: "discord" }); - }); - - it("single media delegates to sendMedia", async () => { - const ctx = baseCtx({ text: "cap", mediaUrl: "https://example.com/a.jpg" }); - const result = await discordOutbound.sendPayload!(ctx); - - expect(ctx.deps.sendDiscord).toHaveBeenCalledTimes(1); - expect(ctx.deps.sendDiscord).toHaveBeenCalledWith( - "channel:123456", - "cap", - expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }), - ); - expect(result).toMatchObject({ channel: "discord" }); - }); - - it("multi-media iterates URLs with caption on first", async () => { - const sendDiscord = vi - .fn() - .mockResolvedValueOnce({ messageId: "dc-1", channelId: "123456" }) - .mockResolvedValueOnce({ messageId: "dc-2", channelId: "123456" }); - const ctx = { - cfg: {}, - to: "channel:123456", - text: "", - payload: { - text: "caption", - mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"], - } as ReplyPayload, - deps: { sendDiscord }, - }; - const result = await discordOutbound.sendPayload!(ctx); - - expect(sendDiscord).toHaveBeenCalledTimes(2); - expect(sendDiscord).toHaveBeenNthCalledWith( - 1, - "channel:123456", - "caption", - expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }), - ); - expect(sendDiscord).toHaveBeenNthCalledWith( - 2, - "channel:123456", - "", - expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }), - ); - expect(result).toMatchObject({ channel: "discord", messageId: "dc-2" }); - }); - - it("empty payload returns no-op", async () => { - const ctx = baseCtx({}); - const result = await discordOutbound.sendPayload!(ctx); - - expect(ctx.deps.sendDiscord).not.toHaveBeenCalled(); - expect(result).toEqual({ channel: "discord", messageId: "" }); - }); - - it("text exceeding chunk limit is sent as-is when chunker is null", async () => { - // Discord has chunker: null, so long text should be sent as a single message - const ctx = baseCtx({ text: "a".repeat(3000) }); - const result = await discordOutbound.sendPayload!(ctx); - - expect(ctx.deps.sendDiscord).toHaveBeenCalledTimes(1); - expect(ctx.deps.sendDiscord).toHaveBeenCalledWith( - "channel:123456", - "a".repeat(3000), - expect.any(Object), - ); - expect(result).toMatchObject({ channel: "discord" }); + installSendPayloadContractSuite({ + channel: "discord", + chunking: { mode: "passthrough", longTextLength: 3000 }, + createHarness, }); }); diff --git a/src/channels/plugins/outbound/slack.sendpayload.test.ts b/src/channels/plugins/outbound/slack.sendpayload.test.ts index c6df264df12..374c9881a73 100644 --- a/src/channels/plugins/outbound/slack.sendpayload.test.ts +++ b/src/channels/plugins/outbound/slack.sendpayload.test.ts @@ -1,92 +1,41 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, vi } from "vitest"; import type { ReplyPayload } from "../../../auto-reply/types.js"; +import { + installSendPayloadContractSuite, + primeSendMock, +} from "../../../test-utils/send-payload-contract.js"; import { slackOutbound } from "./slack.js"; -function baseCtx(payload: ReplyPayload) { - return { +function createHarness(params: { + payload: ReplyPayload; + sendResults?: Array<{ messageId: string }>; +}) { + const sendSlack = vi.fn(); + primeSendMock( + sendSlack, + { messageId: "sl-1", channelId: "C12345", ts: "1234.5678" }, + params.sendResults, + ); + const ctx = { cfg: {}, to: "C12345", text: "", - payload, + payload: params.payload, deps: { - sendSlack: vi - .fn() - .mockResolvedValue({ messageId: "sl-1", channelId: "C12345", ts: "1234.5678" }), + sendSlack, }, }; + return { + run: async () => await slackOutbound.sendPayload!(ctx), + sendMock: sendSlack, + to: ctx.to, + }; } describe("slackOutbound sendPayload", () => { - it("text-only delegates to sendText", async () => { - const ctx = baseCtx({ text: "hello" }); - const result = await slackOutbound.sendPayload!(ctx); - - expect(ctx.deps.sendSlack).toHaveBeenCalledTimes(1); - expect(ctx.deps.sendSlack).toHaveBeenCalledWith("C12345", "hello", expect.any(Object)); - expect(result).toMatchObject({ channel: "slack" }); - }); - - it("single media delegates to sendMedia", async () => { - const ctx = baseCtx({ text: "cap", mediaUrl: "https://example.com/a.jpg" }); - const result = await slackOutbound.sendPayload!(ctx); - - expect(ctx.deps.sendSlack).toHaveBeenCalledTimes(1); - expect(ctx.deps.sendSlack).toHaveBeenCalledWith( - "C12345", - "cap", - expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }), - ); - expect(result).toMatchObject({ channel: "slack" }); - }); - - it("multi-media iterates URLs with caption on first", async () => { - const sendSlack = vi - .fn() - .mockResolvedValueOnce({ messageId: "sl-1", channelId: "C12345" }) - .mockResolvedValueOnce({ messageId: "sl-2", channelId: "C12345" }); - const ctx = { - cfg: {}, - to: "C12345", - text: "", - payload: { - text: "caption", - mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"], - } as ReplyPayload, - deps: { sendSlack }, - }; - const result = await slackOutbound.sendPayload!(ctx); - - expect(sendSlack).toHaveBeenCalledTimes(2); - expect(sendSlack).toHaveBeenNthCalledWith( - 1, - "C12345", - "caption", - expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }), - ); - expect(sendSlack).toHaveBeenNthCalledWith( - 2, - "C12345", - "", - expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }), - ); - expect(result).toMatchObject({ channel: "slack", messageId: "sl-2" }); - }); - - it("empty payload returns no-op", async () => { - const ctx = baseCtx({}); - const result = await slackOutbound.sendPayload!(ctx); - - expect(ctx.deps.sendSlack).not.toHaveBeenCalled(); - expect(result).toEqual({ channel: "slack", messageId: "" }); - }); - - it("text exceeding chunk limit is sent as-is when chunker is null", async () => { - // Slack has chunker: null, so long text should be sent as a single message - const ctx = baseCtx({ text: "a".repeat(5000) }); - const result = await slackOutbound.sendPayload!(ctx); - - expect(ctx.deps.sendSlack).toHaveBeenCalledTimes(1); - expect(ctx.deps.sendSlack).toHaveBeenCalledWith("C12345", "a".repeat(5000), expect.any(Object)); - expect(result).toMatchObject({ channel: "slack" }); + installSendPayloadContractSuite({ + channel: "slack", + chunking: { mode: "passthrough", longTextLength: 5000 }, + createHarness, }); }); diff --git a/src/channels/plugins/outbound/telegram.ts b/src/channels/plugins/outbound/telegram.ts index 2afc67d439d..8af1b5831ee 100644 --- a/src/channels/plugins/outbound/telegram.ts +++ b/src/channels/plugins/outbound/telegram.ts @@ -1,3 +1,4 @@ +import type { ReplyPayload } from "../../../auto-reply/types.js"; import type { OutboundSendDeps } from "../../../infra/outbound/deliver.js"; import type { TelegramInlineButtons } from "../../../telegram/button-types.js"; import { markdownToTelegramHtmlChunks } from "../../../telegram/format.js"; @@ -8,16 +9,19 @@ import { import { sendMessageTelegram } from "../../../telegram/send.js"; import type { ChannelOutboundAdapter } from "../types.js"; +type TelegramSendFn = typeof sendMessageTelegram; +type TelegramSendOpts = Parameters[2]; + function resolveTelegramSendContext(params: { - cfg: NonNullable[2]>["cfg"]; + cfg: NonNullable["cfg"]; deps?: OutboundSendDeps; accountId?: string | null; replyToId?: string | null; threadId?: string | number | null; }): { - send: typeof sendMessageTelegram; + send: TelegramSendFn; baseOpts: { - cfg: NonNullable[2]>["cfg"]; + cfg: NonNullable["cfg"]; verbose: false; textMode: "html"; messageThreadId?: number; @@ -39,6 +43,49 @@ function resolveTelegramSendContext(params: { }; } +export async function sendTelegramPayloadMessages(params: { + send: TelegramSendFn; + to: string; + payload: ReplyPayload; + baseOpts: Omit, "buttons" | "mediaUrl" | "quoteText">; +}): Promise>> { + const telegramData = params.payload.channelData?.telegram as + | { buttons?: TelegramInlineButtons; quoteText?: string } + | undefined; + const quoteText = + typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined; + const text = params.payload.text ?? ""; + const mediaUrls = params.payload.mediaUrls?.length + ? params.payload.mediaUrls + : params.payload.mediaUrl + ? [params.payload.mediaUrl] + : []; + const payloadOpts = { + ...params.baseOpts, + quoteText, + }; + + if (mediaUrls.length === 0) { + return await params.send(params.to, text, { + ...payloadOpts, + buttons: telegramData?.buttons, + }); + } + + // Telegram allows reply_markup on media; attach buttons only to the first send. + let finalResult: Awaited> | undefined; + for (let i = 0; i < mediaUrls.length; i += 1) { + const mediaUrl = mediaUrls[i]; + const isFirst = i === 0; + finalResult = await params.send(params.to, isFirst ? text : "", { + ...payloadOpts, + mediaUrl, + ...(isFirst ? { buttons: telegramData?.buttons } : {}), + }); + } + return finalResult ?? { messageId: "unknown", chatId: params.to }; +} + export const telegramOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: markdownToTelegramHtmlChunks, @@ -92,48 +139,22 @@ export const telegramOutbound: ChannelOutboundAdapter = { replyToId, threadId, }) => { - const { send, baseOpts: contextOpts } = resolveTelegramSendContext({ + const { send, baseOpts } = resolveTelegramSendContext({ cfg, deps, accountId, replyToId, threadId, }); - const telegramData = payload.channelData?.telegram as - | { buttons?: TelegramInlineButtons; quoteText?: string } - | undefined; - const quoteText = - typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined; - const text = payload.text ?? ""; - const mediaUrls = payload.mediaUrls?.length - ? payload.mediaUrls - : payload.mediaUrl - ? [payload.mediaUrl] - : []; - const payloadOpts = { - ...contextOpts, - quoteText, - mediaLocalRoots, - }; - if (mediaUrls.length === 0) { - const result = await send(to, text, { - ...payloadOpts, - buttons: telegramData?.buttons, - }); - return { channel: "telegram", ...result }; - } - - // Telegram allows reply_markup on media; attach buttons only to first send. - let finalResult: Awaited> | undefined; - for (let i = 0; i < mediaUrls.length; i += 1) { - const mediaUrl = mediaUrls[i]; - const isFirst = i === 0; - finalResult = await send(to, isFirst ? text : "", { - ...payloadOpts, - mediaUrl, - ...(isFirst ? { buttons: telegramData?.buttons } : {}), - }); - } - return { channel: "telegram", ...(finalResult ?? { messageId: "unknown", chatId: to }) }; + const result = await sendTelegramPayloadMessages({ + send, + to, + payload, + baseOpts: { + ...baseOpts, + mediaLocalRoots, + }, + }); + return { channel: "telegram", ...result }; }, }; diff --git a/src/channels/plugins/outbound/whatsapp.sendpayload.test.ts b/src/channels/plugins/outbound/whatsapp.sendpayload.test.ts index 3eb6f7467dc..e98351cfa61 100644 --- a/src/channels/plugins/outbound/whatsapp.sendpayload.test.ts +++ b/src/channels/plugins/outbound/whatsapp.sendpayload.test.ts @@ -1,106 +1,37 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, vi } from "vitest"; import type { ReplyPayload } from "../../../auto-reply/types.js"; +import { + installSendPayloadContractSuite, + primeSendMock, +} from "../../../test-utils/send-payload-contract.js"; import { whatsappOutbound } from "./whatsapp.js"; -function baseCtx(payload: ReplyPayload) { - return { +function createHarness(params: { + payload: ReplyPayload; + sendResults?: Array<{ messageId: string }>; +}) { + const sendWhatsApp = vi.fn(); + primeSendMock(sendWhatsApp, { messageId: "wa-1" }, params.sendResults); + const ctx = { cfg: {}, to: "5511999999999@c.us", text: "", - payload, + payload: params.payload, deps: { - sendWhatsApp: vi.fn().mockResolvedValue({ messageId: "wa-1" }), + sendWhatsApp, }, }; + return { + run: async () => await whatsappOutbound.sendPayload!(ctx), + sendMock: sendWhatsApp, + to: ctx.to, + }; } describe("whatsappOutbound sendPayload", () => { - it("text-only delegates to sendText", async () => { - const ctx = baseCtx({ text: "hello" }); - const result = await whatsappOutbound.sendPayload!(ctx); - - expect(ctx.deps.sendWhatsApp).toHaveBeenCalledTimes(1); - expect(ctx.deps.sendWhatsApp).toHaveBeenCalledWith( - "5511999999999@c.us", - "hello", - expect.any(Object), - ); - expect(result).toMatchObject({ channel: "whatsapp", messageId: "wa-1" }); - }); - - it("single media delegates to sendMedia", async () => { - const ctx = baseCtx({ text: "cap", mediaUrl: "https://example.com/a.jpg" }); - const result = await whatsappOutbound.sendPayload!(ctx); - - expect(ctx.deps.sendWhatsApp).toHaveBeenCalledTimes(1); - expect(ctx.deps.sendWhatsApp).toHaveBeenCalledWith( - "5511999999999@c.us", - "cap", - expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }), - ); - expect(result).toMatchObject({ channel: "whatsapp" }); - }); - - it("multi-media iterates URLs with caption on first", async () => { - const sendWhatsApp = vi - .fn() - .mockResolvedValueOnce({ messageId: "wa-1" }) - .mockResolvedValueOnce({ messageId: "wa-2" }); - const ctx = { - cfg: {}, - to: "5511999999999@c.us", - text: "", - payload: { - text: "caption", - mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"], - } as ReplyPayload, - deps: { sendWhatsApp }, - }; - const result = await whatsappOutbound.sendPayload!(ctx); - - expect(sendWhatsApp).toHaveBeenCalledTimes(2); - expect(sendWhatsApp).toHaveBeenNthCalledWith( - 1, - "5511999999999@c.us", - "caption", - expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }), - ); - expect(sendWhatsApp).toHaveBeenNthCalledWith( - 2, - "5511999999999@c.us", - "", - expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }), - ); - expect(result).toMatchObject({ channel: "whatsapp", messageId: "wa-2" }); - }); - - it("empty payload returns no-op", async () => { - const ctx = baseCtx({}); - const result = await whatsappOutbound.sendPayload!(ctx); - - expect(ctx.deps.sendWhatsApp).not.toHaveBeenCalled(); - expect(result).toEqual({ channel: "whatsapp", messageId: "" }); - }); - - it("chunking splits long text", async () => { - const sendWhatsApp = vi - .fn() - .mockResolvedValueOnce({ messageId: "wa-c1" }) - .mockResolvedValueOnce({ messageId: "wa-c2" }); - const longText = "a".repeat(5000); - const ctx = { - cfg: {}, - to: "5511999999999@c.us", - text: "", - payload: { text: longText } as ReplyPayload, - deps: { sendWhatsApp }, - }; - const result = await whatsappOutbound.sendPayload!(ctx); - - expect(sendWhatsApp.mock.calls.length).toBeGreaterThanOrEqual(2); - for (const call of sendWhatsApp.mock.calls) { - expect((call[1] as string).length).toBeLessThanOrEqual(4000); - } - expect(result).toMatchObject({ channel: "whatsapp" }); + installSendPayloadContractSuite({ + channel: "whatsapp", + chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 }, + createHarness, }); }); diff --git a/src/channels/plugins/setup-helpers.test.ts b/src/channels/plugins/setup-helpers.test.ts index df4609fc76f..10069c0b9f4 100644 --- a/src/channels/plugins/setup-helpers.test.ts +++ b/src/channels/plugins/setup-helpers.test.ts @@ -30,7 +30,7 @@ describe("applySetupAccountConfigPatch", () => { }); }); - it("patches named account config and enables both channel and account", () => { + it("patches named account config and preserves existing account enabled flag", () => { const next = applySetupAccountConfigPatch({ cfg: asConfig({ channels: { @@ -50,7 +50,7 @@ describe("applySetupAccountConfigPatch", () => { expect(next.channels?.zalo).toMatchObject({ enabled: true, accounts: { - work: { enabled: true, botToken: "new" }, + work: { enabled: false, botToken: "new" }, }, }); }); diff --git a/src/channels/plugins/setup-helpers.ts b/src/channels/plugins/setup-helpers.ts index 5045c431d60..d592a56e475 100644 --- a/src/channels/plugins/setup-helpers.ts +++ b/src/channels/plugins/setup-helpers.ts @@ -125,6 +125,23 @@ export function applySetupAccountConfigPatch(params: { channelKey: string; accountId: string; patch: Record; +}): OpenClawConfig { + return patchScopedAccountConfig({ + cfg: params.cfg, + channelKey: params.channelKey, + accountId: params.accountId, + patch: params.patch, + }); +} + +export function patchScopedAccountConfig(params: { + cfg: OpenClawConfig; + channelKey: string; + accountId: string; + patch: Record; + accountPatch?: Record; + ensureChannelEnabled?: boolean; + ensureAccountEnabled?: boolean; }): OpenClawConfig { const accountId = normalizeAccountId(params.accountId); const channels = params.cfg.channels as Record | undefined; @@ -135,6 +152,10 @@ export function applySetupAccountConfigPatch(params: { accounts?: Record>; }) : undefined; + const ensureChannelEnabled = params.ensureChannelEnabled ?? true; + const ensureAccountEnabled = params.ensureAccountEnabled ?? ensureChannelEnabled; + const patch = params.patch; + const accountPatch = params.accountPatch ?? patch; if (accountId === DEFAULT_ACCOUNT_ID) { return { ...params.cfg, @@ -142,27 +163,33 @@ export function applySetupAccountConfigPatch(params: { ...params.cfg.channels, [params.channelKey]: { ...base, - enabled: true, - ...params.patch, + ...(ensureChannelEnabled ? { enabled: true } : {}), + ...patch, }, }, } as OpenClawConfig; } const accounts = base?.accounts ?? {}; + const existingAccount = accounts[accountId] ?? {}; return { ...params.cfg, channels: { ...params.cfg.channels, [params.channelKey]: { ...base, - enabled: true, + ...(ensureChannelEnabled ? { enabled: true } : {}), accounts: { ...accounts, [accountId]: { - ...accounts[accountId], - enabled: true, - ...params.patch, + ...existingAccount, + ...(ensureAccountEnabled + ? { + enabled: + typeof existingAccount.enabled === "boolean" ? existingAccount.enabled : true, + } + : {}), + ...accountPatch, }, }, }, diff --git a/src/cli/acp-cli.option-collisions.test.ts b/src/cli/acp-cli.option-collisions.test.ts index 131db6a67cb..068f415de79 100644 --- a/src/cli/acp-cli.option-collisions.test.ts +++ b/src/cli/acp-cli.option-collisions.test.ts @@ -1,9 +1,7 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import { Command } from "commander"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { runRegisteredCli } from "../test-utils/command-runner.js"; +import { withTempSecretFiles } from "../test-utils/secret-file-fixture.js"; const runAcpClientInteractive = vi.fn(async (_opts: unknown) => {}); const serveAcpGateway = vi.fn(async (_opts: unknown) => {}); @@ -30,27 +28,6 @@ vi.mock("../runtime.js", () => ({ describe("acp cli option collisions", () => { let registerAcpCli: typeof import("./acp-cli.js").registerAcpCli; - async function withSecretFiles( - secrets: { token?: string; password?: string }, - run: (files: { tokenFile?: string; passwordFile?: string }) => Promise, - ): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acp-cli-")); - try { - const files: { tokenFile?: string; passwordFile?: string } = {}; - if (secrets.token !== undefined) { - files.tokenFile = path.join(dir, "token.txt"); - await fs.writeFile(files.tokenFile, secrets.token, "utf8"); - } - if (secrets.password !== undefined) { - files.passwordFile = path.join(dir, "password.txt"); - await fs.writeFile(files.passwordFile, secrets.password, "utf8"); - } - return await run(files); - } finally { - await fs.rm(dir, { recursive: true, force: true }); - } - } - function createAcpProgram() { const program = new Command(); registerAcpCli(program); @@ -93,15 +70,19 @@ describe("acp cli option collisions", () => { }); it("loads gateway token/password from files", async () => { - await withSecretFiles({ token: "tok_file\n", [passwordKey()]: "pw_file\n" }, async (files) => { - // pragma: allowlist secret - await parseAcp([ - "--token-file", - files.tokenFile ?? "", - "--password-file", - files.passwordFile ?? "", - ]); - }); + await withTempSecretFiles( + "openclaw-acp-cli-", + { token: "tok_file\n", [passwordKey()]: "pw_file\n" }, + async (files) => { + // pragma: allowlist secret + await parseAcp([ + "--token-file", + files.tokenFile ?? "", + "--password-file", + files.passwordFile ?? "", + ]); + }, + ); expect(serveAcpGateway).toHaveBeenCalledWith( expect.objectContaining({ @@ -111,21 +92,30 @@ describe("acp cli option collisions", () => { ); }); - it("rejects mixed secret flags and file flags", async () => { - await withSecretFiles({ token: "tok_file\n" }, async (files) => { - await parseAcp(["--token", "tok_inline", "--token-file", files.tokenFile ?? ""]); + it.each([ + { + name: "rejects mixed secret flags and file flags", + files: { token: "tok_file\n" }, + args: (tokenFile: string) => ["--token", "tok_inline", "--token-file", tokenFile], + expected: /Use either --token or --token-file/, + }, + { + name: "rejects mixed password flags and file flags", + files: { password: "pw_file\n" }, // pragma: allowlist secret + args: (_tokenFile: string, passwordFile: string) => [ + "--password", + "pw_inline", + "--password-file", + passwordFile, + ], + expected: /Use either --password or --password-file/, + }, + ])("$name", async ({ files, args, expected }) => { + await withTempSecretFiles("openclaw-acp-cli-", files, async ({ tokenFile, passwordFile }) => { + await parseAcp(args(tokenFile ?? "", passwordFile ?? "")); }); - expectCliError(/Use either --token or --token-file/); - }); - - it("rejects mixed password flags and file flags", async () => { - const passwordFileValue = "pw_file\n"; // pragma: allowlist secret - await withSecretFiles({ password: passwordFileValue }, async (files) => { - await parseAcp(["--password", "pw_inline", "--password-file", files.passwordFile ?? ""]); - }); - - expectCliError(/Use either --password or --password-file/); + expectCliError(expected); }); it("warns when inline secret flags are used", async () => { @@ -140,7 +130,7 @@ describe("acp cli option collisions", () => { }); it("trims token file path before reading", async () => { - await withSecretFiles({ token: "tok_file\n" }, async (files) => { + await withTempSecretFiles("openclaw-acp-cli-", { token: "tok_file\n" }, async (files) => { await parseAcp(["--token-file", ` ${files.tokenFile ?? ""} `]); }); diff --git a/src/cli/daemon-cli/register-service-commands.test.ts b/src/cli/daemon-cli/register-service-commands.test.ts index cec45d62769..e249b00c835 100644 --- a/src/cli/daemon-cli/register-service-commands.test.ts +++ b/src/cli/daemon-cli/register-service-commands.test.ts @@ -39,34 +39,37 @@ describe("addGatewayServiceCommands", () => { runDaemonUninstall.mockClear(); }); - it("forwards install option collisions from parent gateway command", async () => { + it.each([ + { + name: "forwards install option collisions from parent gateway command", + argv: ["install", "--force", "--port", "19000", "--token", "tok_test"], + assert: () => { + expect(runDaemonInstall).toHaveBeenCalledWith( + expect.objectContaining({ + force: true, + port: "19000", + token: "tok_test", + }), + ); + }, + }, + { + name: "forwards status auth collisions from parent gateway command", + argv: ["status", "--token", "tok_status", "--password", "pw_status"], + assert: () => { + expect(runDaemonStatus).toHaveBeenCalledWith( + expect.objectContaining({ + rpc: expect.objectContaining({ + token: "tok_status", + password: "pw_status", // pragma: allowlist secret + }), + }), + ); + }, + }, + ])("$name", async ({ argv, assert }) => { const gateway = createGatewayParentLikeCommand(); - await gateway.parseAsync(["install", "--force", "--port", "19000", "--token", "tok_test"], { - from: "user", - }); - - expect(runDaemonInstall).toHaveBeenCalledWith( - expect.objectContaining({ - force: true, - port: "19000", - token: "tok_test", - }), - ); - }); - - it("forwards status auth collisions from parent gateway command", async () => { - const gateway = createGatewayParentLikeCommand(); - await gateway.parseAsync(["status", "--token", "tok_status", "--password", "pw_status"], { - from: "user", - }); - - expect(runDaemonStatus).toHaveBeenCalledWith( - expect.objectContaining({ - rpc: expect.objectContaining({ - token: "tok_status", - password: "pw_status", // pragma: allowlist secret - }), - }), - ); + await gateway.parseAsync(argv, { from: "user" }); + assert(); }); }); diff --git a/src/cli/gateway-cli/register.option-collisions.test.ts b/src/cli/gateway-cli/register.option-collisions.test.ts index 1ef5ba2c238..665886c76eb 100644 --- a/src/cli/gateway-cli/register.option-collisions.test.ts +++ b/src/cli/gateway-cli/register.option-collisions.test.ts @@ -128,30 +128,34 @@ describe("gateway register option collisions", () => { gatewayStatusCommand.mockClear(); }); - it("forwards --token to gateway call when parent and child option names collide", async () => { - await sharedProgram.parseAsync(["gateway", "call", "health", "--token", "tok_call", "--json"], { - from: "user", - }); - - expect(callGatewayCli).toHaveBeenCalledWith( - "health", - expect.objectContaining({ - token: "tok_call", - }), - {}, - ); - }); - - it("forwards --token to gateway probe when parent and child option names collide", async () => { - await sharedProgram.parseAsync(["gateway", "probe", "--token", "tok_probe", "--json"], { - from: "user", - }); - - expect(gatewayStatusCommand).toHaveBeenCalledWith( - expect.objectContaining({ - token: "tok_probe", - }), - defaultRuntime, - ); + it.each([ + { + name: "forwards --token to gateway call when parent and child option names collide", + argv: ["gateway", "call", "health", "--token", "tok_call", "--json"], + assert: () => { + expect(callGatewayCli).toHaveBeenCalledWith( + "health", + expect.objectContaining({ + token: "tok_call", + }), + {}, + ); + }, + }, + { + name: "forwards --token to gateway probe when parent and child option names collide", + argv: ["gateway", "probe", "--token", "tok_probe", "--json"], + assert: () => { + expect(gatewayStatusCommand).toHaveBeenCalledWith( + expect.objectContaining({ + token: "tok_probe", + }), + defaultRuntime, + ); + }, + }, + ])("$name", async ({ argv, assert }) => { + await sharedProgram.parseAsync(argv, { from: "user" }); + assert(); }); }); diff --git a/src/cli/gateway-cli/run.option-collisions.test.ts b/src/cli/gateway-cli/run.option-collisions.test.ts index 3a1f8bf57c7..a896a7a3f76 100644 --- a/src/cli/gateway-cli/run.option-collisions.test.ts +++ b/src/cli/gateway-cli/run.option-collisions.test.ts @@ -1,8 +1,6 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import { Command } from "commander"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { withTempSecretFiles } from "../../test-utils/secret-file-fixture.js"; import { createCliRuntimeCapture } from "../test-runtime-capture.js"; const startGatewayServer = vi.fn(async (_port: number, _opts?: unknown) => ({ @@ -195,16 +193,10 @@ describe("gateway run option collisions", () => { ); }); - it("accepts --auth none override", async () => { - await runGatewayCli(["gateway", "run", "--auth", "none", "--allow-unconfigured"]); + it.each(["none", "trusted-proxy"] as const)("accepts --auth %s override", async (mode) => { + await runGatewayCli(["gateway", "run", "--auth", mode, "--allow-unconfigured"]); - expectAuthOverrideMode("none"); - }); - - it("accepts --auth trusted-proxy override", async () => { - await runGatewayCli(["gateway", "run", "--auth", "trusted-proxy", "--allow-unconfigured"]); - - expectAuthOverrideMode("trusted-proxy"); + expectAuthOverrideMode(mode); }); it("prints all supported modes on invalid --auth value", async () => { @@ -244,36 +236,34 @@ describe("gateway run option collisions", () => { }); it("reads gateway password from --password-file", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-run-")); - try { - const passwordFile = path.join(tempDir, "gateway-password.txt"); - await fs.writeFile(passwordFile, "pw_from_file\n", "utf8"); + await withTempSecretFiles( + "openclaw-gateway-run-", + { password: "pw_from_file\n" }, + async ({ passwordFile }) => { + await runGatewayCli([ + "gateway", + "run", + "--auth", + "password", + "--password-file", + passwordFile ?? "", + "--allow-unconfigured", + ]); + }, + ); - await runGatewayCli([ - "gateway", - "run", - "--auth", - "password", - "--password-file", - passwordFile, - "--allow-unconfigured", - ]); - - expect(startGatewayServer).toHaveBeenCalledWith( - 18789, - expect.objectContaining({ - auth: expect.objectContaining({ - mode: "password", - password: "pw_from_file", // pragma: allowlist secret - }), + expect(startGatewayServer).toHaveBeenCalledWith( + 18789, + expect.objectContaining({ + auth: expect.objectContaining({ + mode: "password", + password: "pw_from_file", // pragma: allowlist secret }), - ); - expect(runtimeErrors).not.toContain( - "Warning: --password can be exposed via process listings. Prefer --password-file or OPENCLAW_GATEWAY_PASSWORD.", - ); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } + }), + ); + expect(runtimeErrors).not.toContain( + "Warning: --password can be exposed via process listings. Prefer --password-file or OPENCLAW_GATEWAY_PASSWORD.", + ); }); it("warns when gateway password is passed inline", async () => { @@ -293,26 +283,24 @@ describe("gateway run option collisions", () => { }); it("rejects using both --password and --password-file", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-run-")); - try { - const passwordFile = path.join(tempDir, "gateway-password.txt"); - await fs.writeFile(passwordFile, "pw_from_file\n", "utf8"); + await withTempSecretFiles( + "openclaw-gateway-run-", + { password: "pw_from_file\n" }, + async ({ passwordFile }) => { + await expect( + runGatewayCli([ + "gateway", + "run", + "--password", + "pw_inline", + "--password-file", + passwordFile ?? "", + "--allow-unconfigured", + ]), + ).rejects.toThrow("__exit__:1"); + }, + ); - await expect( - runGatewayCli([ - "gateway", - "run", - "--password", - "pw_inline", - "--password-file", - passwordFile, - "--allow-unconfigured", - ]), - ).rejects.toThrow("__exit__:1"); - - expect(runtimeErrors).toContain("Use either --password or --password-file."); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } + expect(runtimeErrors).toContain("Use either --password or --password-file."); }); }); diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index 03fb832a041..6a5bd98aea0 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -160,6 +160,8 @@ export function registerOnboardCommand(program: Command) { zaiApiKey: opts.zaiApiKey as string | undefined, xiaomiApiKey: opts.xiaomiApiKey as string | undefined, qianfanApiKey: opts.qianfanApiKey as string | undefined, + modelstudioApiKeyCn: opts.modelstudioApiKeyCn as string | undefined, + modelstudioApiKey: opts.modelstudioApiKey as string | undefined, minimaxApiKey: opts.minimaxApiKey as string | undefined, syntheticApiKey: opts.syntheticApiKey as string | undefined, veniceApiKey: opts.veniceApiKey as string | undefined, diff --git a/src/cli/update-cli.option-collisions.test.ts b/src/cli/update-cli.option-collisions.test.ts index c0dd2d88404..6db4cfdd260 100644 --- a/src/cli/update-cli.option-collisions.test.ts +++ b/src/cli/update-cli.option-collisions.test.ts @@ -44,30 +44,36 @@ describe("update cli option collisions", () => { defaultRuntime.exit.mockClear(); }); - it("forwards parent-captured --json/--timeout to `update status`", async () => { - await runRegisteredCli({ - register: registerUpdateCli as (program: Command) => void, + it.each([ + { + name: "forwards parent-captured --json/--timeout to `update status`", argv: ["update", "status", "--json", "--timeout", "9"], - }); - - expect(updateStatusCommand).toHaveBeenCalledWith( - expect.objectContaining({ - json: true, - timeout: "9", - }), - ); - }); - - it("forwards parent-captured --timeout to `update wizard`", async () => { + assert: () => { + expect(updateStatusCommand).toHaveBeenCalledWith( + expect.objectContaining({ + json: true, + timeout: "9", + }), + ); + }, + }, + { + name: "forwards parent-captured --timeout to `update wizard`", + argv: ["update", "wizard", "--timeout", "13"], + assert: () => { + expect(updateWizardCommand).toHaveBeenCalledWith( + expect.objectContaining({ + timeout: "13", + }), + ); + }, + }, + ])("$name", async ({ argv, assert }) => { await runRegisteredCli({ register: registerUpdateCli as (program: Command) => void, - argv: ["update", "wizard", "--timeout", "13"], + argv, }); - expect(updateWizardCommand).toHaveBeenCalledWith( - expect.objectContaining({ - timeout: "13", - }), - ); + assert(); }); }); diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 27fee5dc01f..23e9b80d958 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -119,6 +119,12 @@ const AUTH_CHOICE_GROUP_DEFS: { hint: "API key", choices: ["qianfan-api-key"], }, + { + value: "modelstudio", + label: "Alibaba Cloud Model Studio", + hint: "Coding Plan API key (CN / Global)", + choices: ["modelstudio-api-key-cn", "modelstudio-api-key"], + }, { value: "copilot", label: "Copilot", @@ -297,6 +303,17 @@ const BASE_AUTH_CHOICE_OPTIONS: ReadonlyArray = [ label: "MiniMax M2.5 Highspeed", hint: "Official fast tier", }, + { value: "qianfan-api-key", label: "Qianfan API key" }, + { + value: "modelstudio-api-key-cn", + label: "Coding Plan API Key for China (subscription)", + hint: "Endpoint: coding.dashscope.aliyuncs.com", + }, + { + value: "modelstudio-api-key", + label: "Coding Plan API Key for Global/Intl (subscription)", + hint: "Endpoint: coding-intl.dashscope.aliyuncs.com", + }, { value: "custom-api-key", label: "Custom Provider" }, ]; diff --git a/src/commands/auth-choice.apply-helpers.ts b/src/commands/auth-choice.apply-helpers.ts index 122be392153..32c6ac82786 100644 --- a/src/commands/auth-choice.apply-helpers.ts +++ b/src/commands/auth-choice.apply-helpers.ts @@ -8,6 +8,8 @@ import { import { encodeJsonPointerToken } from "../secrets/json-pointer.js"; import { PROVIDER_ENV_VARS } from "../secrets/provider-env-vars.js"; import { + formatExecSecretRefIdValidationMessage, + isValidExecSecretRefId, isValidFileSecretRefId, resolveDefaultSecretProviderAlias, } from "../secrets/ref-contract.js"; @@ -238,6 +240,9 @@ export async function promptSecretRefForOnboarding(params: { ) { return 'singleValue mode expects id "value".'; } + if (providerEntry.source === "exec" && !isValidExecSecretRefId(candidate)) { + return formatExecSecretRefIdValidationMessage(); + } return undefined; }, }); diff --git a/src/commands/auth-choice.apply.api-providers.ts b/src/commands/auth-choice.apply.api-providers.ts index 370951e9f0d..046a2e24893 100644 --- a/src/commands/auth-choice.apply.api-providers.ts +++ b/src/commands/auth-choice.apply.api-providers.ts @@ -76,6 +76,12 @@ import { setXiaomiApiKey, setZaiApiKey, ZAI_DEFAULT_MODEL_REF, + MODELSTUDIO_DEFAULT_MODEL_REF, + applyModelStudioConfig, + applyModelStudioConfigCn, + applyModelStudioProviderConfig, + applyModelStudioProviderConfigCn, + setModelStudioApiKey, } from "./onboard-auth.js"; import type { AuthChoice, SecretInputMode } from "./onboard-types.js"; import { OPENCODE_ZEN_DEFAULT_MODEL } from "./opencode-zen-model-default.js"; @@ -295,6 +301,46 @@ const SIMPLE_API_KEY_PROVIDER_FLOWS: Partial String(value ?? "").trim(), + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }, + "modelstudio-api-key": { + provider: "modelstudio", + profileId: "modelstudio:default", + expectedProviders: ["modelstudio"], + envLabel: "MODELSTUDIO_API_KEY", + promptMessage: "Enter Alibaba Cloud Model Studio Coding Plan API key (Global/Intl)", + setCredential: setModelStudioApiKey, + defaultModel: MODELSTUDIO_DEFAULT_MODEL_REF, + applyDefaultConfig: applyModelStudioConfig, + applyProviderConfig: applyModelStudioProviderConfig, + noteDefault: MODELSTUDIO_DEFAULT_MODEL_REF, + noteMessage: [ + "Get your API key at: https://bailian.console.aliyun.com/", + "Endpoint: coding-intl.dashscope.aliyuncs.com", + "Models: qwen3.5-plus, glm-4.7, kimi-k2.5, MiniMax-M2.5, etc.", + ].join("\n"), + noteTitle: "Alibaba Cloud Model Studio Coding Plan (Global/Intl)", + normalize: (value) => String(value ?? "").trim(), + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }, "synthetic-api-key": { provider: "synthetic", profileId: "synthetic:default", diff --git a/src/commands/auth-choice.model-check.ts b/src/commands/auth-choice.model-check.ts index ea7da2f9d6d..975fc3521d3 100644 --- a/src/commands/auth-choice.model-check.ts +++ b/src/commands/auth-choice.model-check.ts @@ -1,5 +1,5 @@ import { ensureAuthProfileStore, listProfilesForProvider } from "../agents/auth-profiles.js"; -import { getCustomProviderApiKey, resolveEnvApiKey } from "../agents/model-auth.js"; +import { hasUsableCustomProviderApiKey, resolveEnvApiKey } from "../agents/model-auth.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; import { resolveDefaultModelForAgent } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/config.js"; @@ -34,8 +34,8 @@ export async function warnIfModelConfigLooksOff( const store = ensureAuthProfileStore(options?.agentDir); const hasProfile = listProfilesForProvider(store, ref.provider).length > 0; const envKey = resolveEnvApiKey(ref.provider); - const customKey = getCustomProviderApiKey(config, ref.provider); - if (!hasProfile && !envKey && !customKey) { + const hasCustomKey = hasUsableCustomProviderApiKey(config, ref.provider); + if (!hasProfile && !envKey && !hasCustomKey) { warnings.push( `No auth configured for provider "${ref.provider}". The agent may fail until credentials are added.`, ); diff --git a/src/commands/backup.ts b/src/commands/backup.ts index 15f0f505d76..ab4397db0f3 100644 --- a/src/commands/backup.ts +++ b/src/commands/backup.ts @@ -1,382 +1,31 @@ -import { randomUUID } from "node:crypto"; -import { constants as fsConstants } from "node:fs"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import * as tar from "tar"; -import type { RuntimeEnv } from "../runtime.js"; -import { resolveHomeDir, resolveUserPath } from "../utils.js"; -import { resolveRuntimeServiceVersion } from "../version.js"; import { - buildBackupArchiveBasename, - buildBackupArchiveRoot, - buildBackupArchivePath, - type BackupAsset, - resolveBackupPlanFromDisk, -} from "./backup-shared.js"; + createBackupArchive, + formatBackupCreateSummary, + type BackupCreateOptions, + type BackupCreateResult, +} from "../infra/backup-create.js"; +import type { RuntimeEnv } from "../runtime.js"; import { backupVerifyCommand } from "./backup-verify.js"; -import { isPathWithin } from "./cleanup-utils.js"; - -export type BackupCreateOptions = { - output?: string; - dryRun?: boolean; - includeWorkspace?: boolean; - onlyConfig?: boolean; - verify?: boolean; - json?: boolean; - nowMs?: number; -}; - -type BackupManifestAsset = { - kind: BackupAsset["kind"]; - sourcePath: string; - archivePath: string; -}; - -type BackupManifest = { - schemaVersion: 1; - createdAt: string; - archiveRoot: string; - runtimeVersion: string; - platform: NodeJS.Platform; - nodeVersion: string; - options: { - includeWorkspace: boolean; - onlyConfig?: boolean; - }; - paths: { - stateDir: string; - configPath: string; - oauthDir: string; - workspaceDirs: string[]; - }; - assets: BackupManifestAsset[]; - skipped: Array<{ - kind: string; - sourcePath: string; - reason: string; - coveredBy?: string; - }>; -}; - -export type BackupCreateResult = { - createdAt: string; - archiveRoot: string; - archivePath: string; - dryRun: boolean; - includeWorkspace: boolean; - onlyConfig: boolean; - verified: boolean; - assets: BackupAsset[]; - skipped: Array<{ - kind: string; - sourcePath: string; - displayPath: string; - reason: string; - coveredBy?: string; - }>; -}; - -async function resolveOutputPath(params: { - output?: string; - nowMs: number; - includedAssets: BackupAsset[]; - stateDir: string; -}): Promise { - const basename = buildBackupArchiveBasename(params.nowMs); - const rawOutput = params.output?.trim(); - if (!rawOutput) { - const cwd = path.resolve(process.cwd()); - const canonicalCwd = await fs.realpath(cwd).catch(() => cwd); - const cwdInsideSource = params.includedAssets.some((asset) => - isPathWithin(canonicalCwd, asset.sourcePath), - ); - const defaultDir = cwdInsideSource ? (resolveHomeDir() ?? path.dirname(params.stateDir)) : cwd; - return path.resolve(defaultDir, basename); - } - - const resolved = resolveUserPath(rawOutput); - if (rawOutput.endsWith("/") || rawOutput.endsWith("\\")) { - return path.join(resolved, basename); - } - - try { - const stat = await fs.stat(resolved); - if (stat.isDirectory()) { - return path.join(resolved, basename); - } - } catch { - // Treat as a file path when the target does not exist yet. - } - - return resolved; -} - -async function assertOutputPathReady(outputPath: string): Promise { - try { - await fs.access(outputPath); - throw new Error(`Refusing to overwrite existing backup archive: ${outputPath}`); - } catch (err) { - const code = (err as NodeJS.ErrnoException | undefined)?.code; - if (code === "ENOENT") { - return; - } - throw err; - } -} - -function buildTempArchivePath(outputPath: string): string { - return `${outputPath}.${randomUUID()}.tmp`; -} - -function isLinkUnsupportedError(code: string | undefined): boolean { - return code === "ENOTSUP" || code === "EOPNOTSUPP" || code === "EPERM"; -} - -async function publishTempArchive(params: { - tempArchivePath: string; - outputPath: string; -}): Promise { - try { - await fs.link(params.tempArchivePath, params.outputPath); - } catch (err) { - const code = (err as NodeJS.ErrnoException | undefined)?.code; - if (code === "EEXIST") { - throw new Error(`Refusing to overwrite existing backup archive: ${params.outputPath}`, { - cause: err, - }); - } - if (!isLinkUnsupportedError(code)) { - throw err; - } - - try { - // Some backup targets support ordinary files but not hard links. - await fs.copyFile(params.tempArchivePath, params.outputPath, fsConstants.COPYFILE_EXCL); - } catch (copyErr) { - const copyCode = (copyErr as NodeJS.ErrnoException | undefined)?.code; - if (copyCode !== "EEXIST") { - await fs.rm(params.outputPath, { force: true }).catch(() => undefined); - } - if (copyCode === "EEXIST") { - throw new Error(`Refusing to overwrite existing backup archive: ${params.outputPath}`, { - cause: copyErr, - }); - } - throw copyErr; - } - } - await fs.rm(params.tempArchivePath, { force: true }); -} - -async function canonicalizePathForContainment(targetPath: string): Promise { - const resolved = path.resolve(targetPath); - const suffix: string[] = []; - let probe = resolved; - - while (true) { - try { - const realProbe = await fs.realpath(probe); - return suffix.length === 0 ? realProbe : path.join(realProbe, ...suffix.toReversed()); - } catch { - const parent = path.dirname(probe); - if (parent === probe) { - return resolved; - } - suffix.push(path.basename(probe)); - probe = parent; - } - } -} - -function buildManifest(params: { - createdAt: string; - archiveRoot: string; - includeWorkspace: boolean; - onlyConfig: boolean; - assets: BackupAsset[]; - skipped: BackupCreateResult["skipped"]; - stateDir: string; - configPath: string; - oauthDir: string; - workspaceDirs: string[]; -}): BackupManifest { - return { - schemaVersion: 1, - createdAt: params.createdAt, - archiveRoot: params.archiveRoot, - runtimeVersion: resolveRuntimeServiceVersion(), - platform: process.platform, - nodeVersion: process.version, - options: { - includeWorkspace: params.includeWorkspace, - onlyConfig: params.onlyConfig, - }, - paths: { - stateDir: params.stateDir, - configPath: params.configPath, - oauthDir: params.oauthDir, - workspaceDirs: params.workspaceDirs, - }, - assets: params.assets.map((asset) => ({ - kind: asset.kind, - sourcePath: asset.sourcePath, - archivePath: asset.archivePath, - })), - skipped: params.skipped.map((entry) => ({ - kind: entry.kind, - sourcePath: entry.sourcePath, - reason: entry.reason, - coveredBy: entry.coveredBy, - })), - }; -} - -function formatTextSummary(result: BackupCreateResult): string[] { - const lines = [`Backup archive: ${result.archivePath}`]; - lines.push(`Included ${result.assets.length} path${result.assets.length === 1 ? "" : "s"}:`); - for (const asset of result.assets) { - lines.push(`- ${asset.kind}: ${asset.displayPath}`); - } - if (result.skipped.length > 0) { - lines.push(`Skipped ${result.skipped.length} path${result.skipped.length === 1 ? "" : "s"}:`); - for (const entry of result.skipped) { - if (entry.reason === "covered" && entry.coveredBy) { - lines.push(`- ${entry.kind}: ${entry.displayPath} (${entry.reason} by ${entry.coveredBy})`); - } else { - lines.push(`- ${entry.kind}: ${entry.displayPath} (${entry.reason})`); - } - } - } - if (result.dryRun) { - lines.push("Dry run only; archive was not written."); - } else { - lines.push(`Created ${result.archivePath}`); - if (result.verified) { - lines.push("Archive verification: passed"); - } - } - return lines; -} - -function remapArchiveEntryPath(params: { - entryPath: string; - manifestPath: string; - archiveRoot: string; -}): string { - const normalizedEntry = path.resolve(params.entryPath); - if (normalizedEntry === params.manifestPath) { - return path.posix.join(params.archiveRoot, "manifest.json"); - } - return buildBackupArchivePath(params.archiveRoot, normalizedEntry); -} +export type { BackupCreateOptions, BackupCreateResult } from "../infra/backup-create.js"; export async function backupCreateCommand( runtime: RuntimeEnv, opts: BackupCreateOptions = {}, ): Promise { - const nowMs = opts.nowMs ?? Date.now(); - const archiveRoot = buildBackupArchiveRoot(nowMs); - const onlyConfig = Boolean(opts.onlyConfig); - const includeWorkspace = onlyConfig ? false : (opts.includeWorkspace ?? true); - const plan = await resolveBackupPlanFromDisk({ includeWorkspace, onlyConfig, nowMs }); - const outputPath = await resolveOutputPath({ - output: opts.output, - nowMs, - includedAssets: plan.included, - stateDir: plan.stateDir, - }); - - if (plan.included.length === 0) { - throw new Error( - onlyConfig - ? "No OpenClaw config file was found to back up." - : "No local OpenClaw state was found to back up.", + const result = await createBackupArchive(opts); + if (opts.verify && !opts.dryRun) { + await backupVerifyCommand( + { + ...runtime, + log: () => {}, + }, + { archive: result.archivePath, json: false }, ); + result.verified = true; } - - const canonicalOutputPath = await canonicalizePathForContainment(outputPath); - const overlappingAsset = plan.included.find((asset) => - isPathWithin(canonicalOutputPath, asset.sourcePath), - ); - if (overlappingAsset) { - throw new Error( - `Backup output must not be written inside a source path: ${outputPath} is inside ${overlappingAsset.sourcePath}`, - ); - } - - if (!opts.dryRun) { - await assertOutputPathReady(outputPath); - } - - const createdAt = new Date(nowMs).toISOString(); - const result: BackupCreateResult = { - createdAt, - archiveRoot, - archivePath: outputPath, - dryRun: Boolean(opts.dryRun), - includeWorkspace, - onlyConfig, - verified: false, - assets: plan.included, - skipped: plan.skipped, - }; - - if (!opts.dryRun) { - await fs.mkdir(path.dirname(outputPath), { recursive: true }); - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-")); - const manifestPath = path.join(tempDir, "manifest.json"); - const tempArchivePath = buildTempArchivePath(outputPath); - try { - const manifest = buildManifest({ - createdAt, - archiveRoot, - includeWorkspace, - onlyConfig, - assets: result.assets, - skipped: result.skipped, - stateDir: plan.stateDir, - configPath: plan.configPath, - oauthDir: plan.oauthDir, - workspaceDirs: plan.workspaceDirs, - }); - await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); - - await tar.c( - { - file: tempArchivePath, - gzip: true, - portable: true, - preservePaths: true, - onWriteEntry: (entry) => { - entry.path = remapArchiveEntryPath({ - entryPath: entry.path, - manifestPath, - archiveRoot, - }); - }, - }, - [manifestPath, ...result.assets.map((asset) => asset.sourcePath)], - ); - await publishTempArchive({ tempArchivePath, outputPath }); - } finally { - await fs.rm(tempArchivePath, { force: true }).catch(() => undefined); - await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); - } - - if (opts.verify) { - await backupVerifyCommand( - { - ...runtime, - log: () => {}, - }, - { archive: outputPath, json: false }, - ); - result.verified = true; - } - } - - const output = opts.json ? JSON.stringify(result, null, 2) : formatTextSummary(result).join("\n"); + const output = opts.json + ? JSON.stringify(result, null, 2) + : formatBackupCreateSummary(result).join("\n"); runtime.log(output); return result; } diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index 5cf0fd57547..a98dd78e510 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -30,10 +30,10 @@ vi.mock("../agents/auth-profiles.js", () => ({ })); const resolveEnvApiKey = vi.hoisted(() => vi.fn(() => undefined)); -const getCustomProviderApiKey = vi.hoisted(() => vi.fn(() => undefined)); +const hasUsableCustomProviderApiKey = vi.hoisted(() => vi.fn(() => false)); vi.mock("../agents/model-auth.js", () => ({ resolveEnvApiKey, - getCustomProviderApiKey, + hasUsableCustomProviderApiKey, })); const OPENROUTER_CATALOG = [ diff --git a/src/commands/model-picker.ts b/src/commands/model-picker.ts index db794210354..1fe4170b7c2 100644 --- a/src/commands/model-picker.ts +++ b/src/commands/model-picker.ts @@ -1,6 +1,6 @@ import { ensureAuthProfileStore, listProfilesForProvider } from "../agents/auth-profiles.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; -import { getCustomProviderApiKey, resolveEnvApiKey } from "../agents/model-auth.js"; +import { hasUsableCustomProviderApiKey, resolveEnvApiKey } from "../agents/model-auth.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; import { buildAllowedModelSet, @@ -52,7 +52,7 @@ function hasAuthForProvider( if (resolveEnvApiKey(provider)) { return true; } - if (getCustomProviderApiKey(cfg, provider)) { + if (hasUsableCustomProviderApiKey(cfg, provider)) { return true; } return false; diff --git a/src/commands/models.list.e2e.test.ts b/src/commands/models.list.e2e.test.ts index e7d55e00b3c..fc80137b0f0 100644 --- a/src/commands/models.list.e2e.test.ts +++ b/src/commands/models.list.e2e.test.ts @@ -21,6 +21,8 @@ const resolveAuthStorePathForDisplay = vi const resolveProfileUnusableUntilForDisplay = vi.fn().mockReturnValue(null); const resolveEnvApiKey = vi.fn().mockReturnValue(undefined); const resolveAwsSdkEnvVarName = vi.fn().mockReturnValue(undefined); +const hasUsableCustomProviderApiKey = vi.fn().mockReturnValue(false); +const resolveUsableCustomProviderApiKey = vi.fn().mockReturnValue(null); const getCustomProviderApiKey = vi.fn().mockReturnValue(undefined); const modelRegistryState = { models: [] as Array>, @@ -57,6 +59,8 @@ vi.mock("../agents/auth-profiles.js", () => ({ vi.mock("../agents/model-auth.js", () => ({ resolveEnvApiKey, resolveAwsSdkEnvVarName, + hasUsableCustomProviderApiKey, + resolveUsableCustomProviderApiKey, getCustomProviderApiKey, })); diff --git a/src/commands/models/list.auth-overview.test.ts b/src/commands/models/list.auth-overview.test.ts index 98906ced281..69807a5d7a7 100644 --- a/src/commands/models/list.auth-overview.test.ts +++ b/src/commands/models/list.auth-overview.test.ts @@ -42,8 +42,8 @@ describe("resolveProviderAuthOverview", () => { modelsPath: "/tmp/models.json", }); - expect(overview.effective.kind).toBe("models.json"); - expect(overview.effective.detail).toContain(`marker(${NON_ENV_SECRETREF_MARKER})`); + expect(overview.effective.kind).toBe("missing"); + expect(overview.effective.detail).toBe("missing"); expect(overview.modelsJson?.value).toContain(`marker(${NON_ENV_SECRETREF_MARKER})`); }); @@ -66,8 +66,41 @@ describe("resolveProviderAuthOverview", () => { modelsPath: "/tmp/models.json", }); - expect(overview.effective.kind).toBe("models.json"); - expect(overview.effective.detail).not.toContain("marker("); - expect(overview.effective.detail).not.toContain("OPENAI_API_KEY"); + expect(overview.effective.kind).toBe("missing"); + expect(overview.effective.detail).toBe("missing"); + expect(overview.modelsJson?.value).not.toContain("marker("); + expect(overview.modelsJson?.value).not.toContain("OPENAI_API_KEY"); + }); + + it("treats env-var marker as usable only when the env key is currently resolvable", () => { + const prior = process.env.OPENAI_API_KEY; + process.env.OPENAI_API_KEY = "sk-openai-from-env"; // pragma: allowlist secret + try { + const overview = resolveProviderAuthOverview({ + provider: "openai", + cfg: { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: "OPENAI_API_KEY", // pragma: allowlist secret + models: [], + }, + }, + }, + } as never, + store: { version: 1, profiles: {} } as never, + modelsPath: "/tmp/models.json", + }); + expect(overview.effective.kind).toBe("env"); + expect(overview.effective.detail).not.toContain("OPENAI_API_KEY"); + } finally { + if (prior === undefined) { + delete process.env.OPENAI_API_KEY; + } else { + process.env.OPENAI_API_KEY = prior; + } + } }); }); diff --git a/src/commands/models/list.auth-overview.ts b/src/commands/models/list.auth-overview.ts index 28880415eeb..17803153c42 100644 --- a/src/commands/models/list.auth-overview.ts +++ b/src/commands/models/list.auth-overview.ts @@ -7,7 +7,11 @@ import { resolveProfileUnusableUntilForDisplay, } from "../../agents/auth-profiles.js"; import { isNonSecretApiKeyMarker } from "../../agents/model-auth-markers.js"; -import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js"; +import { + getCustomProviderApiKey, + resolveEnvApiKey, + resolveUsableCustomProviderApiKey, +} from "../../agents/model-auth.js"; import type { OpenClawConfig } from "../../config/config.js"; import { shortenHomePath } from "../../utils.js"; import { maskApiKey } from "./list.format.js"; @@ -99,6 +103,7 @@ export function resolveProviderAuthOverview(params: { const envKey = resolveEnvApiKey(provider); const customKey = getCustomProviderApiKey(cfg, provider); + const usableCustomKey = resolveUsableCustomProviderApiKey({ cfg, provider }); const effective: ProviderAuthOverview["effective"] = (() => { if (profiles.length > 0) { @@ -115,8 +120,8 @@ export function resolveProviderAuthOverview(params: { detail: isOAuthEnv ? "OAuth (env)" : maskApiKey(envKey.apiKey), }; } - if (customKey) { - return { kind: "models.json", detail: formatMarkerOrSecret(customKey) }; + if (usableCustomKey) { + return { kind: "models.json", detail: formatMarkerOrSecret(usableCustomKey.apiKey) }; } return { kind: "missing", detail: "missing" }; })(); diff --git a/src/commands/models/list.probe.ts b/src/commands/models/list.probe.ts index 40eb6b99b9b..5311b004ce2 100644 --- a/src/commands/models/list.probe.ts +++ b/src/commands/models/list.probe.ts @@ -12,8 +12,7 @@ import { resolveAuthProfileOrder, } from "../../agents/auth-profiles.js"; import { describeFailoverError } from "../../agents/failover-error.js"; -import { isNonSecretApiKeyMarker } from "../../agents/model-auth-markers.js"; -import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js"; +import { hasUsableCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js"; import { loadModelCatalog } from "../../agents/model-catalog.js"; import { findNormalizedProviderValue, @@ -373,8 +372,7 @@ export async function buildProbeTargets(params: { } const envKey = resolveEnvApiKey(providerKey); - const customKey = getCustomProviderApiKey(cfg, providerKey); - const hasUsableModelsJsonKey = Boolean(customKey && !isNonSecretApiKeyMarker(customKey)); + const hasUsableModelsJsonKey = hasUsableCustomProviderApiKey(cfg, providerKey); if (!envKey && !hasUsableModelsJsonKey) { continue; } diff --git a/src/commands/models/list.registry.ts b/src/commands/models/list.registry.ts index 340d49155df..0bc0604432e 100644 --- a/src/commands/models/list.registry.ts +++ b/src/commands/models/list.registry.ts @@ -4,7 +4,7 @@ import { resolveOpenClawAgentDir } from "../../agents/agent-paths.js"; import type { AuthProfileStore } from "../../agents/auth-profiles.js"; import { listProfilesForProvider } from "../../agents/auth-profiles.js"; import { - getCustomProviderApiKey, + hasUsableCustomProviderApiKey, resolveAwsSdkEnvVarName, resolveEnvApiKey, } from "../../agents/model-auth.js"; @@ -35,7 +35,7 @@ const hasAuthForProvider = ( if (resolveEnvApiKey(provider)) { return true; } - if (getCustomProviderApiKey(cfg, provider)) { + if (hasUsableCustomProviderApiKey(cfg, provider)) { return true; } return false; diff --git a/src/commands/models/list.status.test.ts b/src/commands/models/list.status.test.ts index 6f06e63f4b8..9b408f50d93 100644 --- a/src/commands/models/list.status.test.ts +++ b/src/commands/models/list.status.test.ts @@ -61,6 +61,8 @@ const mocks = vi.hoisted(() => { } return null; }), + hasUsableCustomProviderApiKey: vi.fn().mockReturnValue(false), + resolveUsableCustomProviderApiKey: vi.fn().mockReturnValue(null), getCustomProviderApiKey: vi.fn().mockReturnValue(undefined), getShellEnvAppliedKeys: vi.fn().mockReturnValue(["OPENAI_API_KEY", "ANTHROPIC_OAUTH_TOKEN"]), shouldEnableShellEnvFallback: vi.fn().mockReturnValue(true), @@ -106,6 +108,8 @@ vi.mock("../../agents/auth-profiles.js", async (importOriginal) => { vi.mock("../../agents/model-auth.js", () => ({ resolveEnvApiKey: mocks.resolveEnvApiKey, + hasUsableCustomProviderApiKey: mocks.hasUsableCustomProviderApiKey, + resolveUsableCustomProviderApiKey: mocks.resolveUsableCustomProviderApiKey, getCustomProviderApiKey: mocks.getCustomProviderApiKey, })); diff --git a/src/commands/onboard-auth.config-core.ts b/src/commands/onboard-auth.config-core.ts index 103343d5914..4bda29df1bf 100644 --- a/src/commands/onboard-auth.config-core.ts +++ b/src/commands/onboard-auth.config-core.ts @@ -65,6 +65,7 @@ import { buildZaiModelDefinition, buildMoonshotModelDefinition, buildXaiModelDefinition, + buildModelStudioModelDefinition, MISTRAL_BASE_URL, MISTRAL_DEFAULT_MODEL_ID, QIANFAN_BASE_URL, @@ -79,6 +80,9 @@ import { resolveZaiBaseUrl, XAI_BASE_URL, XAI_DEFAULT_MODEL_ID, + MODELSTUDIO_CN_BASE_URL, + MODELSTUDIO_GLOBAL_BASE_URL, + MODELSTUDIO_DEFAULT_MODEL_REF, } from "./onboard-auth.models.js"; export function applyZaiProviderConfig( @@ -573,3 +577,92 @@ export function applyQianfanConfig(cfg: OpenClawConfig): OpenClawConfig { const next = applyQianfanProviderConfig(cfg); return applyAgentDefaultModelPrimary(next, QIANFAN_DEFAULT_MODEL_REF); } + +// Alibaba Cloud Model Studio Coding Plan + +function applyModelStudioProviderConfigWithBaseUrl( + cfg: OpenClawConfig, + baseUrl: string, +): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + + const modelStudioModelIds = [ + "qwen3.5-plus", + "qwen3-max-2026-01-23", + "qwen3-coder-next", + "qwen3-coder-plus", + "MiniMax-M2.5", + "glm-5", + "glm-4.7", + "kimi-k2.5", + ]; + for (const modelId of modelStudioModelIds) { + const modelRef = `modelstudio/${modelId}`; + if (!models[modelRef]) { + models[modelRef] = {}; + } + } + models[MODELSTUDIO_DEFAULT_MODEL_REF] = { + ...models[MODELSTUDIO_DEFAULT_MODEL_REF], + alias: models[MODELSTUDIO_DEFAULT_MODEL_REF]?.alias ?? "Qwen", + }; + + const providers = { ...cfg.models?.providers }; + const existingProvider = providers.modelstudio; + const existingModels = Array.isArray(existingProvider?.models) ? existingProvider.models : []; + + const defaultModels = [ + buildModelStudioModelDefinition({ id: "qwen3.5-plus" }), + buildModelStudioModelDefinition({ id: "qwen3-max-2026-01-23" }), + buildModelStudioModelDefinition({ id: "qwen3-coder-next" }), + buildModelStudioModelDefinition({ id: "qwen3-coder-plus" }), + buildModelStudioModelDefinition({ id: "MiniMax-M2.5" }), + buildModelStudioModelDefinition({ id: "glm-5" }), + buildModelStudioModelDefinition({ id: "glm-4.7" }), + buildModelStudioModelDefinition({ id: "kimi-k2.5" }), + ]; + + const mergedModels = [...existingModels]; + const seen = new Set(existingModels.map((m) => m.id)); + for (const model of defaultModels) { + if (!seen.has(model.id)) { + mergedModels.push(model); + seen.add(model.id); + } + } + + const { apiKey: existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record< + string, + unknown + > as { apiKey?: string }; + const resolvedApiKey = typeof existingApiKey === "string" ? existingApiKey : undefined; + const normalizedApiKey = resolvedApiKey?.trim(); + + providers.modelstudio = { + ...existingProviderRest, + baseUrl, + api: "openai-completions", + ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), + models: mergedModels.length > 0 ? mergedModels : defaultModels, + }; + + return applyOnboardAuthAgentModelsAndProviders(cfg, { agentModels: models, providers }); +} + +export function applyModelStudioProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyModelStudioProviderConfigWithBaseUrl(cfg, MODELSTUDIO_GLOBAL_BASE_URL); +} + +export function applyModelStudioProviderConfigCn(cfg: OpenClawConfig): OpenClawConfig { + return applyModelStudioProviderConfigWithBaseUrl(cfg, MODELSTUDIO_CN_BASE_URL); +} + +export function applyModelStudioConfig(cfg: OpenClawConfig): OpenClawConfig { + const next = applyModelStudioProviderConfig(cfg); + return applyAgentDefaultModelPrimary(next, MODELSTUDIO_DEFAULT_MODEL_REF); +} + +export function applyModelStudioConfigCn(cfg: OpenClawConfig): OpenClawConfig { + const next = applyModelStudioProviderConfigCn(cfg); + return applyAgentDefaultModelPrimary(next, MODELSTUDIO_DEFAULT_MODEL_REF); +} diff --git a/src/commands/onboard-auth.credentials.ts b/src/commands/onboard-auth.credentials.ts index c32a3ea9ae6..c83861b5685 100644 --- a/src/commands/onboard-auth.credentials.ts +++ b/src/commands/onboard-auth.credentials.ts @@ -15,7 +15,11 @@ import { PROVIDER_ENV_VARS } from "../secrets/provider-env-vars.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; import type { SecretInputMode } from "./onboard-types.js"; export { CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF } from "../agents/cloudflare-ai-gateway.js"; -export { MISTRAL_DEFAULT_MODEL_REF, XAI_DEFAULT_MODEL_REF } from "./onboard-auth.models.js"; +export { + MISTRAL_DEFAULT_MODEL_REF, + XAI_DEFAULT_MODEL_REF, + MODELSTUDIO_DEFAULT_MODEL_REF, +} from "./onboard-auth.models.js"; export { KILOCODE_DEFAULT_MODEL_REF }; const resolveAuthAgentDir = (agentDir?: string) => agentDir ?? resolveOpenClawAgentDir(); @@ -472,6 +476,18 @@ export function setQianfanApiKey( }); } +export function setModelStudioApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "modelstudio:default", + credential: buildApiKeyCredential("modelstudio", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + export function setXaiApiKey(key: SecretInput, agentDir?: string, options?: ApiKeyStorageOptions) { upsertAuthProfile({ profileId: "xai:default", diff --git a/src/commands/onboard-auth.models.ts b/src/commands/onboard-auth.models.ts index 36ae85dadac..2945e7b4461 100644 --- a/src/commands/onboard-auth.models.ts +++ b/src/commands/onboard-auth.models.ts @@ -224,3 +224,105 @@ export function buildKilocodeModelDefinition(): ModelDefinitionConfig { maxTokens: KILOCODE_DEFAULT_MAX_TOKENS, }; } + +// Alibaba Cloud Model Studio Coding Plan +export const MODELSTUDIO_CN_BASE_URL = "https://coding.dashscope.aliyuncs.com/v1"; +export const MODELSTUDIO_GLOBAL_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1"; +export const MODELSTUDIO_DEFAULT_MODEL_ID = "qwen3.5-plus"; +export const MODELSTUDIO_DEFAULT_MODEL_REF = `modelstudio/${MODELSTUDIO_DEFAULT_MODEL_ID}`; +export const MODELSTUDIO_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +const MODELSTUDIO_MODEL_CATALOG = { + "qwen3.5-plus": { + name: "qwen3.5-plus", + reasoning: false, + input: ["text", "image"], + contextWindow: 1000000, + maxTokens: 65536, + }, + "qwen3-max-2026-01-23": { + name: "qwen3-max-2026-01-23", + reasoning: false, + input: ["text"], + contextWindow: 262144, + maxTokens: 65536, + }, + "qwen3-coder-next": { + name: "qwen3-coder-next", + reasoning: false, + input: ["text"], + contextWindow: 262144, + maxTokens: 65536, + }, + "qwen3-coder-plus": { + name: "qwen3-coder-plus", + reasoning: false, + input: ["text"], + contextWindow: 1000000, + maxTokens: 65536, + }, + "MiniMax-M2.5": { + name: "MiniMax-M2.5", + reasoning: false, + input: ["text"], + contextWindow: 1000000, + maxTokens: 65536, + }, + "glm-5": { + name: "glm-5", + reasoning: false, + input: ["text"], + contextWindow: 202752, + maxTokens: 16384, + }, + "glm-4.7": { + name: "glm-4.7", + reasoning: false, + input: ["text"], + contextWindow: 202752, + maxTokens: 16384, + }, + "kimi-k2.5": { + name: "kimi-k2.5", + reasoning: false, + input: ["text", "image"], + contextWindow: 262144, + maxTokens: 32768, + }, +} as const; + +type ModelStudioCatalogId = keyof typeof MODELSTUDIO_MODEL_CATALOG; + +export function buildModelStudioModelDefinition(params: { + id: string; + name?: string; + reasoning?: boolean; + input?: string[]; + cost?: ModelDefinitionConfig["cost"]; + contextWindow?: number; + maxTokens?: number; +}): ModelDefinitionConfig { + const catalog = MODELSTUDIO_MODEL_CATALOG[params.id as ModelStudioCatalogId]; + return { + id: params.id, + name: params.name ?? catalog?.name ?? params.id, + reasoning: params.reasoning ?? catalog?.reasoning ?? false, + input: + (params.input as ("text" | "image")[]) ?? + ([...(catalog?.input ?? ["text"])] as ("text" | "image")[]), + cost: params.cost ?? MODELSTUDIO_DEFAULT_COST, + contextWindow: params.contextWindow ?? catalog?.contextWindow ?? 262144, + maxTokens: params.maxTokens ?? catalog?.maxTokens ?? 65536, + }; +} + +export function buildModelStudioDefaultModelDefinition(): ModelDefinitionConfig { + return buildModelStudioModelDefinition({ + id: MODELSTUDIO_DEFAULT_MODEL_ID, + }); +} diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index 13d2cf75bf0..22946567fae 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -39,6 +39,10 @@ export { applyXiaomiProviderConfig, applyZaiConfig, applyZaiProviderConfig, + applyModelStudioConfig, + applyModelStudioConfigCn, + applyModelStudioProviderConfig, + applyModelStudioProviderConfigCn, KILOCODE_BASE_URL, } from "./onboard-auth.config-core.js"; export { @@ -84,6 +88,7 @@ export { setVolcengineApiKey, setZaiApiKey, setXaiApiKey, + setModelStudioApiKey, writeOAuthCredentials, HUGGINGFACE_DEFAULT_MODEL_REF, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, @@ -92,6 +97,7 @@ export { TOGETHER_DEFAULT_MODEL_REF, MISTRAL_DEFAULT_MODEL_REF, XAI_DEFAULT_MODEL_REF, + MODELSTUDIO_DEFAULT_MODEL_REF, } from "./onboard-auth.credentials.js"; export { buildKilocodeModelDefinition, diff --git a/src/commands/onboard-non-interactive.provider-auth.test.ts b/src/commands/onboard-non-interactive.provider-auth.test.ts index d72de28a61d..3f5ccee1755 100644 --- a/src/commands/onboard-non-interactive.provider-auth.test.ts +++ b/src/commands/onboard-non-interactive.provider-auth.test.ts @@ -611,6 +611,26 @@ describe("onboard (non-interactive): provider auth", () => { }); }); + it("infers Model Studio auth choice from --modelstudio-api-key and sets default model", async () => { + await withOnboardEnv("openclaw-onboard-modelstudio-infer-", async (env) => { + const cfg = await runOnboardingAndReadConfig(env, { + modelstudioApiKey: "modelstudio-test-key", // pragma: allowlist secret + }); + + expect(cfg.auth?.profiles?.["modelstudio:default"]?.provider).toBe("modelstudio"); + expect(cfg.auth?.profiles?.["modelstudio:default"]?.mode).toBe("api_key"); + expect(cfg.models?.providers?.modelstudio?.baseUrl).toBe( + "https://coding-intl.dashscope.aliyuncs.com/v1", + ); + expect(cfg.agents?.defaults?.model?.primary).toBe("modelstudio/qwen3.5-plus"); + await expectApiKeyProfile({ + profileId: "modelstudio:default", + provider: "modelstudio", + key: "modelstudio-test-key", + }); + }); + }); + it("configures a custom provider from non-interactive flags", async () => { await withOnboardEnv("openclaw-onboard-custom-provider-", async ({ configPath, runtime }) => { await runNonInteractiveOnboardingWithDefaults(runtime, { diff --git a/src/commands/onboard-non-interactive/local/auth-choice-inference.ts b/src/commands/onboard-non-interactive/local/auth-choice-inference.ts index aecab3ba489..a49be3ad2c8 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice-inference.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice-inference.ts @@ -30,6 +30,8 @@ type AuthChoiceFlagOptions = Pick< | "xaiApiKey" | "litellmApiKey" | "qianfanApiKey" + | "modelstudioApiKeyCn" + | "modelstudioApiKey" | "volcengineApiKey" | "byteplusApiKey" | "customBaseUrl" diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index 98eef51dd20..9739f57ce2e 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -15,6 +15,8 @@ import { applyCloudflareAiGatewayConfig, applyKilocodeConfig, applyQianfanConfig, + applyModelStudioConfig, + applyModelStudioConfigCn, applyKimiCodeConfig, applyMinimaxApiConfig, applyMinimaxApiConfigCn, @@ -37,6 +39,7 @@ import { setCloudflareAiGatewayConfig, setByteplusApiKey, setQianfanApiKey, + setModelStudioApiKey, setGeminiApiKey, setKilocodeApiKey, setKimiCodingApiKey, @@ -498,6 +501,60 @@ export async function applyNonInteractiveAuthChoice(params: { return applyQianfanConfig(nextConfig); } + if (authChoice === "modelstudio-api-key-cn") { + const resolved = await resolveApiKey({ + provider: "modelstudio", + cfg: baseConfig, + flagValue: opts.modelstudioApiKeyCn, + flagName: "--modelstudio-api-key-cn", + envVar: "MODELSTUDIO_API_KEY", + runtime, + }); + if (!resolved) { + return null; + } + if ( + !(await maybeSetResolvedApiKey(resolved, (value) => + setModelStudioApiKey(value, undefined, apiKeyStorageOptions), + )) + ) { + return null; + } + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "modelstudio:default", + provider: "modelstudio", + mode: "api_key", + }); + return applyModelStudioConfigCn(nextConfig); + } + + if (authChoice === "modelstudio-api-key") { + const resolved = await resolveApiKey({ + provider: "modelstudio", + cfg: baseConfig, + flagValue: opts.modelstudioApiKey, + flagName: "--modelstudio-api-key", + envVar: "MODELSTUDIO_API_KEY", + runtime, + }); + if (!resolved) { + return null; + } + if ( + !(await maybeSetResolvedApiKey(resolved, (value) => + setModelStudioApiKey(value, undefined, apiKeyStorageOptions), + )) + ) { + return null; + } + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "modelstudio:default", + provider: "modelstudio", + mode: "api_key", + }); + return applyModelStudioConfig(nextConfig); + } + if (authChoice === "openai-api-key") { const resolved = await resolveApiKey({ provider: "openai", diff --git a/src/commands/onboard-provider-auth-flags.ts b/src/commands/onboard-provider-auth-flags.ts index a1038625a78..43c552f99fb 100644 --- a/src/commands/onboard-provider-auth-flags.ts +++ b/src/commands/onboard-provider-auth-flags.ts @@ -23,6 +23,8 @@ type OnboardProviderAuthOptionKey = keyof Pick< | "xaiApiKey" | "litellmApiKey" | "qianfanApiKey" + | "modelstudioApiKeyCn" + | "modelstudioApiKey" | "volcengineApiKey" | "byteplusApiKey" >; @@ -184,6 +186,20 @@ export const ONBOARD_PROVIDER_AUTH_FLAGS: ReadonlyArray cliOption: "--qianfan-api-key ", description: "QIANFAN API key", }, + { + optionKey: "modelstudioApiKeyCn", + authChoice: "modelstudio-api-key-cn", + cliFlag: "--modelstudio-api-key-cn", + cliOption: "--modelstudio-api-key-cn ", + description: "Alibaba Cloud Model Studio Coding Plan API key (China)", + }, + { + optionKey: "modelstudioApiKey", + authChoice: "modelstudio-api-key", + cliFlag: "--modelstudio-api-key", + cliOption: "--modelstudio-api-key ", + description: "Alibaba Cloud Model Studio Coding Plan API key (Global/Intl)", + }, { optionKey: "volcengineApiKey", authChoice: "volcengine-api-key", diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index 7e938430517..44f4660321e 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -49,6 +49,8 @@ export type AuthChoice = | "volcengine-api-key" | "byteplus-api-key" | "qianfan-api-key" + | "modelstudio-api-key-cn" + | "modelstudio-api-key" | "custom-api-key" | "skip"; export type AuthChoiceGroupId = @@ -75,6 +77,7 @@ export type AuthChoiceGroupId = | "together" | "huggingface" | "qianfan" + | "modelstudio" | "xai" | "volcengine" | "byteplus" @@ -135,6 +138,8 @@ export type OnboardOptions = { volcengineApiKey?: string; byteplusApiKey?: string; qianfanApiKey?: string; + modelstudioApiKeyCn?: string; + modelstudioApiKey?: string; customBaseUrl?: string; customApiKey?: string; customModelId?: string; diff --git a/src/config/config.secrets-schema.test.ts b/src/config/config.secrets-schema.test.ts index 196bb50ace4..e3c236fb15b 100644 --- a/src/config/config.secrets-schema.test.ts +++ b/src/config/config.secrets-schema.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it } from "vitest"; +import { + INVALID_EXEC_SECRET_REF_IDS, + VALID_EXEC_SECRET_REF_IDS, +} from "../test-utils/secret-ref-test-vectors.js"; import { validateConfigObjectRaw } from "./validation.js"; function validateOpenAiApiKeyRef(apiKey: unknown) { @@ -173,4 +177,31 @@ describe("config secret refs schema", () => { ).toBe(true); } }); + + it("accepts valid exec secret reference ids", () => { + for (const id of VALID_EXEC_SECRET_REF_IDS) { + const result = validateOpenAiApiKeyRef({ + source: "exec", + provider: "vault", + id, + }); + expect(result.ok, `expected valid exec ref id: ${id}`).toBe(true); + } + }); + + it("rejects invalid exec secret reference ids", () => { + for (const id of INVALID_EXEC_SECRET_REF_IDS) { + const result = validateOpenAiApiKeyRef({ + source: "exec", + provider: "vault", + id, + }); + expect(result.ok, `expected invalid exec ref id: ${id}`).toBe(false); + if (!result.ok) { + expect( + result.issues.some((issue) => issue.path.includes("models.providers.openai.apiKey")), + ).toBe(true); + } + } + }); }); diff --git a/src/config/config.ts b/src/config/config.ts index 7caaa15a95f..3bd36d0d709 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -5,6 +5,7 @@ export { createConfigIO, getRuntimeConfigSnapshot, getRuntimeConfigSourceSnapshot, + projectConfigOntoRuntimeSourceSnapshot, loadConfig, readBestEffortConfig, parseConfigJson5, diff --git a/src/config/io.runtime-snapshot-write.test.ts b/src/config/io.runtime-snapshot-write.test.ts index 71ddbbb8de3..480897c698c 100644 --- a/src/config/io.runtime-snapshot-write.test.ts +++ b/src/config/io.runtime-snapshot-write.test.ts @@ -7,6 +7,7 @@ import { clearRuntimeConfigSnapshot, getRuntimeConfigSourceSnapshot, loadConfig, + projectConfigOntoRuntimeSourceSnapshot, setRuntimeConfigSnapshotRefreshHandler, setRuntimeConfigSnapshot, writeConfigFile, @@ -61,6 +62,46 @@ describe("runtime config snapshot writes", () => { }); }); + it("skips source projection for non-runtime-derived configs", async () => { + await withTempHome("openclaw-config-runtime-projection-shape-", async () => { + const sourceConfig: OpenClawConfig = { + ...createSourceConfig(), + gateway: { + auth: { + mode: "token", + }, + }, + }; + const runtimeConfig: OpenClawConfig = { + ...createRuntimeConfig(), + gateway: { + auth: { + mode: "token", + }, + }, + }; + const independentConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-independent-config", // pragma: allowlist secret + models: [], + }, + }, + }, + }; + + try { + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + const projected = projectConfigOntoRuntimeSourceSnapshot(independentConfig); + expect(projected).toBe(independentConfig); + } finally { + resetRuntimeConfigState(); + } + }); + }); + it("clears runtime source snapshot when runtime snapshot is cleared", async () => { const sourceConfig = createSourceConfig(); const runtimeConfig = createRuntimeConfig(); diff --git a/src/config/io.ts b/src/config/io.ts index a4ec4cd430c..2b542bba755 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -68,6 +68,7 @@ const SHELL_ENV_EXPECTED_KEYS = [ "OPENROUTER_API_KEY", "AI_GATEWAY_API_KEY", "MINIMAX_API_KEY", + "MODELSTUDIO_API_KEY", "SYNTHETIC_API_KEY", "KILOCODE_API_KEY", "ELEVENLABS_API_KEY", @@ -1373,6 +1374,58 @@ export function getRuntimeConfigSourceSnapshot(): OpenClawConfig | null { return runtimeConfigSourceSnapshot; } +function isCompatibleTopLevelRuntimeProjectionShape(params: { + runtimeSnapshot: OpenClawConfig; + candidate: OpenClawConfig; +}): boolean { + const runtime = params.runtimeSnapshot as Record; + const candidate = params.candidate as Record; + for (const key of Object.keys(runtime)) { + if (!Object.hasOwn(candidate, key)) { + return false; + } + const runtimeValue = runtime[key]; + const candidateValue = candidate[key]; + const runtimeType = Array.isArray(runtimeValue) + ? "array" + : runtimeValue === null + ? "null" + : typeof runtimeValue; + const candidateType = Array.isArray(candidateValue) + ? "array" + : candidateValue === null + ? "null" + : typeof candidateValue; + if (runtimeType !== candidateType) { + return false; + } + } + return true; +} + +export function projectConfigOntoRuntimeSourceSnapshot(config: OpenClawConfig): OpenClawConfig { + if (!runtimeConfigSnapshot || !runtimeConfigSourceSnapshot) { + return config; + } + if (config === runtimeConfigSnapshot) { + return runtimeConfigSourceSnapshot; + } + // This projection expects callers to pass config objects derived from the + // active runtime snapshot (for example shallow/deep clones with targeted edits). + // For structurally unrelated configs, skip projection to avoid accidental + // merge-patch deletions or reintroducing resolved values into source refs. + if ( + !isCompatibleTopLevelRuntimeProjectionShape({ + runtimeSnapshot: runtimeConfigSnapshot, + candidate: config, + }) + ) { + return config; + } + const runtimePatch = createMergePatch(runtimeConfigSnapshot, config); + return coerceConfig(applyMergePatch(runtimeConfigSourceSnapshot, runtimePatch)); +} + export function setRuntimeConfigSnapshotRefreshHandler( refreshHandler: RuntimeConfigSnapshotRefreshHandler | null, ): void { diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 41c047e860c..45eac2fb310 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -93,7 +93,7 @@ export type TelegramAccountConfig = { /** If false, do not start this Telegram account. Default: true. */ enabled?: boolean; botToken?: string; - /** Path to file containing bot token (for secret managers like agenix). */ + /** Path to a regular file containing the bot token; symlinks are rejected. */ tokenFile?: string; /** Control reply threading when reply tags are present (off|first|all). */ replyToMode?: ReplyToMode; diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 23accd81637..066a33f0f4f 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -1,14 +1,17 @@ import path from "node:path"; import { z } from "zod"; import { isSafeExecutableValue } from "../infra/exec-safety.js"; -import { isValidFileSecretRefId } from "../secrets/ref-contract.js"; +import { + formatExecSecretRefIdValidationMessage, + isValidExecSecretRefId, + isValidFileSecretRefId, +} from "../secrets/ref-contract.js"; import { MODEL_APIS } from "./types.models.js"; import { createAllowDenyChannelRulesSchema } from "./zod-schema.allowdeny.js"; import { sensitive } from "./zod-schema.sensitive.js"; const ENV_SECRET_REF_ID_PATTERN = /^[A-Z][A-Z0-9_]{0,127}$/; const SECRET_PROVIDER_ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/; -const EXEC_SECRET_REF_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/; const WINDOWS_ABS_PATH_PATTERN = /^[A-Za-z]:[\\/]/; const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/; @@ -65,12 +68,7 @@ const ExecSecretRefSchema = z SECRET_PROVIDER_ALIAS_PATTERN, 'Secret reference provider must match /^[a-z][a-z0-9_-]{0,63}$/ (example: "default").', ), - id: z - .string() - .regex( - EXEC_SECRET_REF_ID_PATTERN, - 'Exec secret reference id must match /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/ (example: "vault/openai/api-key").', - ), + id: z.string().refine(isValidExecSecretRefId, formatExecSecretRefIdValidationMessage()), }) .strict(); diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 3ceefb480ff..0bb676fa5ad 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -384,6 +384,16 @@ export const DiscordGuildChannelSchema = z systemPrompt: z.string().optional(), includeThreadStarter: z.boolean().optional(), autoThread: z.boolean().optional(), + /** Archive duration for auto-created threads in minutes. Discord supports 60, 1440 (1 day), 4320 (3 days), 10080 (1 week). Default: 60. */ + autoArchiveDuration: z + .union([ + z.enum(["60", "1440", "4320", "10080"]), + z.literal(60), + z.literal(1440), + z.literal(4320), + z.literal(10080), + ]) + .optional(), }) .strict(); diff --git a/src/cron/isolated-agent.lane.test.ts b/src/cron/isolated-agent.lane.test.ts new file mode 100644 index 00000000000..5d26faff327 --- /dev/null +++ b/src/cron/isolated-agent.lane.test.ts @@ -0,0 +1,84 @@ +import "./isolated-agent.mocks.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; +import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; +import { + makeCfg, + makeJob, + withTempCronHome, + writeSessionStoreEntries, +} from "./isolated-agent.test-harness.js"; + +function makeDeps() { + return { + sendMessageSlack: vi.fn(), + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn(), + sendMessageDiscord: vi.fn(), + sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), + }; +} + +function mockEmbeddedOk() { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); +} + +function lastEmbeddedLane(): string | undefined { + const calls = vi.mocked(runEmbeddedPiAgent).mock.calls; + expect(calls.length).toBeGreaterThan(0); + return (calls.at(-1)?.[0] as { lane?: string } | undefined)?.lane; +} + +async function runLaneCase(home: string, lane?: string) { + const storePath = await writeSessionStoreEntries(home, { + "agent:main:main": { + sessionId: "main-session", + updatedAt: Date.now(), + lastProvider: "webchat", + lastTo: "", + }, + }); + mockEmbeddedOk(); + + await runCronIsolatedAgentTurn({ + cfg: makeCfg(home, storePath), + deps: makeDeps(), + job: makeJob({ kind: "agentTurn", message: "do it", deliver: false }), + message: "do it", + sessionKey: "cron:job-1", + ...(lane === undefined ? {} : { lane }), + }); + + return lastEmbeddedLane(); +} + +describe("runCronIsolatedAgentTurn lane selection", () => { + beforeEach(() => { + vi.mocked(runEmbeddedPiAgent).mockClear(); + }); + + it("moves the cron lane to nested for embedded runs", async () => { + await withTempCronHome(async (home) => { + expect(await runLaneCase(home, "cron")).toBe("nested"); + }); + }); + + it("defaults missing lanes to nested for embedded runs", async () => { + await withTempCronHome(async (home) => { + expect(await runLaneCase(home)).toBe("nested"); + }); + }); + + it("preserves non-cron lanes for embedded runs", async () => { + await withTempCronHome(async (home) => { + expect(await runLaneCase(home, "subagent")).toBe("subagent"); + }); + }); +}); diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 0666b752e5c..4db6b88b57f 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -12,6 +12,7 @@ import { getCliSessionId, setCliSessionId } from "../../agents/cli-session.js"; import { lookupContextTokens } from "../../agents/context.js"; import { resolveCronStyleNow } from "../../agents/current-time.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js"; +import { resolveNestedAgentLane } from "../../agents/lanes.js"; import { loadModelCatalog } from "../../agents/model-catalog.js"; import { runWithModelFallback } from "../../agents/model-fallback.js"; import { @@ -197,6 +198,16 @@ function appendCronDeliveryInstruction(params: { return `${params.commandBody}\n\nReturn your summary as plain text; it will be delivered automatically. If the task explicitly calls for messaging a specific external recipient, note who/where it should go instead of sending it yourself.`.trim(); } +function resolveCronEmbeddedAgentLane(lane?: string) { + const trimmed = lane?.trim(); + // Cron jobs already execute inside the cron command lane. Reusing that same + // lane for the nested embedded-agent run deadlocks: the outer cron task holds + // the lane while the inner run waits to reacquire it. + if (!trimmed || trimmed === "cron") { + return CommandLane.Nested; + } + return trimmed; +} export async function runCronIsolatedAgentTurn(params: { cfg: OpenClawConfig; deps: CliDeps; @@ -610,7 +621,7 @@ export async function runCronIsolatedAgentTurn(params: { config: cfgWithAgentDefaults, skillsSnapshot, prompt: promptText, - lane: params.lane ?? "cron", + lane: resolveNestedAgentLane(params.lane), provider: providerOverride, model: modelOverride, authProfileId, diff --git a/src/discord/client.test.ts b/src/discord/client.test.ts new file mode 100644 index 00000000000..3dc156670e7 --- /dev/null +++ b/src/discord/client.test.ts @@ -0,0 +1,91 @@ +import type { RequestClient } from "@buape/carbon"; +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { createDiscordRestClient } from "./client.js"; + +describe("createDiscordRestClient", () => { + const fakeRest = {} as RequestClient; + + it("uses explicit token without resolving config token SecretRefs", () => { + const cfg = { + channels: { + discord: { + token: { + source: "exec", + provider: "vault", + id: "discord/bot-token", + }, + }, + }, + } as OpenClawConfig; + + const result = createDiscordRestClient( + { + token: "Bot explicit-token", + rest: fakeRest, + }, + cfg, + ); + + expect(result.token).toBe("explicit-token"); + expect(result.rest).toBe(fakeRest); + expect(result.account.accountId).toBe("default"); + }); + + it("keeps account retry config when explicit token is provided", () => { + const cfg = { + channels: { + discord: { + accounts: { + ops: { + token: { + source: "exec", + provider: "vault", + id: "discord/ops-token", + }, + retry: { + attempts: 7, + }, + }, + }, + }, + }, + } as OpenClawConfig; + + const result = createDiscordRestClient( + { + accountId: "ops", + token: "Bot explicit-account-token", + rest: fakeRest, + }, + cfg, + ); + + expect(result.token).toBe("explicit-account-token"); + expect(result.account.accountId).toBe("ops"); + expect(result.account.config.retry).toMatchObject({ attempts: 7 }); + }); + + it("still throws when no explicit token is provided and config token is unresolved", () => { + const cfg = { + channels: { + discord: { + token: { + source: "file", + provider: "default", + id: "/discord/token", + }, + }, + }, + } as OpenClawConfig; + + expect(() => + createDiscordRestClient( + { + rest: fakeRest, + }, + cfg, + ), + ).toThrow(/unresolved SecretRef/i); + }); +}); diff --git a/src/discord/client.ts b/src/discord/client.ts index 4f754fa8624..62d917cebb6 100644 --- a/src/discord/client.ts +++ b/src/discord/client.ts @@ -2,10 +2,16 @@ import { RequestClient } from "@buape/carbon"; import { loadConfig } from "../config/config.js"; import { createDiscordRetryRunner, type RetryRunner } from "../infra/retry-policy.js"; import type { RetryConfig } from "../infra/retry.js"; -import { resolveDiscordAccount } from "./accounts.js"; +import { normalizeAccountId } from "../routing/session-key.js"; +import { + mergeDiscordAccountConfig, + resolveDiscordAccount, + type ResolvedDiscordAccount, +} from "./accounts.js"; import { normalizeDiscordToken } from "./token.js"; export type DiscordClientOpts = { + cfg?: ReturnType; token?: string; accountId?: string; rest?: RequestClient; @@ -13,11 +19,7 @@ export type DiscordClientOpts = { verbose?: boolean; }; -function resolveToken(params: { explicit?: string; accountId: string; fallbackToken?: string }) { - const explicit = normalizeDiscordToken(params.explicit, "channels.discord.token"); - if (explicit) { - return explicit; - } +function resolveToken(params: { accountId: string; fallbackToken?: string }) { const fallback = normalizeDiscordToken(params.fallbackToken, "channels.discord.token"); if (!fallback) { throw new Error( @@ -31,22 +33,48 @@ function resolveRest(token: string, rest?: RequestClient) { return rest ?? new RequestClient(token); } -export function createDiscordRestClient(opts: DiscordClientOpts, cfg = loadConfig()) { - const account = resolveDiscordAccount({ cfg, accountId: opts.accountId }); - const token = resolveToken({ - explicit: opts.token, - accountId: account.accountId, - fallbackToken: account.token, - }); +function resolveAccountWithoutToken(params: { + cfg: ReturnType; + accountId?: string; +}): ResolvedDiscordAccount { + const accountId = normalizeAccountId(params.accountId); + const merged = mergeDiscordAccountConfig(params.cfg, accountId); + const baseEnabled = params.cfg.channels?.discord?.enabled !== false; + const accountEnabled = merged.enabled !== false; + return { + accountId, + enabled: baseEnabled && accountEnabled, + name: merged.name?.trim() || undefined, + token: "", + tokenSource: "none", + config: merged, + }; +} + +export function createDiscordRestClient( + opts: DiscordClientOpts, + cfg?: ReturnType, +) { + const resolvedCfg = opts.cfg ?? cfg ?? loadConfig(); + const explicitToken = normalizeDiscordToken(opts.token, "channels.discord.token"); + const account = explicitToken + ? resolveAccountWithoutToken({ cfg: resolvedCfg, accountId: opts.accountId }) + : resolveDiscordAccount({ cfg: resolvedCfg, accountId: opts.accountId }); + const token = + explicitToken ?? + resolveToken({ + accountId: account.accountId, + fallbackToken: account.token, + }); const rest = resolveRest(token, opts.rest); return { token, rest, account }; } export function createDiscordClient( opts: DiscordClientOpts, - cfg = loadConfig(), + cfg?: ReturnType, ): { token: string; rest: RequestClient; request: RetryRunner } { - const { token, rest, account } = createDiscordRestClient(opts, cfg); + const { token, rest, account } = createDiscordRestClient(opts, opts.cfg ?? cfg); const request = createDiscordRetryRunner({ retry: opts.retry, configRetry: account.config.retry, @@ -56,5 +84,5 @@ export function createDiscordClient( } export function resolveDiscordRest(opts: DiscordClientOpts) { - return createDiscordRestClient(opts).rest; + return createDiscordRestClient(opts, opts.cfg).rest; } diff --git a/src/discord/monitor/agent-components.ts b/src/discord/monitor/agent-components.ts index 16b3f564bfe..56e7dfe3240 100644 --- a/src/discord/monitor/agent-components.ts +++ b/src/discord/monitor/agent-components.ts @@ -1009,6 +1009,7 @@ async function dispatchDiscordComponentEvent(params: { deliver: async (payload) => { const replyToId = replyReference.use(); await deliverDiscordReply({ + cfg: ctx.cfg, replies: [payload], target: deliverTarget, token, diff --git a/src/discord/monitor/allow-list.ts b/src/discord/monitor/allow-list.ts index 5432cb5d128..b736928e276 100644 --- a/src/discord/monitor/allow-list.ts +++ b/src/discord/monitor/allow-list.ts @@ -40,6 +40,7 @@ export type DiscordGuildEntryResolved = { systemPrompt?: string; includeThreadStarter?: boolean; autoThread?: boolean; + autoArchiveDuration?: "60" | "1440" | "4320" | "10080" | 60 | 1440 | 4320 | 10080; } >; }; @@ -55,6 +56,7 @@ export type DiscordChannelConfigResolved = { systemPrompt?: string; includeThreadStarter?: boolean; autoThread?: boolean; + autoArchiveDuration?: "60" | "1440" | "4320" | "10080" | 60 | 1440 | 4320 | 10080; matchKey?: string; matchSource?: ChannelMatchSource; }; @@ -401,6 +403,7 @@ function resolveDiscordChannelConfigEntry( systemPrompt: entry.systemPrompt, includeThreadStarter: entry.includeThreadStarter, autoThread: entry.autoThread, + autoArchiveDuration: entry.autoArchiveDuration, }; return resolved; } diff --git a/src/discord/monitor/exec-approvals.ts b/src/discord/monitor/exec-approvals.ts index f426ae51903..e8583475e30 100644 --- a/src/discord/monitor/exec-approvals.ts +++ b/src/discord/monitor/exec-approvals.ts @@ -13,9 +13,8 @@ import { ButtonStyle, Routes } from "discord-api-types/v10"; import type { OpenClawConfig } from "../../config/config.js"; import { loadSessionStore, resolveStorePath } from "../../config/sessions.js"; import type { DiscordExecApprovalConfig } from "../../config/types.discord.js"; -import { buildGatewayConnectionDetails } from "../../gateway/call.js"; import { GatewayClient } from "../../gateway/client.js"; -import { resolveGatewayConnectionAuth } from "../../gateway/connection-auth.js"; +import { createOperatorApprovalsGatewayClient } from "../../gateway/operator-approvals-client.js"; import type { EventFrame } from "../../gateway/protocol/index.js"; import { getExecApprovalApproverDmNoticeText } from "../../infra/exec-approval-reply.js"; import type { @@ -27,11 +26,7 @@ import { logDebug, logError } from "../../logger.js"; import { normalizeAccountId, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import type { RuntimeEnv } from "../../runtime.js"; import { compileSafeRegex, testRegexWithBoundedInput } from "../../security/safe-regex.js"; -import { - GATEWAY_CLIENT_MODES, - GATEWAY_CLIENT_NAMES, - normalizeMessageChannel, -} from "../../utils/message-channel.js"; +import { normalizeMessageChannel } from "../../utils/message-channel.js"; import { createDiscordClient, stripUndefinedFields } from "../send.shared.js"; import { DiscordUiContainer } from "../ui.js"; @@ -408,31 +403,10 @@ export class DiscordExecApprovalHandler { logDebug("discord exec approvals: starting handler"); - const { url: gatewayUrl, urlSource } = buildGatewayConnectionDetails({ + this.gatewayClient = await createOperatorApprovalsGatewayClient({ config: this.opts.cfg, - url: this.opts.gatewayUrl, - }); - const gatewayUrlOverrideSource = - urlSource === "cli --url" - ? "cli" - : urlSource === "env OPENCLAW_GATEWAY_URL" - ? "env" - : undefined; - const auth = await resolveGatewayConnectionAuth({ - config: this.opts.cfg, - env: process.env, - urlOverride: gatewayUrlOverrideSource ? gatewayUrl : undefined, - urlOverrideSource: gatewayUrlOverrideSource, - }); - - this.gatewayClient = new GatewayClient({ - url: gatewayUrl, - token: auth.token, - password: auth.password, - clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, + gatewayUrl: this.opts.gatewayUrl, clientDisplayName: "Discord Exec Approvals", - mode: GATEWAY_CLIENT_MODES.BACKEND, - scopes: ["operator.approvals"], onEvent: (evt) => this.handleGatewayEvent(evt), onHelloOk: () => { logDebug("discord exec approvals: connected to gateway"); diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index c283658ac09..ea64b37f98e 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -684,6 +684,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) const replyToId = replyReference.use(); await deliverDiscordReply({ + cfg, replies: [payload], target: deliverTarget, token, diff --git a/src/discord/monitor/provider.group-policy.test.ts b/src/discord/monitor/provider.group-policy.test.ts index 48d4f67614a..9fe01fd0a31 100644 --- a/src/discord/monitor/provider.group-policy.test.ts +++ b/src/discord/monitor/provider.group-policy.test.ts @@ -1,21 +1,14 @@ import { describe, expect, it } from "vitest"; +import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../test-utils/runtime-group-policy-contract.js"; import { __testing } from "./provider.js"; describe("resolveDiscordRuntimeGroupPolicy", () => { - it("fails closed when channels.discord is missing and no defaults are set", () => { - const resolved = __testing.resolveDiscordRuntimeGroupPolicy({ - providerConfigPresent: false, - }); - expect(resolved.groupPolicy).toBe("allowlist"); - expect(resolved.providerMissingFallbackApplied).toBe(true); - }); - - it("keeps open default when channels.discord is configured", () => { - const resolved = __testing.resolveDiscordRuntimeGroupPolicy({ - providerConfigPresent: true, - }); - expect(resolved.groupPolicy).toBe("open"); - expect(resolved.providerMissingFallbackApplied).toBe(false); + installProviderRuntimeGroupPolicyFallbackSuite({ + resolve: __testing.resolveDiscordRuntimeGroupPolicy, + configuredLabel: "keeps open default when channels.discord is configured", + defaultGroupPolicyUnderTest: "open", + missingConfigLabel: "fails closed when channels.discord is missing and no defaults are set", + missingDefaultLabel: "ignores explicit global defaults when provider config is missing", }); it("respects explicit provider policy", () => { @@ -26,13 +19,4 @@ describe("resolveDiscordRuntimeGroupPolicy", () => { expect(resolved.groupPolicy).toBe("disabled"); expect(resolved.providerMissingFallbackApplied).toBe(false); }); - - it("ignores explicit global defaults when provider config is missing", () => { - const resolved = __testing.resolveDiscordRuntimeGroupPolicy({ - providerConfigPresent: false, - defaultGroupPolicy: "open", - }); - expect(resolved.groupPolicy).toBe("allowlist"); - expect(resolved.providerMissingFallbackApplied).toBe(true); - }); }); diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index b0825d03345..08de298a062 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -441,6 +441,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { ? createThreadBindingManager({ accountId: account.accountId, token, + cfg, idleTimeoutMs: threadBindingIdleTimeoutMs, maxAgeMs: threadBindingMaxAgeMs, }) diff --git a/src/discord/monitor/reply-delivery.test.ts b/src/discord/monitor/reply-delivery.test.ts index 3d0357ef43a..1e0bdc00942 100644 --- a/src/discord/monitor/reply-delivery.test.ts +++ b/src/discord/monitor/reply-delivery.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; import type { RuntimeEnv } from "../../runtime.js"; import { deliverDiscordReply } from "./reply-delivery.js"; import { @@ -23,6 +24,9 @@ vi.mock("../send.shared.js", () => ({ describe("deliverDiscordReply", () => { const runtime = {} as RuntimeEnv; + const cfg = { + channels: { discord: { token: "test-token" } }, + } as OpenClawConfig; const createBoundThreadBindings = async ( overrides: Partial<{ threadId: string; @@ -86,6 +90,7 @@ describe("deliverDiscordReply", () => { target: "channel:123", token: "token", runtime, + cfg, textLimit: 2000, replyToId: "reply-1", }); @@ -128,6 +133,7 @@ describe("deliverDiscordReply", () => { target: "channel:456", token: "token", runtime, + cfg, textLimit: 2000, }); @@ -147,6 +153,7 @@ describe("deliverDiscordReply", () => { target: "channel:654", token: "token", runtime, + cfg, textLimit: 2000, mediaLocalRoots, }); @@ -174,6 +181,19 @@ describe("deliverDiscordReply", () => { ); }); + it("forwards cfg to Discord send helpers", async () => { + await deliverDiscordReply({ + replies: [{ text: "cfg path" }], + target: "channel:101", + token: "token", + runtime, + cfg, + textLimit: 2000, + }); + + expect(sendMessageDiscordMock.mock.calls[0]?.[2]?.cfg).toBe(cfg); + }); + it("uses replyToId only for the first chunk when replyToMode is first", async () => { await deliverDiscordReply({ replies: [ @@ -184,6 +204,7 @@ describe("deliverDiscordReply", () => { target: "channel:789", token: "token", runtime, + cfg, textLimit: 5, replyToId: "reply-1", replyToMode: "first", @@ -200,6 +221,7 @@ describe("deliverDiscordReply", () => { target: "channel:789", token: "token", runtime, + cfg, textLimit: 2000, replyToId: "reply-1", replyToMode: "first", @@ -219,6 +241,7 @@ describe("deliverDiscordReply", () => { target: "channel:789", token: "token", runtime, + cfg, textLimit: 2000, }); @@ -246,6 +269,7 @@ describe("deliverDiscordReply", () => { token: "token", rest: fakeRest, runtime, + cfg, textLimit: 5, }); @@ -265,6 +289,7 @@ describe("deliverDiscordReply", () => { token: "token", rest: fakeRest, runtime, + cfg, textLimit: 2000, maxLinesPerMessage: 120, chunkMode: "newline", @@ -285,6 +310,7 @@ describe("deliverDiscordReply", () => { target: "channel:789", token: "token", runtime, + cfg, textLimit: 2000, }); @@ -303,6 +329,7 @@ describe("deliverDiscordReply", () => { target: "channel:123", token: "token", runtime, + cfg, textLimit: 2000, }); @@ -320,6 +347,7 @@ describe("deliverDiscordReply", () => { target: "channel:123", token: "token", runtime, + cfg, textLimit: 2000, }); @@ -336,6 +364,7 @@ describe("deliverDiscordReply", () => { target: "channel:123", token: "token", runtime, + cfg, textLimit: 2000, }), ).rejects.toThrow("bad request"); @@ -353,6 +382,7 @@ describe("deliverDiscordReply", () => { target: "channel:123", token: "token", runtime, + cfg, textLimit: 2000, }), ).rejects.toThrow("rate limited"); @@ -372,6 +402,7 @@ describe("deliverDiscordReply", () => { target: "channel:123", token: "token", runtime, + cfg, textLimit: 2, }); @@ -386,6 +417,7 @@ describe("deliverDiscordReply", () => { target: "channel:thread-1", token: "token", runtime, + cfg, textLimit: 2000, replyToId: "reply-1", sessionKey: "agent:main:subagent:child", @@ -396,6 +428,7 @@ describe("deliverDiscordReply", () => { expect(sendWebhookMessageDiscordMock).toHaveBeenCalledWith( "Hello from subagent", expect.objectContaining({ + cfg, webhookId: "wh_1", webhookToken: "tok_1", accountId: "default", @@ -418,6 +451,7 @@ describe("deliverDiscordReply", () => { target: "channel:thread-1", token: "token", runtime, + cfg, textLimit: 2000, sessionKey: "agent:main:subagent:child", threadBindings, @@ -441,12 +475,14 @@ describe("deliverDiscordReply", () => { token: "token", accountId: "default", runtime, + cfg, textLimit: 2000, sessionKey: "agent:main:subagent:child", threadBindings, }); expect(sendWebhookMessageDiscordMock).toHaveBeenCalledTimes(1); + expect(sendWebhookMessageDiscordMock.mock.calls[0]?.[1]?.cfg).toBe(cfg); expect(sendMessageDiscordMock).toHaveBeenCalledTimes(1); expect(sendMessageDiscordMock).toHaveBeenCalledWith( "channel:thread-1", @@ -464,6 +500,7 @@ describe("deliverDiscordReply", () => { token: "token", accountId: "default", runtime, + cfg, textLimit: 2000, sessionKey: "agent:main:subagent:child", threadBindings, diff --git a/src/discord/monitor/reply-delivery.ts b/src/discord/monitor/reply-delivery.ts index d3e7ef9bf61..fb235ca65d0 100644 --- a/src/discord/monitor/reply-delivery.ts +++ b/src/discord/monitor/reply-delivery.ts @@ -2,7 +2,7 @@ import type { RequestClient } from "@buape/carbon"; import { resolveAgentAvatar } from "../../agents/identity-avatar.js"; import type { ChunkMode } from "../../auto-reply/chunk.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; -import { loadConfig } from "../../config/config.js"; +import type { OpenClawConfig } from "../../config/config.js"; import type { MarkdownTableMode, ReplyToMode } from "../../config/types.base.js"; import { createDiscordRetryRunner, type RetryRunner } from "../../infra/retry-policy.js"; import { resolveRetryConfig, retryAsync, type RetryConfig } from "../../infra/retry.js"; @@ -103,7 +103,10 @@ function resolveBoundThreadBinding(params: { return bindings.find((entry) => entry.threadId === targetChannelId); } -function resolveBindingPersona(binding: DiscordThreadBindingLookupRecord | undefined): { +function resolveBindingPersona( + cfg: OpenClawConfig, + binding: DiscordThreadBindingLookupRecord | undefined, +): { username?: string; avatarUrl?: string; } { @@ -115,7 +118,7 @@ function resolveBindingPersona(binding: DiscordThreadBindingLookupRecord | undef let avatarUrl: string | undefined; try { - const avatar = resolveAgentAvatar(loadConfig(), binding.agentId); + const avatar = resolveAgentAvatar(cfg, binding.agentId); if (avatar.kind === "remote") { avatarUrl = avatar.url; } @@ -126,6 +129,7 @@ function resolveBindingPersona(binding: DiscordThreadBindingLookupRecord | undef } async function sendDiscordChunkWithFallback(params: { + cfg: OpenClawConfig; target: string; text: string; token: string; @@ -152,6 +156,7 @@ async function sendDiscordChunkWithFallback(params: { if (binding?.webhookId && binding?.webhookToken) { try { await sendWebhookMessageDiscord(text, { + cfg: params.cfg, webhookId: binding.webhookId, webhookToken: binding.webhookToken, accountId: binding.accountId, @@ -190,6 +195,7 @@ async function sendDiscordChunkWithFallback(params: { await sendWithRetry( () => sendMessageDiscord(params.target, text, { + cfg: params.cfg, token: params.token, rest: params.rest, accountId: params.accountId, @@ -200,6 +206,7 @@ async function sendDiscordChunkWithFallback(params: { } async function sendAdditionalDiscordMedia(params: { + cfg: OpenClawConfig; target: string; token: string; rest?: RequestClient; @@ -214,6 +221,7 @@ async function sendAdditionalDiscordMedia(params: { await sendWithRetry( () => sendMessageDiscord(params.target, "", { + cfg: params.cfg, token: params.token, rest: params.rest, mediaUrl, @@ -227,6 +235,7 @@ async function sendAdditionalDiscordMedia(params: { } export async function deliverDiscordReply(params: { + cfg: OpenClawConfig; replies: ReplyPayload[]; target: string; token: string; @@ -267,12 +276,12 @@ export async function deliverDiscordReply(params: { sessionKey: params.sessionKey, target: params.target, }); - const persona = resolveBindingPersona(binding); + const persona = resolveBindingPersona(params.cfg, binding); // Pre-resolve channel ID and retry runner once to avoid per-chunk overhead. // This eliminates redundant channel-type GET requests and client creation that // can cause ordering issues when multiple chunks share the RequestClient queue. const channelId = resolveTargetChannelId(params.target); - const account = resolveDiscordAccount({ cfg: loadConfig(), accountId: params.accountId }); + const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }); const retryConfig = resolveDeliveryRetryConfig(account.config.retry); const request: RetryRunner | undefined = channelId ? createDiscordRetryRunner({ configRetry: account.config.retry }) @@ -302,6 +311,7 @@ export async function deliverDiscordReply(params: { } const replyTo = resolveReplyTo(); await sendDiscordChunkWithFallback({ + cfg: params.cfg, target: params.target, text: chunk, token: params.token, @@ -331,6 +341,7 @@ export async function deliverDiscordReply(params: { if (payload.audioAsVoice) { const replyTo = resolveReplyTo(); await sendVoiceMessageDiscord(params.target, firstMedia, { + cfg: params.cfg, token: params.token, rest: params.rest, accountId: params.accountId, @@ -339,6 +350,7 @@ export async function deliverDiscordReply(params: { deliveredAny = true; // Voice messages cannot include text; send remaining text separately if present. await sendDiscordChunkWithFallback({ + cfg: params.cfg, target: params.target, text, token: params.token, @@ -356,6 +368,7 @@ export async function deliverDiscordReply(params: { }); // Additional media items are sent as regular attachments (voice is single-file only). await sendAdditionalDiscordMedia({ + cfg: params.cfg, target: params.target, token: params.token, rest: params.rest, @@ -370,6 +383,7 @@ export async function deliverDiscordReply(params: { const replyTo = resolveReplyTo(); await sendMessageDiscord(params.target, text, { + cfg: params.cfg, token: params.token, rest: params.rest, mediaUrl: firstMedia, @@ -379,6 +393,7 @@ export async function deliverDiscordReply(params: { }); deliveredAny = true; await sendAdditionalDiscordMedia({ + cfg: params.cfg, target: params.target, token: params.token, rest: params.rest, diff --git a/src/discord/monitor/thread-bindings.discord-api.test.ts b/src/discord/monitor/thread-bindings.discord-api.test.ts index 0dca4afe0b4..5b455da9e5d 100644 --- a/src/discord/monitor/thread-bindings.discord-api.test.ts +++ b/src/discord/monitor/thread-bindings.discord-api.test.ts @@ -1,8 +1,12 @@ import { ChannelType } from "discord-api-types/v10"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { ThreadBindingRecord } from "./thread-bindings.types.js"; const hoisted = vi.hoisted(() => { const restGet = vi.fn(); + const sendMessageDiscord = vi.fn(); + const sendWebhookMessageDiscord = vi.fn(); const createDiscordRestClient = vi.fn(() => ({ rest: { get: restGet, @@ -10,6 +14,8 @@ const hoisted = vi.hoisted(() => { })); return { restGet, + sendMessageDiscord, + sendWebhookMessageDiscord, createDiscordRestClient, }; }); @@ -18,12 +24,20 @@ vi.mock("../client.js", () => ({ createDiscordRestClient: hoisted.createDiscordRestClient, })); -const { resolveChannelIdForBinding } = await import("./thread-bindings.discord-api.js"); +vi.mock("../send.js", () => ({ + sendMessageDiscord: (...args: unknown[]) => hoisted.sendMessageDiscord(...args), + sendWebhookMessageDiscord: (...args: unknown[]) => hoisted.sendWebhookMessageDiscord(...args), +})); + +const { maybeSendBindingMessage, resolveChannelIdForBinding } = + await import("./thread-bindings.discord-api.js"); describe("resolveChannelIdForBinding", () => { beforeEach(() => { hoisted.restGet.mockClear(); hoisted.createDiscordRestClient.mockClear(); + hoisted.sendMessageDiscord.mockClear().mockResolvedValue({}); + hoisted.sendWebhookMessageDiscord.mockClear().mockResolvedValue({}); }); it("returns explicit channelId without resolving route", async () => { @@ -53,6 +67,26 @@ describe("resolveChannelIdForBinding", () => { expect(resolved).toBe("channel-parent"); }); + it("forwards cfg when resolving channel id through Discord client", async () => { + const cfg = { + channels: { discord: { token: "tok" } }, + } as OpenClawConfig; + hoisted.restGet.mockResolvedValueOnce({ + id: "thread-1", + type: ChannelType.PublicThread, + parent_id: "channel-parent", + }); + + await resolveChannelIdForBinding({ + cfg, + accountId: "default", + threadId: "thread-1", + }); + + const createDiscordRestClientCalls = hoisted.createDiscordRestClient.mock.calls as unknown[][]; + expect(createDiscordRestClientCalls[0]?.[1]).toBe(cfg); + }); + it("keeps non-thread channel id even when parent_id exists", async () => { hoisted.restGet.mockResolvedValueOnce({ id: "channel-text", @@ -83,3 +117,45 @@ describe("resolveChannelIdForBinding", () => { expect(resolved).toBe("forum-1"); }); }); + +describe("maybeSendBindingMessage", () => { + beforeEach(() => { + hoisted.sendMessageDiscord.mockClear().mockResolvedValue({}); + hoisted.sendWebhookMessageDiscord.mockClear().mockResolvedValue({}); + }); + + it("forwards cfg to webhook send path", async () => { + const cfg = { + channels: { discord: { token: "tok" } }, + } as OpenClawConfig; + const record = { + accountId: "default", + channelId: "parent-1", + threadId: "thread-1", + targetKind: "subagent", + targetSessionKey: "agent:main:subagent:test", + agentId: "main", + boundBy: "test", + boundAt: Date.now(), + lastActivityAt: Date.now(), + webhookId: "wh_1", + webhookToken: "tok_1", + } satisfies ThreadBindingRecord; + + await maybeSendBindingMessage({ + cfg, + record, + text: "hello webhook", + }); + + expect(hoisted.sendWebhookMessageDiscord).toHaveBeenCalledTimes(1); + expect(hoisted.sendWebhookMessageDiscord.mock.calls[0]?.[1]).toMatchObject({ + cfg, + webhookId: "wh_1", + webhookToken: "tok_1", + accountId: "default", + threadId: "thread-1", + }); + expect(hoisted.sendMessageDiscord).not.toHaveBeenCalled(); + }); +}); diff --git a/src/discord/monitor/thread-bindings.discord-api.ts b/src/discord/monitor/thread-bindings.discord-api.ts index faac1cce4e8..2a59075cf46 100644 --- a/src/discord/monitor/thread-bindings.discord-api.ts +++ b/src/discord/monitor/thread-bindings.discord-api.ts @@ -1,4 +1,5 @@ import { ChannelType, Routes } from "discord-api-types/v10"; +import type { OpenClawConfig } from "../../config/config.js"; import { logVerbose } from "../../globals.js"; import { createDiscordRestClient } from "../client.js"; import { sendMessageDiscord, sendWebhookMessageDiscord } from "../send.js"; @@ -122,6 +123,7 @@ export function isDiscordThreadGoneError(err: unknown): boolean { } export async function maybeSendBindingMessage(params: { + cfg?: OpenClawConfig; record: ThreadBindingRecord; text: string; preferWebhook?: boolean; @@ -134,6 +136,7 @@ export async function maybeSendBindingMessage(params: { if (params.preferWebhook !== false && record.webhookId && record.webhookToken) { try { await sendWebhookMessageDiscord(text, { + cfg: params.cfg, webhookId: record.webhookId, webhookToken: record.webhookToken, accountId: record.accountId, @@ -147,6 +150,7 @@ export async function maybeSendBindingMessage(params: { } try { await sendMessageDiscord(buildThreadTarget(record.threadId), text, { + cfg: params.cfg, accountId: record.accountId, }); } catch (err) { @@ -155,15 +159,19 @@ export async function maybeSendBindingMessage(params: { } export async function createWebhookForChannel(params: { + cfg?: OpenClawConfig; accountId: string; token?: string; channelId: string; }): Promise<{ webhookId?: string; webhookToken?: string }> { try { - const rest = createDiscordRestClient({ - accountId: params.accountId, - token: params.token, - }).rest; + const rest = createDiscordRestClient( + { + accountId: params.accountId, + token: params.token, + }, + params.cfg, + ).rest; const created = (await rest.post(Routes.channelWebhooks(params.channelId), { body: { name: "OpenClaw Agents", @@ -218,6 +226,7 @@ export function findReusableWebhook(params: { accountId: string; channelId: stri } export async function resolveChannelIdForBinding(params: { + cfg?: OpenClawConfig; accountId: string; token?: string; threadId: string; @@ -228,10 +237,13 @@ export async function resolveChannelIdForBinding(params: { return explicit; } try { - const rest = createDiscordRestClient({ - accountId: params.accountId, - token: params.token, - }).rest; + const rest = createDiscordRestClient( + { + accountId: params.accountId, + token: params.token, + }, + params.cfg, + ).rest; const channel = (await rest.get(Routes.channel(params.threadId))) as { id?: string; type?: number; @@ -261,6 +273,7 @@ export async function resolveChannelIdForBinding(params: { } export async function createThreadForBinding(params: { + cfg?: OpenClawConfig; accountId: string; token?: string; channelId: string; @@ -274,6 +287,7 @@ export async function createThreadForBinding(params: { autoArchiveMinutes: 60, }, { + cfg: params.cfg, accountId: params.accountId, token: params.token, }, diff --git a/src/discord/monitor/thread-bindings.lifecycle.test.ts b/src/discord/monitor/thread-bindings.lifecycle.test.ts index b4eeb229f6f..6d37dcc1c2a 100644 --- a/src/discord/monitor/thread-bindings.lifecycle.test.ts +++ b/src/discord/monitor/thread-bindings.lifecycle.test.ts @@ -2,7 +2,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; +import { + clearRuntimeConfigSnapshot, + setRuntimeConfigSnapshot, + type OpenClawConfig, +} from "../../config/config.js"; const hoisted = vi.hoisted(() => { const sendMessageDiscord = vi.fn(async (_to: string, _text: string, _opts?: unknown) => ({})); @@ -68,6 +72,7 @@ const { describe("thread binding lifecycle", () => { beforeEach(() => { __testing.resetThreadBindingsForTests(); + clearRuntimeConfigSnapshot(); hoisted.sendMessageDiscord.mockClear(); hoisted.sendWebhookMessageDiscord.mockClear(); hoisted.restGet.mockClear(); @@ -627,9 +632,13 @@ describe("thread binding lifecycle", () => { }); it("passes manager token when resolving parent channels for auto-bind", async () => { + const cfg = { + channels: { discord: { token: "tok" } }, + } as OpenClawConfig; createThreadBindingManager({ accountId: "runtime", token: "runtime-token", + cfg, persist: false, enableSweeper: false, idleTimeoutMs: 24 * 60 * 60 * 1000, @@ -647,6 +656,7 @@ describe("thread binding lifecycle", () => { hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-runtime" }); const childBinding = await autoBindSpawnedDiscordSubagent({ + cfg, accountId: "runtime", channel: "discord", to: "channel:thread-runtime", @@ -662,6 +672,73 @@ describe("thread binding lifecycle", () => { accountId: "runtime", token: "runtime-token", }); + const usedCfg = hoisted.createDiscordRestClient.mock.calls.some((call) => { + if (call?.[1] === cfg) { + return true; + } + const first = call?.[0]; + return ( + typeof first === "object" && first !== null && (first as { cfg?: unknown }).cfg === cfg + ); + }); + expect(usedCfg).toBe(true); + }); + + it("uses the active runtime snapshot cfg for manager operations", async () => { + const startupCfg = { + channels: { discord: { token: "startup-token" } }, + } as OpenClawConfig; + const refreshedCfg = { + channels: { discord: { token: "refreshed-token" } }, + } as OpenClawConfig; + const manager = createThreadBindingManager({ + accountId: "runtime", + token: "runtime-token", + cfg: startupCfg, + persist: false, + enableSweeper: false, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + }); + + setRuntimeConfigSnapshot(refreshedCfg); + hoisted.createDiscordRestClient.mockClear(); + hoisted.createThreadDiscord.mockClear(); + hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-runtime-cfg" }); + + const bound = await manager.bindTarget({ + createThread: true, + channelId: "parent-runtime", + targetKind: "subagent", + targetSessionKey: "agent:main:subagent:runtime-cfg", + agentId: "main", + }); + + expect(bound).not.toBeNull(); + const usedRefreshedCfg = hoisted.createDiscordRestClient.mock.calls.some((call) => { + if (call?.[1] === refreshedCfg) { + return true; + } + const first = call?.[0]; + return ( + typeof first === "object" && + first !== null && + (first as { cfg?: unknown }).cfg === refreshedCfg + ); + }); + expect(usedRefreshedCfg).toBe(true); + const usedStartupCfg = hoisted.createDiscordRestClient.mock.calls.some((call) => { + if (call?.[1] === startupCfg) { + return true; + } + const first = call?.[0]; + return ( + typeof first === "object" && + first !== null && + (first as { cfg?: unknown }).cfg === startupCfg + ); + }); + expect(usedStartupCfg).toBe(false); }); it("refreshes manager token when an existing manager is reused", async () => { diff --git a/src/discord/monitor/thread-bindings.lifecycle.ts b/src/discord/monitor/thread-bindings.lifecycle.ts index f5beb9a3e6f..256ab5e249c 100644 --- a/src/discord/monitor/thread-bindings.lifecycle.ts +++ b/src/discord/monitor/thread-bindings.lifecycle.ts @@ -118,6 +118,7 @@ export function listThreadBindingsBySessionKey(params: { } export async function autoBindSpawnedDiscordSubagent(params: { + cfg?: OpenClawConfig; accountId?: string; channel?: string; to?: string; @@ -146,6 +147,7 @@ export async function autoBindSpawnedDiscordSubagent(params: { } else { channelId = (await resolveChannelIdForBinding({ + cfg: params.cfg, accountId: manager.accountId, token: managerToken, threadId: requesterThreadId, @@ -164,6 +166,7 @@ export async function autoBindSpawnedDiscordSubagent(params: { } channelId = (await resolveChannelIdForBinding({ + cfg: params.cfg, accountId: manager.accountId, token: managerToken, threadId: target.id, diff --git a/src/discord/monitor/thread-bindings.manager.ts b/src/discord/monitor/thread-bindings.manager.ts index 386d1adbc8c..43ee414c2a5 100644 --- a/src/discord/monitor/thread-bindings.manager.ts +++ b/src/discord/monitor/thread-bindings.manager.ts @@ -1,5 +1,6 @@ import { Routes } from "discord-api-types/v10"; import { resolveThreadBindingConversationIdFromBindingId } from "../../channels/thread-binding-id.js"; +import { getRuntimeConfigSnapshot, type OpenClawConfig } from "../../config/config.js"; import { logVerbose } from "../../globals.js"; import { registerSessionBindingAdapter, @@ -162,6 +163,7 @@ export function createThreadBindingManager( params: { accountId?: string; token?: string; + cfg?: OpenClawConfig; persist?: boolean; enableSweeper?: boolean; idleTimeoutMs?: number; @@ -188,6 +190,7 @@ export function createThreadBindingManager( params.maxAgeMs, DEFAULT_THREAD_BINDING_MAX_AGE_MS, ); + const resolveCurrentCfg = () => getRuntimeConfigSnapshot() ?? params.cfg; const resolveCurrentToken = () => getThreadBindingToken(accountId) ?? params.token; let sweepTimer: NodeJS.Timeout | null = null; @@ -255,6 +258,7 @@ export function createThreadBindingManager( return nextRecord; }, bindTarget: async (bindParams) => { + const cfg = resolveCurrentCfg(); let threadId = normalizeThreadId(bindParams.threadId); let channelId = bindParams.channelId?.trim() || ""; @@ -268,6 +272,7 @@ export function createThreadBindingManager( }); threadId = (await createThreadForBinding({ + cfg, accountId, token: resolveCurrentToken(), channelId, @@ -282,6 +287,7 @@ export function createThreadBindingManager( if (!channelId) { channelId = (await resolveChannelIdForBinding({ + cfg, accountId, token: resolveCurrentToken(), threadId, @@ -307,6 +313,7 @@ export function createThreadBindingManager( } if (!webhookId || !webhookToken) { const createdWebhook = await createWebhookForChannel({ + cfg, accountId, token: resolveCurrentToken(), channelId, @@ -340,7 +347,7 @@ export function createThreadBindingManager( const introText = bindParams.introText?.trim(); if (introText) { - void maybeSendBindingMessage({ record, text: introText }); + void maybeSendBindingMessage({ cfg, record, text: introText }); } return record; }, @@ -365,6 +372,7 @@ export function createThreadBindingManager( saveBindingsToDisk(); } if (unbindParams.sendFarewell !== false) { + const cfg = resolveCurrentCfg(); const farewell = resolveThreadBindingFarewellText({ reason: unbindParams.reason, farewellText: unbindParams.farewellText, @@ -379,7 +387,12 @@ export function createThreadBindingManager( }); // Use bot send path for farewell messages so unbound threads don't process // webhook echoes as fresh inbound turns when allowBots is enabled. - void maybeSendBindingMessage({ record: removed, text: farewell, preferWebhook: false }); + void maybeSendBindingMessage({ + cfg, + record: removed, + text: farewell, + preferWebhook: false, + }); } return removed; }, @@ -433,10 +446,14 @@ export function createThreadBindingManager( } let rest; try { - rest = createDiscordRestClient({ - accountId, - token: resolveCurrentToken(), - }).rest; + const cfg = resolveCurrentCfg(); + rest = createDiscordRestClient( + { + accountId, + token: resolveCurrentToken(), + }, + cfg, + ).rest; } catch { return; } @@ -561,8 +578,10 @@ export function createThreadBindingManager( if (placement === "child") { createThread = true; if (!channelId && conversationId) { + const cfg = resolveCurrentCfg(); channelId = (await resolveChannelIdForBinding({ + cfg, accountId, token: resolveCurrentToken(), threadId: conversationId, diff --git a/src/discord/monitor/threading.auto-thread.test.ts b/src/discord/monitor/threading.auto-thread.test.ts index 6228914bb39..2affabcae44 100644 --- a/src/discord/monitor/threading.auto-thread.test.ts +++ b/src/discord/monitor/threading.auto-thread.test.ts @@ -1,5 +1,5 @@ import { ChannelType } from "@buape/carbon"; -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { maybeCreateDiscordAutoThread } from "./threading.js"; describe("maybeCreateDiscordAutoThread", () => { @@ -89,3 +89,74 @@ describe("maybeCreateDiscordAutoThread", () => { expect(postMock).toHaveBeenCalled(); }); }); + +describe("maybeCreateDiscordAutoThread autoArchiveDuration", () => { + const postMock = vi.fn(); + const getMock = vi.fn(); + const mockClient = { + rest: { post: postMock, get: getMock }, + } as unknown as Parameters[0]["client"]; + const mockMessage = { + id: "msg1", + timestamp: "123", + } as unknown as Parameters[0]["message"]; + + beforeEach(() => { + postMock.mockReset(); + getMock.mockReset(); + }); + + it("uses configured autoArchiveDuration", async () => { + postMock.mockResolvedValueOnce({ id: "thread1" }); + await maybeCreateDiscordAutoThread({ + client: mockClient, + message: mockMessage, + messageChannelId: "text1", + isGuildMessage: true, + channelConfig: { allowed: true, autoThread: true, autoArchiveDuration: "10080" }, + channelType: ChannelType.GuildText, + baseText: "test", + combinedBody: "test", + }); + expect(postMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ body: expect.objectContaining({ auto_archive_duration: 10080 }) }), + ); + }); + + it("accepts numeric autoArchiveDuration", async () => { + postMock.mockResolvedValueOnce({ id: "thread1" }); + await maybeCreateDiscordAutoThread({ + client: mockClient, + message: mockMessage, + messageChannelId: "text1", + isGuildMessage: true, + channelConfig: { allowed: true, autoThread: true, autoArchiveDuration: 4320 }, + channelType: ChannelType.GuildText, + baseText: "test", + combinedBody: "test", + }); + expect(postMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ body: expect.objectContaining({ auto_archive_duration: 4320 }) }), + ); + }); + + it("defaults to 60 when autoArchiveDuration not set", async () => { + postMock.mockResolvedValueOnce({ id: "thread1" }); + await maybeCreateDiscordAutoThread({ + client: mockClient, + message: mockMessage, + messageChannelId: "text1", + isGuildMessage: true, + channelConfig: { allowed: true, autoThread: true }, + channelType: ChannelType.GuildText, + baseText: "test", + combinedBody: "test", + }); + expect(postMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ body: expect.objectContaining({ auto_archive_duration: 60 }) }), + ); + }); +}); diff --git a/src/discord/monitor/threading.ts b/src/discord/monitor/threading.ts index 14377d8e644..28897e9b7aa 100644 --- a/src/discord/monitor/threading.ts +++ b/src/discord/monitor/threading.ts @@ -397,12 +397,18 @@ export async function maybeCreateDiscordAutoThread(params: { params.baseText || params.combinedBody || "Thread", params.message.id, ); + + // Parse archive duration from config, default to 60 minutes + const archiveDuration = params.channelConfig?.autoArchiveDuration + ? Number(params.channelConfig.autoArchiveDuration) + : 60; + const created = (await params.client.rest.post( `${Routes.channelMessage(messageChannelId, params.message.id)}/threads`, { body: { name: threadName, - auto_archive_duration: 60, + auto_archive_duration: archiveDuration, }, }, )) as { id?: string }; diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index 04ddc5027d4..eb081520a0f 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -7,7 +7,6 @@ const wsInstances = vi.hoisted((): MockWebSocket[] => []); const clearDeviceAuthTokenMock = vi.hoisted(() => vi.fn()); const loadDeviceAuthTokenMock = vi.hoisted(() => vi.fn()); const storeDeviceAuthTokenMock = vi.hoisted(() => vi.fn()); -const clearDevicePairingMock = vi.hoisted(() => vi.fn()); const logDebugMock = vi.hoisted(() => vi.fn()); type WsEvent = "open" | "message" | "close" | "error"; @@ -52,7 +51,9 @@ class MockWebSocket { } } - close(_code?: number, _reason?: string): void {} + close(code?: number, reason?: string): void { + this.emitClose(code ?? 1000, reason ?? ""); + } send(data: string): void { this.sent.push(data); @@ -91,14 +92,6 @@ vi.mock("../infra/device-auth-store.js", async (importOriginal) => { }; }); -vi.mock("../infra/device-pairing.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - clearDevicePairing: (...args: unknown[]) => clearDevicePairingMock(...args), - }; -}); - vi.mock("../logger.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -250,8 +243,6 @@ describe("GatewayClient close handling", () => { wsInstances.length = 0; clearDeviceAuthTokenMock.mockClear(); clearDeviceAuthTokenMock.mockImplementation(() => undefined); - clearDevicePairingMock.mockClear(); - clearDevicePairingMock.mockResolvedValue(true); logDebugMock.mockClear(); }); @@ -266,7 +257,7 @@ describe("GatewayClient close handling", () => { ); expect(clearDeviceAuthTokenMock).toHaveBeenCalledWith({ deviceId: "dev-1", role: "operator" }); - expect(clearDevicePairingMock).toHaveBeenCalledWith("dev-1"); + expect(logDebugMock).toHaveBeenCalledWith("cleared stale device-auth token for device dev-1"); expect(onClose).toHaveBeenCalledWith( 1008, "unauthorized: DEVICE token mismatch (rotate/reissue device token)", @@ -289,38 +280,18 @@ describe("GatewayClient close handling", () => { expect(logDebugMock).toHaveBeenCalledWith( expect.stringContaining("failed clearing stale device-auth token"), ); - expect(clearDevicePairingMock).not.toHaveBeenCalled(); - expect(onClose).toHaveBeenCalledWith(1008, "unauthorized: device token mismatch"); - client.stop(); - }); - - it("does not break close flow when pairing clear rejects", async () => { - clearDevicePairingMock.mockRejectedValue(new Error("pairing store unavailable")); - const onClose = vi.fn(); - const client = createClientWithIdentity("dev-3", onClose); - - client.start(); - expect(() => { - getLatestWs().emitClose(1008, "unauthorized: device token mismatch"); - }).not.toThrow(); - - await Promise.resolve(); - expect(logDebugMock).toHaveBeenCalledWith( - expect.stringContaining("failed clearing stale device pairing"), - ); expect(onClose).toHaveBeenCalledWith(1008, "unauthorized: device token mismatch"); client.stop(); }); it("does not clear auth state for non-mismatch close reasons", () => { const onClose = vi.fn(); - const client = createClientWithIdentity("dev-4", onClose); + const client = createClientWithIdentity("dev-3", onClose); client.start(); getLatestWs().emitClose(1008, "unauthorized: signature invalid"); expect(clearDeviceAuthTokenMock).not.toHaveBeenCalled(); - expect(clearDevicePairingMock).not.toHaveBeenCalled(); expect(onClose).toHaveBeenCalledWith(1008, "unauthorized: signature invalid"); client.stop(); }); @@ -328,7 +299,7 @@ describe("GatewayClient close handling", () => { it("does not clear persisted device auth when explicit shared token is provided", () => { const onClose = vi.fn(); const identity: DeviceIdentity = { - deviceId: "dev-5", + deviceId: "dev-4", privateKeyPem: "private-key", // pragma: allowlist secret publicKeyPem: "public-key", }; @@ -343,7 +314,6 @@ describe("GatewayClient close handling", () => { getLatestWs().emitClose(1008, "unauthorized: device token mismatch"); expect(clearDeviceAuthTokenMock).not.toHaveBeenCalled(); - expect(clearDevicePairingMock).not.toHaveBeenCalled(); expect(onClose).toHaveBeenCalledWith(1008, "unauthorized: device token mismatch"); client.stop(); }); @@ -458,4 +428,156 @@ describe("GatewayClient connect auth payload", () => { }); client.stop(); }); + + it("retries with stored device token after shared-token mismatch on trusted endpoints", async () => { + loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" }); + const client = new GatewayClient({ + url: "ws://127.0.0.1:18789", + token: "shared-token", + }); + + client.start(); + const ws1 = getLatestWs(); + ws1.emitOpen(); + emitConnectChallenge(ws1); + const firstConnectRaw = ws1.sent.find((frame) => frame.includes('"method":"connect"')); + expect(firstConnectRaw).toBeTruthy(); + const firstConnect = JSON.parse(firstConnectRaw ?? "{}") as { + id?: string; + params?: { auth?: { token?: string; deviceToken?: string } }; + }; + expect(firstConnect.params?.auth?.token).toBe("shared-token"); + expect(firstConnect.params?.auth?.deviceToken).toBeUndefined(); + + ws1.emitMessage( + JSON.stringify({ + type: "res", + id: firstConnect.id, + ok: false, + error: { + code: "INVALID_REQUEST", + message: "unauthorized", + details: { code: "AUTH_TOKEN_MISMATCH", canRetryWithDeviceToken: true }, + }, + }), + ); + + await vi.waitFor(() => expect(wsInstances.length).toBeGreaterThan(1), { timeout: 3_000 }); + const ws2 = getLatestWs(); + ws2.emitOpen(); + emitConnectChallenge(ws2, "nonce-2"); + expect(connectFrameFrom(ws2)).toMatchObject({ + token: "shared-token", + deviceToken: "stored-device-token", + }); + client.stop(); + }); + + it("retries with stored device token when server recommends retry_with_device_token", async () => { + loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" }); + const client = new GatewayClient({ + url: "ws://127.0.0.1:18789", + token: "shared-token", + }); + + client.start(); + const ws1 = getLatestWs(); + ws1.emitOpen(); + emitConnectChallenge(ws1); + const firstConnectRaw = ws1.sent.find((frame) => frame.includes('"method":"connect"')); + expect(firstConnectRaw).toBeTruthy(); + const firstConnect = JSON.parse(firstConnectRaw ?? "{}") as { id?: string }; + + ws1.emitMessage( + JSON.stringify({ + type: "res", + id: firstConnect.id, + ok: false, + error: { + code: "INVALID_REQUEST", + message: "unauthorized", + details: { code: "AUTH_UNAUTHORIZED", recommendedNextStep: "retry_with_device_token" }, + }, + }), + ); + + await vi.waitFor(() => expect(wsInstances.length).toBeGreaterThan(1), { timeout: 3_000 }); + const ws2 = getLatestWs(); + ws2.emitOpen(); + emitConnectChallenge(ws2, "nonce-2"); + expect(connectFrameFrom(ws2)).toMatchObject({ + token: "shared-token", + deviceToken: "stored-device-token", + }); + client.stop(); + }); + + it("does not auto-reconnect on AUTH_TOKEN_MISSING connect failures", async () => { + vi.useFakeTimers(); + const client = new GatewayClient({ + url: "ws://127.0.0.1:18789", + token: "shared-token", + }); + + client.start(); + const ws1 = getLatestWs(); + ws1.emitOpen(); + emitConnectChallenge(ws1); + const firstConnectRaw = ws1.sent.find((frame) => frame.includes('"method":"connect"')); + expect(firstConnectRaw).toBeTruthy(); + const firstConnect = JSON.parse(firstConnectRaw ?? "{}") as { id?: string }; + + ws1.emitMessage( + JSON.stringify({ + type: "res", + id: firstConnect.id, + ok: false, + error: { + code: "INVALID_REQUEST", + message: "unauthorized", + details: { code: "AUTH_TOKEN_MISSING" }, + }, + }), + ); + + await vi.advanceTimersByTimeAsync(30_000); + expect(wsInstances).toHaveLength(1); + client.stop(); + vi.useRealTimers(); + }); + + it("does not auto-reconnect on token mismatch when retry is not trusted", async () => { + vi.useFakeTimers(); + loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" }); + const client = new GatewayClient({ + url: "wss://gateway.example.com:18789", + token: "shared-token", + }); + + client.start(); + const ws1 = getLatestWs(); + ws1.emitOpen(); + emitConnectChallenge(ws1); + const firstConnectRaw = ws1.sent.find((frame) => frame.includes('"method":"connect"')); + expect(firstConnectRaw).toBeTruthy(); + const firstConnect = JSON.parse(firstConnectRaw ?? "{}") as { id?: string }; + + ws1.emitMessage( + JSON.stringify({ + type: "res", + id: firstConnect.id, + ok: false, + error: { + code: "INVALID_REQUEST", + message: "unauthorized", + details: { code: "AUTH_TOKEN_MISMATCH", canRetryWithDeviceToken: true }, + }, + }), + ); + + await vi.advanceTimersByTimeAsync(30_000); + expect(wsInstances).toHaveLength(1); + client.stop(); + vi.useRealTimers(); + }); }); diff --git a/src/gateway/client.ts b/src/gateway/client.ts index 4641545ea8e..489347e54f9 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -11,7 +11,6 @@ import { publicKeyRawBase64UrlFromPem, signDevicePayload, } from "../infra/device-identity.js"; -import { clearDevicePairing } from "../infra/device-pairing.js"; import { normalizeFingerprint } from "../infra/tls/fingerprint.js"; import { rawDataToString } from "../infra/ws.js"; import { logDebug, logError } from "../logger.js"; @@ -23,7 +22,13 @@ import { } from "../utils/message-channel.js"; import { VERSION } from "../version.js"; import { buildDeviceAuthPayloadV3 } from "./device-auth.js"; -import { isSecureWebSocketUrl } from "./net.js"; +import { isLoopbackHost, isSecureWebSocketUrl } from "./net.js"; +import { + ConnectErrorDetailCodes, + readConnectErrorDetailCode, + readConnectErrorRecoveryAdvice, + type ConnectErrorRecoveryAdvice, +} from "./protocol/connect-error-details.js"; import { type ConnectParams, type EventFrame, @@ -41,6 +46,24 @@ type Pending = { expectFinal: boolean; }; +type GatewayClientErrorShape = { + code?: string; + message?: string; + details?: unknown; +}; + +class GatewayClientRequestError extends Error { + readonly gatewayCode: string; + readonly details?: unknown; + + constructor(error: GatewayClientErrorShape) { + super(error.message ?? "gateway request failed"); + this.name = "GatewayClientRequestError"; + this.gatewayCode = error.code ?? "UNAVAILABLE"; + this.details = error.details; + } +} + export type GatewayClientOptions = { url?: string; // ws://127.0.0.1:18789 connectDelayMs?: number; @@ -93,6 +116,9 @@ export class GatewayClient { private connectNonce: string | null = null; private connectSent = false; private connectTimer: NodeJS.Timeout | null = null; + private pendingDeviceTokenRetry = false; + private deviceTokenRetryBudgetUsed = false; + private pendingConnectErrorDetailCode: string | null = null; // Track last tick to detect silent stalls. private lastTick: number | null = null; private tickIntervalMs = 30_000; @@ -184,6 +210,8 @@ export class GatewayClient { this.ws.on("message", (data) => this.handleMessage(rawDataToString(data))); this.ws.on("close", (code, reason) => { const reasonText = rawDataToString(reason); + const connectErrorDetailCode = this.pendingConnectErrorDetailCode; + this.pendingConnectErrorDetailCode = null; this.ws = null; // Clear persisted device auth state only when device-token auth was active. // Shared token/password failures can return the same close reason but should @@ -199,9 +227,6 @@ export class GatewayClient { const role = this.opts.role ?? "operator"; try { clearDeviceAuthToken({ deviceId, role }); - void clearDevicePairing(deviceId).catch((err) => { - logDebug(`failed clearing stale device pairing for device ${deviceId}: ${String(err)}`); - }); logDebug(`cleared stale device-auth token for device ${deviceId}`); } catch (err) { logDebug( @@ -210,6 +235,10 @@ export class GatewayClient { } } this.flushPendingErrors(new Error(`gateway closed (${code}): ${reasonText}`)); + if (this.shouldPauseReconnectAfterAuthFailure(connectErrorDetailCode)) { + this.opts.onClose?.(code, reasonText); + return; + } this.scheduleReconnect(); this.opts.onClose?.(code, reasonText); }); @@ -223,6 +252,9 @@ export class GatewayClient { stop() { this.closed = true; + this.pendingDeviceTokenRetry = false; + this.deviceTokenRetryBudgetUsed = false; + this.pendingConnectErrorDetailCode = null; if (this.tickTimer) { clearInterval(this.tickTimer); this.tickTimer = null; @@ -253,11 +285,20 @@ export class GatewayClient { const storedToken = this.opts.deviceIdentity ? loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role })?.token : null; + const shouldUseDeviceRetryToken = + this.pendingDeviceTokenRetry && + !explicitDeviceToken && + Boolean(explicitGatewayToken) && + Boolean(storedToken) && + this.isTrustedDeviceRetryEndpoint(); + if (shouldUseDeviceRetryToken) { + this.pendingDeviceTokenRetry = false; + } // Keep shared gateway credentials explicit. Persisted per-device tokens only // participate when no explicit shared token/password is provided. const resolvedDeviceToken = explicitDeviceToken ?? - (!(explicitGatewayToken || this.opts.password?.trim()) + (shouldUseDeviceRetryToken || !(explicitGatewayToken || this.opts.password?.trim()) ? (storedToken ?? undefined) : undefined); // Legacy compatibility: keep `auth.token` populated for device-token auth when @@ -327,6 +368,9 @@ export class GatewayClient { void this.request("connect", params) .then((helloOk) => { + this.pendingDeviceTokenRetry = false; + this.deviceTokenRetryBudgetUsed = false; + this.pendingConnectErrorDetailCode = null; const authInfo = helloOk?.auth; if (authInfo?.deviceToken && this.opts.deviceIdentity) { storeDeviceAuthToken({ @@ -346,6 +390,19 @@ export class GatewayClient { this.opts.onHelloOk?.(helloOk); }) .catch((err) => { + this.pendingConnectErrorDetailCode = + err instanceof GatewayClientRequestError ? readConnectErrorDetailCode(err.details) : null; + const shouldRetryWithDeviceToken = this.shouldRetryWithStoredDeviceToken({ + error: err, + explicitGatewayToken, + resolvedDeviceToken, + storedToken: storedToken ?? undefined, + }); + if (shouldRetryWithDeviceToken) { + this.pendingDeviceTokenRetry = true; + this.deviceTokenRetryBudgetUsed = true; + this.backoffMs = Math.min(this.backoffMs, 250); + } this.opts.onConnectError?.(err instanceof Error ? err : new Error(String(err))); const msg = `gateway connect failed: ${String(err)}`; if (this.opts.mode === GATEWAY_CLIENT_MODES.PROBE) { @@ -357,6 +414,86 @@ export class GatewayClient { }); } + private shouldPauseReconnectAfterAuthFailure(detailCode: string | null): boolean { + if (!detailCode) { + return false; + } + if ( + detailCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISSING || + detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING || + detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH || + detailCode === ConnectErrorDetailCodes.AUTH_RATE_LIMITED || + detailCode === ConnectErrorDetailCodes.PAIRING_REQUIRED || + detailCode === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED || + detailCode === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED + ) { + return true; + } + if (detailCode !== ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH) { + return false; + } + if (this.pendingDeviceTokenRetry) { + return false; + } + // If the endpoint is not trusted for retry, mismatch is terminal until operator action. + if (!this.isTrustedDeviceRetryEndpoint()) { + return true; + } + // Pause mismatch reconnect loops once the one-shot device-token retry is consumed. + return this.deviceTokenRetryBudgetUsed; + } + + private shouldRetryWithStoredDeviceToken(params: { + error: unknown; + explicitGatewayToken?: string; + storedToken?: string; + resolvedDeviceToken?: string; + }): boolean { + if (this.deviceTokenRetryBudgetUsed) { + return false; + } + if (params.resolvedDeviceToken) { + return false; + } + if (!params.explicitGatewayToken || !params.storedToken) { + return false; + } + if (!this.isTrustedDeviceRetryEndpoint()) { + return false; + } + if (!(params.error instanceof GatewayClientRequestError)) { + return false; + } + const detailCode = readConnectErrorDetailCode(params.error.details); + const advice: ConnectErrorRecoveryAdvice = readConnectErrorRecoveryAdvice(params.error.details); + const retryWithDeviceTokenRecommended = + advice.recommendedNextStep === "retry_with_device_token"; + return ( + advice.canRetryWithDeviceToken === true || + retryWithDeviceTokenRecommended || + detailCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH + ); + } + + private isTrustedDeviceRetryEndpoint(): boolean { + const rawUrl = this.opts.url ?? "ws://127.0.0.1:18789"; + try { + const parsed = new URL(rawUrl); + const protocol = + parsed.protocol === "https:" + ? "wss:" + : parsed.protocol === "http:" + ? "ws:" + : parsed.protocol; + if (isLoopbackHost(parsed.hostname)) { + return true; + } + return protocol === "wss:" && Boolean(this.opts.tlsFingerprint?.trim()); + } catch { + return false; + } + } + private handleMessage(raw: string) { try { const parsed = JSON.parse(raw); @@ -402,7 +539,13 @@ export class GatewayClient { if (parsed.ok) { pending.resolve(parsed.payload); } else { - pending.reject(new Error(parsed.error?.message ?? "unknown error")); + pending.reject( + new GatewayClientRequestError({ + code: parsed.error?.code, + message: parsed.error?.message ?? "unknown error", + details: parsed.error?.details, + }), + ); } } } catch (err) { diff --git a/src/gateway/operator-approvals-client.ts b/src/gateway/operator-approvals-client.ts new file mode 100644 index 00000000000..82ea7c80413 --- /dev/null +++ b/src/gateway/operator-approvals-client.ts @@ -0,0 +1,46 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; +import { buildGatewayConnectionDetails } from "./call.js"; +import { GatewayClient, type GatewayClientOptions } from "./client.js"; +import { resolveGatewayConnectionAuth } from "./connection-auth.js"; + +export async function createOperatorApprovalsGatewayClient( + params: Pick< + GatewayClientOptions, + "clientDisplayName" | "onClose" | "onConnectError" | "onEvent" | "onHelloOk" + > & { + config: OpenClawConfig; + gatewayUrl?: string; + }, +): Promise { + const { url: gatewayUrl, urlSource } = buildGatewayConnectionDetails({ + config: params.config, + url: params.gatewayUrl, + }); + const gatewayUrlOverrideSource = + urlSource === "cli --url" + ? "cli" + : urlSource === "env OPENCLAW_GATEWAY_URL" + ? "env" + : undefined; + const auth = await resolveGatewayConnectionAuth({ + config: params.config, + env: process.env, + urlOverride: gatewayUrlOverrideSource ? gatewayUrl : undefined, + urlOverrideSource: gatewayUrlOverrideSource, + }); + + return new GatewayClient({ + url: gatewayUrl, + token: auth.token, + password: auth.password, + clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, + clientDisplayName: params.clientDisplayName, + mode: GATEWAY_CLIENT_MODES.BACKEND, + scopes: ["operator.approvals"], + onEvent: params.onEvent, + onHelloOk: params.onHelloOk, + onConnectError: params.onConnectError, + onClose: params.onClose, + }); +} diff --git a/src/gateway/protocol/connect-error-details.test.ts b/src/gateway/protocol/connect-error-details.test.ts new file mode 100644 index 00000000000..2a7a2c53979 --- /dev/null +++ b/src/gateway/protocol/connect-error-details.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { + readConnectErrorDetailCode, + readConnectErrorRecoveryAdvice, +} from "./connect-error-details.js"; + +describe("readConnectErrorDetailCode", () => { + it("reads structured detail codes", () => { + expect(readConnectErrorDetailCode({ code: "AUTH_TOKEN_MISMATCH" })).toBe("AUTH_TOKEN_MISMATCH"); + }); + + it("returns null for invalid detail payloads", () => { + expect(readConnectErrorDetailCode(null)).toBeNull(); + expect(readConnectErrorDetailCode("AUTH_TOKEN_MISMATCH")).toBeNull(); + }); +}); + +describe("readConnectErrorRecoveryAdvice", () => { + it("reads retry advice fields when present", () => { + expect( + readConnectErrorRecoveryAdvice({ + canRetryWithDeviceToken: true, + recommendedNextStep: "retry_with_device_token", + }), + ).toEqual({ + canRetryWithDeviceToken: true, + recommendedNextStep: "retry_with_device_token", + }); + }); + + it("returns empty advice for invalid payloads", () => { + expect(readConnectErrorRecoveryAdvice(null)).toEqual({}); + expect(readConnectErrorRecoveryAdvice("x")).toEqual({}); + expect(readConnectErrorRecoveryAdvice({ canRetryWithDeviceToken: "yes" })).toEqual({}); + expect( + readConnectErrorRecoveryAdvice({ + canRetryWithDeviceToken: true, + recommendedNextStep: "retry_with_magic", + }), + ).toEqual({ canRetryWithDeviceToken: true, recommendedNextStep: undefined }); + }); +}); diff --git a/src/gateway/protocol/connect-error-details.ts b/src/gateway/protocol/connect-error-details.ts index 442e8f2c54d..298241c623f 100644 --- a/src/gateway/protocol/connect-error-details.ts +++ b/src/gateway/protocol/connect-error-details.ts @@ -28,6 +28,26 @@ export const ConnectErrorDetailCodes = { export type ConnectErrorDetailCode = (typeof ConnectErrorDetailCodes)[keyof typeof ConnectErrorDetailCodes]; +export type ConnectRecoveryNextStep = + | "retry_with_device_token" + | "update_auth_configuration" + | "update_auth_credentials" + | "wait_then_retry" + | "review_auth_configuration"; + +export type ConnectErrorRecoveryAdvice = { + canRetryWithDeviceToken?: boolean; + recommendedNextStep?: ConnectRecoveryNextStep; +}; + +const CONNECT_RECOVERY_NEXT_STEP_VALUES: ReadonlySet = new Set([ + "retry_with_device_token", + "update_auth_configuration", + "update_auth_credentials", + "wait_then_retry", + "review_auth_configuration", +]); + export function resolveAuthConnectErrorDetailCode( reason: string | undefined, ): ConnectErrorDetailCode { @@ -91,3 +111,26 @@ export function readConnectErrorDetailCode(details: unknown): string | null { const code = (details as { code?: unknown }).code; return typeof code === "string" && code.trim().length > 0 ? code : null; } + +export function readConnectErrorRecoveryAdvice(details: unknown): ConnectErrorRecoveryAdvice { + if (!details || typeof details !== "object" || Array.isArray(details)) { + return {}; + } + const raw = details as { + canRetryWithDeviceToken?: unknown; + recommendedNextStep?: unknown; + }; + const canRetryWithDeviceToken = + typeof raw.canRetryWithDeviceToken === "boolean" ? raw.canRetryWithDeviceToken : undefined; + const normalizedNextStep = + typeof raw.recommendedNextStep === "string" ? raw.recommendedNextStep.trim() : ""; + const recommendedNextStep = CONNECT_RECOVERY_NEXT_STEP_VALUES.has( + normalizedNextStep as ConnectRecoveryNextStep, + ) + ? (normalizedNextStep as ConnectRecoveryNextStep) + : undefined; + return { + canRetryWithDeviceToken, + recommendedNextStep, + }; +} diff --git a/src/gateway/protocol/primitives.secretref.test.ts b/src/gateway/protocol/primitives.secretref.test.ts new file mode 100644 index 00000000000..67f8304d48e --- /dev/null +++ b/src/gateway/protocol/primitives.secretref.test.ts @@ -0,0 +1,34 @@ +import AjvPkg from "ajv"; +import { describe, expect, it } from "vitest"; +import { + INVALID_EXEC_SECRET_REF_IDS, + VALID_EXEC_SECRET_REF_IDS, +} from "../../test-utils/secret-ref-test-vectors.js"; +import { SecretInputSchema, SecretRefSchema } from "./schema/primitives.js"; + +describe("gateway protocol SecretRef schema", () => { + const Ajv = AjvPkg as unknown as new (opts?: object) => import("ajv").default; + const ajv = new Ajv({ allErrors: true, strict: false }); + const validateSecretRef = ajv.compile(SecretRefSchema); + const validateSecretInput = ajv.compile(SecretInputSchema); + + it("accepts valid source-specific refs", () => { + expect(validateSecretRef({ source: "env", provider: "default", id: "OPENAI_API_KEY" })).toBe( + true, + ); + expect( + validateSecretRef({ source: "file", provider: "filemain", id: "/providers/openai/apiKey" }), + ).toBe(true); + for (const id of VALID_EXEC_SECRET_REF_IDS) { + expect(validateSecretRef({ source: "exec", provider: "vault", id }), id).toBe(true); + expect(validateSecretInput({ source: "exec", provider: "vault", id }), id).toBe(true); + } + }); + + it("rejects invalid exec refs", () => { + for (const id of INVALID_EXEC_SECRET_REF_IDS) { + expect(validateSecretRef({ source: "exec", provider: "vault", id }), id).toBe(false); + expect(validateSecretInput({ source: "exec", provider: "vault", id }), id).toBe(false); + } + }); +}); diff --git a/src/gateway/protocol/schema/primitives.ts b/src/gateway/protocol/schema/primitives.ts index 2268d1bde50..6ac6a71b64a 100644 --- a/src/gateway/protocol/schema/primitives.ts +++ b/src/gateway/protocol/schema/primitives.ts @@ -1,4 +1,10 @@ import { Type } from "@sinclair/typebox"; +import { ENV_SECRET_REF_ID_RE } from "../../../config/types.secrets.js"; +import { + EXEC_SECRET_REF_ID_JSON_SCHEMA_PATTERN, + FILE_SECRET_REF_ID_PATTERN, + SECRET_PROVIDER_ALIAS_PATTERN, +} from "../../../secrets/ref-contract.js"; import { SESSION_LABEL_MAX_LENGTH } from "../../../sessions/session-label.js"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../client-info.js"; @@ -27,13 +33,41 @@ export const SecretRefSourceSchema = Type.Union([ Type.Literal("exec"), ]); -export const SecretRefSchema = Type.Object( +const SecretProviderAliasString = Type.String({ + pattern: SECRET_PROVIDER_ALIAS_PATTERN.source, +}); + +const EnvSecretRefSchema = Type.Object( { - source: SecretRefSourceSchema, - provider: NonEmptyString, - id: NonEmptyString, + source: Type.Literal("env"), + provider: SecretProviderAliasString, + id: Type.String({ pattern: ENV_SECRET_REF_ID_RE.source }), }, { additionalProperties: false }, ); +const FileSecretRefSchema = Type.Object( + { + source: Type.Literal("file"), + provider: SecretProviderAliasString, + id: Type.String({ pattern: FILE_SECRET_REF_ID_PATTERN.source }), + }, + { additionalProperties: false }, +); + +const ExecSecretRefSchema = Type.Object( + { + source: Type.Literal("exec"), + provider: SecretProviderAliasString, + id: Type.String({ pattern: EXEC_SECRET_REF_ID_JSON_SCHEMA_PATTERN }), + }, + { additionalProperties: false }, +); + +export const SecretRefSchema = Type.Union([ + EnvSecretRefSchema, + FileSecretRefSchema, + ExecSecretRefSchema, +]); + export const SecretInputSchema = Type.Union([Type.String(), SecretRefSchema]); diff --git a/src/gateway/reconnect-gating.test.ts b/src/gateway/reconnect-gating.test.ts index 3ea02e21820..d073cc59c3f 100644 --- a/src/gateway/reconnect-gating.test.ts +++ b/src/gateway/reconnect-gating.test.ts @@ -39,9 +39,15 @@ describe("isNonRecoverableAuthError", () => { ); }); + it("blocks reconnect for PAIRING_REQUIRED", () => { + expect(isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.PAIRING_REQUIRED))).toBe( + true, + ); + }); + it("allows reconnect for AUTH_TOKEN_MISMATCH (device-token fallback flow)", () => { - // Browser client fallback: stale device token → mismatch → sendConnect() clears it → - // next reconnect uses opts.token (shared gateway token). Blocking here breaks recovery. + // Browser client can queue a single trusted-device retry after shared token mismatch. + // Blocking reconnect on mismatch here would skip that bounded recovery attempt. expect(isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH))).toBe( false, ); diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index bd8f6b57ac2..83bf3057278 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -128,6 +128,19 @@ function migrateAndPruneSessionStoreKey(params: { return { target, primaryKey, entry: params.store[primaryKey] }; } +function stripRuntimeModelState(entry?: SessionEntry): SessionEntry | undefined { + if (!entry) { + return entry; + } + return { + ...entry, + model: undefined, + modelProvider: undefined, + contextTokens: undefined, + systemPromptReport: undefined, + }; +} + function archiveSessionTranscriptsForSession(params: { sessionId: string | undefined; storePath: string; @@ -507,9 +520,10 @@ export const sessionsHandlers: GatewayRequestHandlers = { const next = await updateSessionStore(storePath, (store) => { const { primaryKey } = migrateAndPruneSessionStoreKey({ cfg, key, store }); const entry = store[primaryKey]; + const resetEntry = stripRuntimeModelState(entry); const parsed = parseAgentSessionKey(primaryKey); const sessionAgentId = normalizeAgentId(parsed?.agentId ?? resolveDefaultAgentId(cfg)); - const resolvedModel = resolveSessionModelRef(cfg, entry, sessionAgentId); + const resolvedModel = resolveSessionModelRef(cfg, resetEntry, sessionAgentId); oldSessionId = entry?.sessionId; oldSessionFile = entry?.sessionFile; const now = Date.now(); @@ -524,7 +538,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { responseUsage: entry?.responseUsage, model: resolvedModel.model, modelProvider: resolvedModel.provider, - contextTokens: entry?.contextTokens, + contextTokens: resetEntry?.contextTokens, sendPolicy: entry?.sendPolicy, label: entry?.label, origin: snapshotSessionOrigin(entry), diff --git a/src/gateway/server.auth.compat-baseline.test.ts b/src/gateway/server.auth.compat-baseline.test.ts new file mode 100644 index 00000000000..d63b62b8b88 --- /dev/null +++ b/src/gateway/server.auth.compat-baseline.test.ts @@ -0,0 +1,196 @@ +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { + connectReq, + CONTROL_UI_CLIENT, + ConnectErrorDetailCodes, + getFreePort, + openWs, + originForPort, + restoreGatewayToken, + startGatewayServer, + testState, +} from "./server.auth.shared.js"; + +function expectAuthErrorDetails(params: { + details: unknown; + expectedCode: string; + canRetryWithDeviceToken?: boolean; + recommendedNextStep?: string; +}) { + const details = params.details as + | { + code?: string; + canRetryWithDeviceToken?: boolean; + recommendedNextStep?: string; + } + | undefined; + expect(details?.code).toBe(params.expectedCode); + if (params.canRetryWithDeviceToken !== undefined) { + expect(details?.canRetryWithDeviceToken).toBe(params.canRetryWithDeviceToken); + } + if (params.recommendedNextStep !== undefined) { + expect(details?.recommendedNextStep).toBe(params.recommendedNextStep); + } +} + +describe("gateway auth compatibility baseline", () => { + describe("token mode", () => { + let server: Awaited>; + let port = 0; + let prevToken: string | undefined; + + beforeAll(async () => { + prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; + testState.gatewayAuth = { mode: "token", token: "secret" }; + process.env.OPENCLAW_GATEWAY_TOKEN = "secret"; + port = await getFreePort(); + server = await startGatewayServer(port); + }); + + afterAll(async () => { + await server.close(); + restoreGatewayToken(prevToken); + }); + + test("keeps valid shared-token connect behavior unchanged", async () => { + const ws = await openWs(port); + try { + const res = await connectReq(ws, { token: "secret" }); + expect(res.ok).toBe(true); + } finally { + ws.close(); + } + }); + + test("returns stable token-missing details for control ui without token", async () => { + const ws = await openWs(port, { origin: originForPort(port) }); + try { + const res = await connectReq(ws, { + skipDefaultAuth: true, + client: { ...CONTROL_UI_CLIENT }, + }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("Control UI settings"); + expectAuthErrorDetails({ + details: res.error?.details, + expectedCode: ConnectErrorDetailCodes.AUTH_TOKEN_MISSING, + canRetryWithDeviceToken: false, + recommendedNextStep: "update_auth_configuration", + }); + } finally { + ws.close(); + } + }); + + test("provides one-time retry hint for shared token mismatches", async () => { + const ws = await openWs(port); + try { + const res = await connectReq(ws, { token: "wrong" }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("gateway token mismatch"); + expectAuthErrorDetails({ + details: res.error?.details, + expectedCode: ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH, + canRetryWithDeviceToken: true, + recommendedNextStep: "retry_with_device_token", + }); + } finally { + ws.close(); + } + }); + + test("keeps explicit device token mismatch semantics stable", async () => { + const ws = await openWs(port); + try { + const res = await connectReq(ws, { + skipDefaultAuth: true, + deviceToken: "not-a-valid-device-token", + }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("device token mismatch"); + expectAuthErrorDetails({ + details: res.error?.details, + expectedCode: ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH, + canRetryWithDeviceToken: false, + recommendedNextStep: "update_auth_credentials", + }); + } finally { + ws.close(); + } + }); + }); + + describe("password mode", () => { + let server: Awaited>; + let port = 0; + let prevToken: string | undefined; + + beforeAll(async () => { + prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; + testState.gatewayAuth = { mode: "password", password: "secret" }; + delete process.env.OPENCLAW_GATEWAY_TOKEN; + port = await getFreePort(); + server = await startGatewayServer(port); + }); + + afterAll(async () => { + await server.close(); + restoreGatewayToken(prevToken); + }); + + test("keeps valid shared-password connect behavior unchanged", async () => { + const ws = await openWs(port); + try { + const res = await connectReq(ws, { password: "secret" }); + expect(res.ok).toBe(true); + } finally { + ws.close(); + } + }); + + test("returns stable password mismatch details", async () => { + const ws = await openWs(port); + try { + const res = await connectReq(ws, { password: "wrong" }); + expect(res.ok).toBe(false); + expectAuthErrorDetails({ + details: res.error?.details, + expectedCode: ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH, + canRetryWithDeviceToken: false, + recommendedNextStep: "update_auth_credentials", + }); + } finally { + ws.close(); + } + }); + }); + + describe("none mode", () => { + let server: Awaited>; + let port = 0; + let prevToken: string | undefined; + + beforeAll(async () => { + prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; + testState.gatewayAuth = { mode: "none" }; + delete process.env.OPENCLAW_GATEWAY_TOKEN; + port = await getFreePort(); + server = await startGatewayServer(port); + }); + + afterAll(async () => { + await server.close(); + restoreGatewayToken(prevToken); + }); + + test("keeps auth-none loopback behavior unchanged", async () => { + const ws = await openWs(port); + try { + const res = await connectReq(ws, { skipDefaultAuth: true }); + expect(res.ok).toBe(true); + } finally { + ws.close(); + } + }); + }); +}); diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index 3817cead335..12698faf3bf 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -391,9 +391,16 @@ export function registerControlUiAndPairingSuite(): void { expect(res.ok).toBe(false); expect(res.error?.message ?? "").toContain("gateway token mismatch"); expect(res.error?.message ?? "").not.toContain("device token mismatch"); - expect((res.error?.details as { code?: string } | undefined)?.code).toBe( - ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH, - ); + const details = res.error?.details as + | { + code?: string; + canRetryWithDeviceToken?: boolean; + recommendedNextStep?: string; + } + | undefined; + expect(details?.code).toBe(ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH); + expect(details?.canRetryWithDeviceToken).toBe(true); + expect(details?.recommendedNextStep).toBe("retry_with_device_token"); }, }, { diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.test.ts index b3a603fa287..d62a3e90968 100644 --- a/src/gateway/server.reload.test.ts +++ b/src/gateway/server.reload.test.ts @@ -8,6 +8,7 @@ import { installGatewayTestHooks, rpcReq, startServerWithClient, + testState, withGatewayServer, } from "./test-helpers.js"; @@ -242,6 +243,94 @@ describe("gateway hot reload", () => { ); } + async function writeTalkApiKeyEnvRefConfig(refId = "TALK_API_KEY_REF") { + const configPath = process.env.OPENCLAW_CONFIG_PATH; + if (!configPath) { + throw new Error("OPENCLAW_CONFIG_PATH is not set"); + } + await fs.writeFile( + configPath, + `${JSON.stringify( + { + talk: { + apiKey: { source: "env", provider: "default", id: refId }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + } + + async function writeGatewayTraversalExecRefConfig() { + const configPath = process.env.OPENCLAW_CONFIG_PATH; + if (!configPath) { + throw new Error("OPENCLAW_CONFIG_PATH is not set"); + } + await fs.writeFile( + configPath, + `${JSON.stringify( + { + gateway: { + auth: { + mode: "token", + token: { source: "exec", provider: "vault", id: "a/../b" }, + }, + }, + secrets: { + providers: { + vault: { + source: "exec", + command: process.execPath, + }, + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + } + + async function writeGatewayTokenExecRefConfig(params: { + resolverScriptPath: string; + modePath: string; + tokenValue: string; + }) { + const configPath = process.env.OPENCLAW_CONFIG_PATH; + if (!configPath) { + throw new Error("OPENCLAW_CONFIG_PATH is not set"); + } + await fs.writeFile( + configPath, + `${JSON.stringify( + { + gateway: { + auth: { + mode: "token", + token: { source: "exec", provider: "vault", id: "gateway/token" }, + }, + }, + secrets: { + providers: { + vault: { + source: "exec", + command: process.execPath, + allowSymlinkCommand: true, + args: [params.resolverScriptPath, params.modePath, params.tokenValue], + }, + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + } + async function writeDisabledSurfaceRefConfig() { const configPath = process.env.OPENCLAW_CONFIG_PATH; if (!configPath) { @@ -485,6 +574,13 @@ describe("gateway hot reload", () => { ); }); + it("fails startup when an active exec ref id contains traversal segments", async () => { + await writeGatewayTraversalExecRefConfig(); + await expect(withGatewayServer(async () => {})).rejects.toThrow( + /must not include "\." or "\.\." path segments/i, + ); + }); + it("allows startup when unresolved refs exist only on disabled surfaces", async () => { await writeDisabledSurfaceRefConfig(); delete process.env.DISABLED_TELEGRAM_STARTUP_REF; @@ -650,6 +746,154 @@ describe("gateway hot reload", () => { await server.close(); } }); + + it("keeps last-known-good snapshot active when secrets.reload fails over RPC", async () => { + const refId = "RUNTIME_LKG_TALK_API_KEY"; + const previousRefValue = process.env[refId]; + process.env[refId] = "talk-key-before-reload-failure"; // pragma: allowlist secret + await writeTalkApiKeyEnvRefConfig(refId); + + const { server, ws } = await startServerWithClient(); + try { + await connectOk(ws); + const preResolve = await rpcReq<{ + assignments?: Array<{ path: string; pathSegments: string[]; value: unknown }>; + }>(ws, "secrets.resolve", { + commandName: "runtime-lkg-test", + targetIds: ["talk.apiKey"], + }); + expect(preResolve.ok).toBe(true); + expect(preResolve.payload?.assignments?.[0]?.path).toBe("talk.apiKey"); + expect(preResolve.payload?.assignments?.[0]?.value).toBe("talk-key-before-reload-failure"); + + delete process.env[refId]; + const reload = await rpcReq<{ warningCount?: number }>(ws, "secrets.reload", {}); + expect(reload.ok).toBe(false); + expect(reload.error?.code).toBe("UNAVAILABLE"); + expect(reload.error?.message ?? "").toContain(refId); + + const postResolve = await rpcReq<{ + assignments?: Array<{ path: string; pathSegments: string[]; value: unknown }>; + }>(ws, "secrets.resolve", { + commandName: "runtime-lkg-test", + targetIds: ["talk.apiKey"], + }); + expect(postResolve.ok).toBe(true); + expect(postResolve.payload?.assignments?.[0]?.path).toBe("talk.apiKey"); + expect(postResolve.payload?.assignments?.[0]?.value).toBe("talk-key-before-reload-failure"); + } finally { + if (previousRefValue === undefined) { + delete process.env[refId]; + } else { + process.env[refId] = previousRefValue; + } + ws.close(); + await server.close(); + } + }); + + it("keeps last-known-good auth snapshot active when gateway auth token exec reload fails", async () => { + const stateDir = process.env.OPENCLAW_STATE_DIR; + if (!stateDir) { + throw new Error("OPENCLAW_STATE_DIR is not set"); + } + const resolverScriptPath = path.join(stateDir, "gateway-auth-token-resolver.cjs"); + const modePath = path.join(stateDir, "gateway-auth-token-resolver.mode"); + const tokenValue = "gateway-auth-exec-token"; + await fs.mkdir(path.dirname(resolverScriptPath), { recursive: true }); + await fs.writeFile( + resolverScriptPath, + `const fs = require("node:fs"); +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; +}); +process.stdin.on("end", () => { + const modePath = process.argv[2]; + const token = process.argv[3]; + const mode = fs.existsSync(modePath) ? fs.readFileSync(modePath, "utf8").trim() : "ok"; + let ids = ["gateway/token"]; + try { + const parsed = JSON.parse(input || "{}"); + if (Array.isArray(parsed.ids) && parsed.ids.length > 0) { + ids = parsed.ids.map((entry) => String(entry)); + } + } catch {} + + if (mode === "fail") { + const errors = {}; + for (const id of ids) { + errors[id] = { message: "forced failure" }; + } + process.stdout.write(JSON.stringify({ protocolVersion: 1, values: {}, errors }) + "\\n"); + return; + } + + const values = {}; + for (const id of ids) { + values[id] = token; + } + process.stdout.write(JSON.stringify({ protocolVersion: 1, values }) + "\\n"); +}); +`, + "utf8", + ); + await fs.writeFile(modePath, "ok\n", "utf8"); + await writeGatewayTokenExecRefConfig({ + resolverScriptPath, + modePath, + tokenValue, + }); + + const previousGatewayAuth = testState.gatewayAuth; + const previousGatewayTokenEnv = process.env.OPENCLAW_GATEWAY_TOKEN; + testState.gatewayAuth = undefined; + delete process.env.OPENCLAW_GATEWAY_TOKEN; + + const started = await startServerWithClient(); + const { server, ws, envSnapshot } = started; + try { + await connectOk(ws, { + token: tokenValue, + }); + const preResolve = await rpcReq<{ + assignments?: Array<{ path: string; pathSegments: string[]; value: unknown }>; + }>(ws, "secrets.resolve", { + commandName: "runtime-lkg-auth-test", + targetIds: ["gateway.auth.token"], + }); + expect(preResolve.ok).toBe(true); + expect(preResolve.payload?.assignments?.[0]?.path).toBe("gateway.auth.token"); + expect(preResolve.payload?.assignments?.[0]?.value).toBe(tokenValue); + + await fs.writeFile(modePath, "fail\n", "utf8"); + const reload = await rpcReq<{ warningCount?: number }>(ws, "secrets.reload", {}); + expect(reload.ok).toBe(false); + expect(reload.error?.code).toBe("UNAVAILABLE"); + expect(reload.error?.message ?? "").toContain("forced failure"); + + const postResolve = await rpcReq<{ + assignments?: Array<{ path: string; pathSegments: string[]; value: unknown }>; + }>(ws, "secrets.resolve", { + commandName: "runtime-lkg-auth-test", + targetIds: ["gateway.auth.token"], + }); + expect(postResolve.ok).toBe(true); + expect(postResolve.payload?.assignments?.[0]?.path).toBe("gateway.auth.token"); + expect(postResolve.payload?.assignments?.[0]?.value).toBe(tokenValue); + } finally { + testState.gatewayAuth = previousGatewayAuth; + if (previousGatewayTokenEnv === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = previousGatewayTokenEnv; + } + envSnapshot.restore(); + ws.close(); + await server.close(); + } + }); }); describe("gateway agents", () => { diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts index f986d49c648..1decc4b9178 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts @@ -591,6 +591,43 @@ describe("gateway server sessions", () => { ws.close(); }); + test("sessions.reset recomputes model from defaults instead of stale runtime model", async () => { + await createSessionStoreDir(); + testState.agentConfig = { + model: { + primary: "openai/gpt-test-a", + }, + }; + + await writeSessionStore({ + entries: { + main: { + sessionId: "sess-stale-model", + updatedAt: Date.now(), + modelProvider: "qwencode", + model: "qwen3.5-plus-2026-02-15", + contextTokens: 123456, + }, + }, + }); + + const { ws } = await openClient(); + const reset = await rpcReq<{ + ok: true; + key: string; + entry: { sessionId: string; modelProvider?: string; model?: string; contextTokens?: number }; + }>(ws, "sessions.reset", { key: "main" }); + + expect(reset.ok).toBe(true); + expect(reset.payload?.key).toBe("agent:main:main"); + expect(reset.payload?.entry.sessionId).not.toBe("sess-stale-model"); + expect(reset.payload?.entry.modelProvider).toBe("openai"); + expect(reset.payload?.entry.model).toBe("gpt-test-a"); + expect(reset.payload?.entry.contextTokens).toBeUndefined(); + + ws.close(); + }); + test("sessions.preview resolves legacy mixed-case main alias with custom mainKey", async () => { const { dir, storePath } = await createSessionStoreDir(); testState.agentsConfig = { list: [{ id: "ops", default: true }] }; diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index f1568796192..83d1b5f12a3 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -562,6 +562,31 @@ export function attachGatewayWsMessageHandler(params: { clientIp: browserRateLimitClientIp, }); const rejectUnauthorized = (failedAuth: GatewayAuthResult) => { + const canRetryWithDeviceToken = + failedAuth.reason === "token_mismatch" && + Boolean(device) && + hasSharedAuth && + !connectParams.auth?.deviceToken; + const recommendedNextStep = (() => { + if (canRetryWithDeviceToken) { + return "retry_with_device_token"; + } + switch (failedAuth.reason) { + case "token_missing": + case "token_missing_config": + case "password_missing": + case "password_missing_config": + return "update_auth_configuration"; + case "token_mismatch": + case "password_mismatch": + case "device_token_mismatch": + return "update_auth_credentials"; + case "rate_limited": + return "wait_then_retry"; + default: + return "review_auth_configuration"; + } + })(); markHandshakeFailure("unauthorized", { authMode: resolvedAuth.mode, authProvided: connectParams.auth?.password @@ -594,6 +619,8 @@ export function attachGatewayWsMessageHandler(params: { details: { code: resolveAuthConnectErrorDetailCode(failedAuth.reason), authReason: failedAuth.reason, + canRetryWithDeviceToken, + recommendedNextStep, }, }); close(1008, truncateCloseReason(authMessage)); diff --git a/src/imessage/monitor/provider.group-policy.test.ts b/src/imessage/monitor/provider.group-policy.test.ts index c28d7c10b4b..58812ad5711 100644 --- a/src/imessage/monitor/provider.group-policy.test.ts +++ b/src/imessage/monitor/provider.group-policy.test.ts @@ -1,29 +1,13 @@ -import { describe, expect, it } from "vitest"; +import { describe } from "vitest"; +import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../test-utils/runtime-group-policy-contract.js"; import { __testing } from "./monitor-provider.js"; describe("resolveIMessageRuntimeGroupPolicy", () => { - it("fails closed when channels.imessage is missing and no defaults are set", () => { - const resolved = __testing.resolveIMessageRuntimeGroupPolicy({ - providerConfigPresent: false, - }); - expect(resolved.groupPolicy).toBe("allowlist"); - expect(resolved.providerMissingFallbackApplied).toBe(true); - }); - - it("keeps open fallback when channels.imessage is configured", () => { - const resolved = __testing.resolveIMessageRuntimeGroupPolicy({ - providerConfigPresent: true, - }); - expect(resolved.groupPolicy).toBe("open"); - expect(resolved.providerMissingFallbackApplied).toBe(false); - }); - - it("ignores explicit global defaults when provider config is missing", () => { - const resolved = __testing.resolveIMessageRuntimeGroupPolicy({ - providerConfigPresent: false, - defaultGroupPolicy: "disabled", - }); - expect(resolved.groupPolicy).toBe("allowlist"); - expect(resolved.providerMissingFallbackApplied).toBe(true); + installProviderRuntimeGroupPolicyFallbackSuite({ + resolve: __testing.resolveIMessageRuntimeGroupPolicy, + configuredLabel: "keeps open fallback when channels.imessage is configured", + defaultGroupPolicyUnderTest: "disabled", + missingConfigLabel: "fails closed when channels.imessage is missing and no defaults are set", + missingDefaultLabel: "ignores explicit global defaults when provider config is missing", }); }); diff --git a/src/infra/archive-staging.ts b/src/infra/archive-staging.ts new file mode 100644 index 00000000000..443e28e062e --- /dev/null +++ b/src/infra/archive-staging.ts @@ -0,0 +1,218 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { copyFileWithinRoot } from "./fs-safe.js"; +import { isNotFoundPathError, isPathInside } from "./path-guards.js"; + +const ERROR_ARCHIVE_ENTRY_TRAVERSES_SYMLINK = "archive entry traverses symlink in destination"; + +export type ArchiveSecurityErrorCode = + | "destination-not-directory" + | "destination-symlink" + | "destination-symlink-traversal"; + +export class ArchiveSecurityError extends Error { + code: ArchiveSecurityErrorCode; + + constructor(code: ArchiveSecurityErrorCode, message: string, options?: ErrorOptions) { + super(message, options); + this.code = code; + this.name = "ArchiveSecurityError"; + } +} + +function symlinkTraversalError(originalPath: string): ArchiveSecurityError { + return new ArchiveSecurityError( + "destination-symlink-traversal", + `${ERROR_ARCHIVE_ENTRY_TRAVERSES_SYMLINK}: ${originalPath}`, + ); +} + +export async function prepareArchiveDestinationDir(destDir: string): Promise { + const stat = await fs.lstat(destDir); + if (stat.isSymbolicLink()) { + throw new ArchiveSecurityError("destination-symlink", "archive destination is a symlink"); + } + if (!stat.isDirectory()) { + throw new ArchiveSecurityError( + "destination-not-directory", + "archive destination is not a directory", + ); + } + return await fs.realpath(destDir); +} + +async function assertNoSymlinkTraversal(params: { + rootDir: string; + relPath: string; + originalPath: string; +}): Promise { + const parts = params.relPath.split(/[\\/]+/).filter(Boolean); + let current = path.resolve(params.rootDir); + for (const part of parts) { + current = path.join(current, part); + let stat: Awaited>; + try { + stat = await fs.lstat(current); + } catch (err) { + if (isNotFoundPathError(err)) { + continue; + } + throw err; + } + if (stat.isSymbolicLink()) { + throw symlinkTraversalError(params.originalPath); + } + } +} + +async function assertResolvedInsideDestination(params: { + destinationRealDir: string; + targetPath: string; + originalPath: string; +}): Promise { + let resolved: string; + try { + resolved = await fs.realpath(params.targetPath); + } catch (err) { + if (isNotFoundPathError(err)) { + return; + } + throw err; + } + if (!isPathInside(params.destinationRealDir, resolved)) { + throw symlinkTraversalError(params.originalPath); + } +} + +export async function prepareArchiveOutputPath(params: { + destinationDir: string; + destinationRealDir: string; + relPath: string; + outPath: string; + originalPath: string; + isDirectory: boolean; +}): Promise { + await assertNoSymlinkTraversal({ + rootDir: params.destinationDir, + relPath: params.relPath, + originalPath: params.originalPath, + }); + + if (params.isDirectory) { + await fs.mkdir(params.outPath, { recursive: true }); + await assertResolvedInsideDestination({ + destinationRealDir: params.destinationRealDir, + targetPath: params.outPath, + originalPath: params.originalPath, + }); + return; + } + + const parentDir = path.dirname(params.outPath); + await fs.mkdir(parentDir, { recursive: true }); + await assertResolvedInsideDestination({ + destinationRealDir: params.destinationRealDir, + targetPath: parentDir, + originalPath: params.originalPath, + }); +} + +async function applyStagedEntryMode(params: { + destinationRealDir: string; + relPath: string; + mode: number; + originalPath: string; +}): Promise { + const destinationPath = path.join(params.destinationRealDir, params.relPath); + await assertResolvedInsideDestination({ + destinationRealDir: params.destinationRealDir, + targetPath: destinationPath, + originalPath: params.originalPath, + }); + if (params.mode !== 0) { + await fs.chmod(destinationPath, params.mode).catch(() => undefined); + } +} + +export async function withStagedArchiveDestination(params: { + destinationRealDir: string; + run: (stagingDir: string) => Promise; +}): Promise { + const stagingDir = await fs.mkdtemp(path.join(params.destinationRealDir, ".openclaw-archive-")); + try { + return await params.run(stagingDir); + } finally { + await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined); + } +} + +export async function mergeExtractedTreeIntoDestination(params: { + sourceDir: string; + destinationDir: string; + destinationRealDir: string; +}): Promise { + const walk = async (currentSourceDir: string): Promise => { + const entries = await fs.readdir(currentSourceDir, { withFileTypes: true }); + for (const entry of entries) { + const sourcePath = path.join(currentSourceDir, entry.name); + const relPath = path.relative(params.sourceDir, sourcePath); + const originalPath = relPath.split(path.sep).join("/"); + const destinationPath = path.join(params.destinationDir, relPath); + const sourceStat = await fs.lstat(sourcePath); + + if (sourceStat.isSymbolicLink()) { + throw symlinkTraversalError(originalPath); + } + + if (sourceStat.isDirectory()) { + await prepareArchiveOutputPath({ + destinationDir: params.destinationDir, + destinationRealDir: params.destinationRealDir, + relPath, + outPath: destinationPath, + originalPath, + isDirectory: true, + }); + await walk(sourcePath); + await applyStagedEntryMode({ + destinationRealDir: params.destinationRealDir, + relPath, + mode: sourceStat.mode & 0o777, + originalPath, + }); + continue; + } + + if (!sourceStat.isFile()) { + throw new Error(`archive staging contains unsupported entry: ${originalPath}`); + } + + await prepareArchiveOutputPath({ + destinationDir: params.destinationDir, + destinationRealDir: params.destinationRealDir, + relPath, + outPath: destinationPath, + originalPath, + isDirectory: false, + }); + await copyFileWithinRoot({ + sourcePath, + rootDir: params.destinationRealDir, + relativePath: relPath, + mkdir: true, + }); + await applyStagedEntryMode({ + destinationRealDir: params.destinationRealDir, + relPath, + mode: sourceStat.mode & 0o777, + originalPath, + }); + } + }; + + await walk(params.sourceDir); +} + +export function createArchiveSymlinkTraversalError(originalPath: string): ArchiveSecurityError { + return symlinkTraversalError(originalPath); +} diff --git a/src/infra/archive.test.ts b/src/infra/archive.test.ts index 175d68a48e3..14c546e7674 100644 --- a/src/infra/archive.test.ts +++ b/src/infra/archive.test.ts @@ -10,6 +10,7 @@ import { extractArchive, resolveArchiveKind, resolvePackedRootDir } from "./arch let fixtureRoot = ""; let fixtureCount = 0; +const directorySymlinkType = process.platform === "win32" ? "junction" : undefined; async function makeTempDir(prefix = "case") { const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`); @@ -48,6 +49,14 @@ async function writePackageArchive(params: { await tar.c({ cwd: params.workDir, file: params.archivePath }, ["package"]); } +async function createDirectorySymlink(targetDir: string, linkPath: string) { + await fs.symlink(targetDir, linkPath, directorySymlinkType); +} + +async function expectPathMissing(filePath: string) { + await expect(fs.stat(filePath)).rejects.toMatchObject({ code: "ENOENT" }); +} + async function expectExtractedSizeBudgetExceeded(params: { archivePath: string; destDir: string; @@ -105,6 +114,33 @@ describe("archive utils", () => { }, ); + it.each([{ ext: "zip" as const }, { ext: "tar" as const }])( + "rejects $ext extraction when destination dir is a symlink", + async ({ ext }) => { + await withArchiveCase(ext, async ({ workDir, archivePath, extractDir }) => { + const realExtractDir = path.join(workDir, "real-extract"); + await fs.mkdir(realExtractDir, { recursive: true }); + await writePackageArchive({ + ext, + workDir, + archivePath, + fileName: "hello.txt", + content: "hi", + }); + await fs.rm(extractDir, { recursive: true, force: true }); + await createDirectorySymlink(realExtractDir, extractDir); + + await expect( + extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }), + ).rejects.toMatchObject({ + code: "destination-symlink", + } satisfies Partial); + + await expectPathMissing(path.join(realExtractDir, "package", "hello.txt")); + }); + }, + ); + it("rejects zip path traversal (zip slip)", async () => { await withArchiveCase("zip", async ({ archivePath, extractDir }) => { const zip = new JSZip(); @@ -121,13 +157,7 @@ describe("archive utils", () => { await withArchiveCase("zip", async ({ workDir, archivePath, extractDir }) => { const outsideDir = path.join(workDir, "outside"); await fs.mkdir(outsideDir, { recursive: true }); - // Use 'junction' on Windows — junctions target directories without - // requiring SeCreateSymbolicLinkPrivilege. - await fs.symlink( - outsideDir, - path.join(extractDir, "escape"), - process.platform === "win32" ? "junction" : undefined, - ); + await createDirectorySymlink(outsideDir, path.join(extractDir, "escape")); const zip = new JSZip(); zip.file("escape/pwn.txt", "owned"); @@ -233,6 +263,26 @@ describe("archive utils", () => { }); }); + it("rejects tar entries that traverse pre-existing destination symlinks", async () => { + await withArchiveCase("tar", async ({ workDir, archivePath, extractDir }) => { + const outsideDir = path.join(workDir, "outside"); + const archiveRoot = path.join(workDir, "archive-root"); + await fs.mkdir(outsideDir, { recursive: true }); + await fs.mkdir(path.join(archiveRoot, "escape"), { recursive: true }); + await fs.writeFile(path.join(archiveRoot, "escape", "pwn.txt"), "owned"); + await createDirectorySymlink(outsideDir, path.join(extractDir, "escape")); + await tar.c({ cwd: archiveRoot, file: archivePath }, ["escape"]); + + await expect( + extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }), + ).rejects.toMatchObject({ + code: "destination-symlink-traversal", + } satisfies Partial); + + await expectPathMissing(path.join(outsideDir, "pwn.txt")); + }); + }); + it.each([{ ext: "zip" as const }, { ext: "tar" as const }])( "rejects $ext archives that exceed extracted size budget", async ({ ext }) => { diff --git a/src/infra/archive.ts b/src/infra/archive.ts index 694560b4d31..97460eff4f3 100644 --- a/src/infra/archive.ts +++ b/src/infra/archive.ts @@ -13,9 +13,16 @@ import { stripArchivePath, validateArchiveEntryPath, } from "./archive-path.js"; +import { + createArchiveSymlinkTraversalError, + mergeExtractedTreeIntoDestination, + prepareArchiveDestinationDir, + prepareArchiveOutputPath, + withStagedArchiveDestination, +} from "./archive-staging.js"; import { sameFileIdentity } from "./file-identity.js"; import { openFileWithinRoot, openWritableFileWithinRoot, SafeOpenError } from "./fs-safe.js"; -import { isNotFoundPathError, isPathInside } from "./path-guards.js"; +import { isNotFoundPathError } from "./path-guards.js"; export type ArchiveKind = "tar" | "zip"; @@ -37,20 +44,13 @@ export type ArchiveExtractLimits = { maxEntryBytes?: number; }; -export type ArchiveSecurityErrorCode = - | "destination-not-directory" - | "destination-symlink" - | "destination-symlink-traversal"; - -export class ArchiveSecurityError extends Error { - code: ArchiveSecurityErrorCode; - - constructor(code: ArchiveSecurityErrorCode, message: string, options?: ErrorOptions) { - super(message, options); - this.code = code; - this.name = "ArchiveSecurityError"; - } -} +export { ArchiveSecurityError, type ArchiveSecurityErrorCode } from "./archive-staging.js"; +export { + mergeExtractedTreeIntoDestination, + prepareArchiveDestinationDir, + prepareArchiveOutputPath, + withStagedArchiveDestination, +} from "./archive-staging.js"; /** @internal */ export const DEFAULT_MAX_ARCHIVE_BYTES_ZIP = 256 * 1024 * 1024; @@ -66,7 +66,6 @@ const ERROR_ARCHIVE_ENTRY_COUNT_EXCEEDS_LIMIT = "archive entry count exceeds lim const ERROR_ARCHIVE_ENTRY_EXTRACTED_SIZE_EXCEEDS_LIMIT = "archive entry extracted size exceeds limit"; const ERROR_ARCHIVE_EXTRACTED_SIZE_EXCEEDS_LIMIT = "archive extracted size exceeds limit"; -const ERROR_ARCHIVE_ENTRY_TRAVERSES_SYMLINK = "archive entry traverses symlink in destination"; const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants; const OPEN_WRITE_CREATE_FLAGS = fsConstants.O_WRONLY | @@ -217,68 +216,8 @@ function createExtractBudgetTransform(params: { }); } -function symlinkTraversalError(originalPath: string): ArchiveSecurityError { - return new ArchiveSecurityError( - "destination-symlink-traversal", - `${ERROR_ARCHIVE_ENTRY_TRAVERSES_SYMLINK}: ${originalPath}`, - ); -} - -async function assertDestinationDirReady(destDir: string): Promise { - const stat = await fs.lstat(destDir); - if (stat.isSymbolicLink()) { - throw new ArchiveSecurityError("destination-symlink", "archive destination is a symlink"); - } - if (!stat.isDirectory()) { - throw new ArchiveSecurityError( - "destination-not-directory", - "archive destination is not a directory", - ); - } - return await fs.realpath(destDir); -} - -async function assertNoSymlinkTraversal(params: { - rootDir: string; - relPath: string; - originalPath: string; -}): Promise { - const parts = params.relPath.split("/").filter(Boolean); - let current = path.resolve(params.rootDir); - for (const part of parts) { - current = path.join(current, part); - let stat: Awaited>; - try { - stat = await fs.lstat(current); - } catch (err) { - if (isNotFoundPathError(err)) { - continue; - } - throw err; - } - if (stat.isSymbolicLink()) { - throw symlinkTraversalError(params.originalPath); - } - } -} - -async function assertResolvedInsideDestination(params: { - destinationRealDir: string; - targetPath: string; - originalPath: string; -}): Promise { - let resolved: string; - try { - resolved = await fs.realpath(params.targetPath); - } catch (err) { - if (isNotFoundPathError(err)) { - return; - } - throw err; - } - if (!isPathInside(params.destinationRealDir, resolved)) { - throw symlinkTraversalError(params.originalPath); - } +function symlinkTraversalError(originalPath: string) { + return createArchiveSymlinkTraversalError(originalPath); } type OpenZipOutputFileResult = { @@ -403,29 +342,7 @@ async function prepareZipOutputPath(params: { originalPath: string; isDirectory: boolean; }): Promise { - await assertNoSymlinkTraversal({ - rootDir: params.destinationDir, - relPath: params.relPath, - originalPath: params.originalPath, - }); - - if (params.isDirectory) { - await fs.mkdir(params.outPath, { recursive: true }); - await assertResolvedInsideDestination({ - destinationRealDir: params.destinationRealDir, - targetPath: params.outPath, - originalPath: params.originalPath, - }); - return; - } - - const parentDir = path.dirname(params.outPath); - await fs.mkdir(parentDir, { recursive: true }); - await assertResolvedInsideDestination({ - destinationRealDir: params.destinationRealDir, - targetPath: parentDir, - originalPath: params.originalPath, - }); + await prepareArchiveOutputPath(params); } async function writeZipFileEntry(params: { @@ -511,7 +428,7 @@ async function extractZip(params: { limits?: ArchiveExtractLimits; }): Promise { const limits = resolveExtractLimits(params.limits); - const destinationRealDir = await assertDestinationDirReady(params.destDir); + const destinationRealDir = await prepareArchiveDestinationDir(params.destDir); const stat = await fs.stat(params.archivePath); if (stat.size > limits.maxArchiveBytes) { throw new Error(ERROR_ARCHIVE_SIZE_EXCEEDS_LIMIT); @@ -588,7 +505,7 @@ function readTarEntryInfo(entry: unknown): TarEntryInfo { return { path: p, type: t, size: s }; } -export function createTarEntrySafetyChecker(params: { +export function createTarEntryPreflightChecker(params: { rootDir: string; stripComponents?: number; limits?: ArchiveExtractLimits; @@ -641,37 +558,54 @@ export async function extractArchive(params: { const label = kind === "zip" ? "extract zip" : "extract tar"; if (kind === "tar") { - const limits = resolveExtractLimits(params.limits); - const stat = await fs.stat(params.archivePath); - if (stat.size > limits.maxArchiveBytes) { - throw new Error(ERROR_ARCHIVE_SIZE_EXCEEDS_LIMIT); - } - - const checkTarEntrySafety = createTarEntrySafetyChecker({ - rootDir: params.destDir, - stripComponents: params.stripComponents, - limits, - }); await withTimeout( - tar.x({ - file: params.archivePath, - cwd: params.destDir, - strip: Math.max(0, Math.floor(params.stripComponents ?? 0)), - gzip: params.tarGzip, - preservePaths: false, - strict: true, - onReadEntry(entry) { - try { - checkTarEntrySafety(readTarEntryInfo(entry)); - } catch (err) { - const error = err instanceof Error ? err : new Error(String(err)); - // Node's EventEmitter calls listeners with `this` bound to the - // emitter (tar.Unpack), which exposes Parser.abort(). - const emitter = this as unknown as { abort?: (error: Error) => void }; - emitter.abort?.(error); - } - }, - }), + (async () => { + const limits = resolveExtractLimits(params.limits); + const stat = await fs.stat(params.archivePath); + if (stat.size > limits.maxArchiveBytes) { + throw new Error(ERROR_ARCHIVE_SIZE_EXCEEDS_LIMIT); + } + + const destinationRealDir = await prepareArchiveDestinationDir(params.destDir); + await withStagedArchiveDestination({ + destinationRealDir, + run: async (stagingDir) => { + const checkTarEntrySafety = createTarEntryPreflightChecker({ + rootDir: destinationRealDir, + stripComponents: params.stripComponents, + limits, + }); + // A canonical cwd is not enough here: tar can still follow + // pre-existing child symlinks in the live destination tree. + // Extract into a private staging dir first, then merge through + // the same safe-open boundary checks used by direct file writes. + await tar.x({ + file: params.archivePath, + cwd: stagingDir, + strip: Math.max(0, Math.floor(params.stripComponents ?? 0)), + gzip: params.tarGzip, + preservePaths: false, + strict: true, + onReadEntry(entry) { + try { + checkTarEntrySafety(readTarEntryInfo(entry)); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + // Node's EventEmitter calls listeners with `this` bound to the + // emitter (tar.Unpack), which exposes Parser.abort(). + const emitter = this as unknown as { abort?: (error: Error) => void }; + emitter.abort?.(error); + } + }, + }); + await mergeExtractedTreeIntoDestination({ + sourceDir: stagingDir, + destinationDir: destinationRealDir, + destinationRealDir, + }); + }, + }); + })(), params.timeoutMs, label, ); diff --git a/src/infra/backup-create.ts b/src/infra/backup-create.ts new file mode 100644 index 00000000000..4697d859cc3 --- /dev/null +++ b/src/infra/backup-create.ts @@ -0,0 +1,368 @@ +import { randomUUID } from "node:crypto"; +import { constants as fsConstants } from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import * as tar from "tar"; +import { + buildBackupArchiveBasename, + buildBackupArchivePath, + buildBackupArchiveRoot, + type BackupAsset, + resolveBackupPlanFromDisk, +} from "../commands/backup-shared.js"; +import { isPathWithin } from "../commands/cleanup-utils.js"; +import { resolveHomeDir, resolveUserPath } from "../utils.js"; +import { resolveRuntimeServiceVersion } from "../version.js"; + +export type BackupCreateOptions = { + output?: string; + dryRun?: boolean; + includeWorkspace?: boolean; + onlyConfig?: boolean; + verify?: boolean; + json?: boolean; + nowMs?: number; +}; + +type BackupManifestAsset = { + kind: BackupAsset["kind"]; + sourcePath: string; + archivePath: string; +}; + +type BackupManifest = { + schemaVersion: 1; + createdAt: string; + archiveRoot: string; + runtimeVersion: string; + platform: NodeJS.Platform; + nodeVersion: string; + options: { + includeWorkspace: boolean; + onlyConfig?: boolean; + }; + paths: { + stateDir: string; + configPath: string; + oauthDir: string; + workspaceDirs: string[]; + }; + assets: BackupManifestAsset[]; + skipped: Array<{ + kind: string; + sourcePath: string; + reason: string; + coveredBy?: string; + }>; +}; + +export type BackupCreateResult = { + createdAt: string; + archiveRoot: string; + archivePath: string; + dryRun: boolean; + includeWorkspace: boolean; + onlyConfig: boolean; + verified: boolean; + assets: BackupAsset[]; + skipped: Array<{ + kind: string; + sourcePath: string; + displayPath: string; + reason: string; + coveredBy?: string; + }>; +}; + +async function resolveOutputPath(params: { + output?: string; + nowMs: number; + includedAssets: BackupAsset[]; + stateDir: string; +}): Promise { + const basename = buildBackupArchiveBasename(params.nowMs); + const rawOutput = params.output?.trim(); + if (!rawOutput) { + const cwd = path.resolve(process.cwd()); + const canonicalCwd = await fs.realpath(cwd).catch(() => cwd); + const cwdInsideSource = params.includedAssets.some((asset) => + isPathWithin(canonicalCwd, asset.sourcePath), + ); + const defaultDir = cwdInsideSource ? (resolveHomeDir() ?? path.dirname(params.stateDir)) : cwd; + return path.resolve(defaultDir, basename); + } + + const resolved = resolveUserPath(rawOutput); + if (rawOutput.endsWith("/") || rawOutput.endsWith("\\")) { + return path.join(resolved, basename); + } + + try { + const stat = await fs.stat(resolved); + if (stat.isDirectory()) { + return path.join(resolved, basename); + } + } catch { + // Treat as a file path when the target does not exist yet. + } + + return resolved; +} + +async function assertOutputPathReady(outputPath: string): Promise { + try { + await fs.access(outputPath); + throw new Error(`Refusing to overwrite existing backup archive: ${outputPath}`); + } catch (err) { + const code = (err as NodeJS.ErrnoException | undefined)?.code; + if (code === "ENOENT") { + return; + } + throw err; + } +} + +function buildTempArchivePath(outputPath: string): string { + return `${outputPath}.${randomUUID()}.tmp`; +} + +function isLinkUnsupportedError(code: string | undefined): boolean { + return code === "ENOTSUP" || code === "EOPNOTSUPP" || code === "EPERM"; +} + +async function publishTempArchive(params: { + tempArchivePath: string; + outputPath: string; +}): Promise { + try { + await fs.link(params.tempArchivePath, params.outputPath); + } catch (err) { + const code = (err as NodeJS.ErrnoException | undefined)?.code; + if (code === "EEXIST") { + throw new Error(`Refusing to overwrite existing backup archive: ${params.outputPath}`, { + cause: err, + }); + } + if (!isLinkUnsupportedError(code)) { + throw err; + } + + try { + // Some backup targets support ordinary files but not hard links. + await fs.copyFile(params.tempArchivePath, params.outputPath, fsConstants.COPYFILE_EXCL); + } catch (copyErr) { + const copyCode = (copyErr as NodeJS.ErrnoException | undefined)?.code; + if (copyCode !== "EEXIST") { + await fs.rm(params.outputPath, { force: true }).catch(() => undefined); + } + if (copyCode === "EEXIST") { + throw new Error(`Refusing to overwrite existing backup archive: ${params.outputPath}`, { + cause: copyErr, + }); + } + throw copyErr; + } + } + await fs.rm(params.tempArchivePath, { force: true }); +} + +async function canonicalizePathForContainment(targetPath: string): Promise { + const resolved = path.resolve(targetPath); + const suffix: string[] = []; + let probe = resolved; + + while (true) { + try { + const realProbe = await fs.realpath(probe); + return suffix.length === 0 ? realProbe : path.join(realProbe, ...suffix.toReversed()); + } catch { + const parent = path.dirname(probe); + if (parent === probe) { + return resolved; + } + suffix.push(path.basename(probe)); + probe = parent; + } + } +} + +function buildManifest(params: { + createdAt: string; + archiveRoot: string; + includeWorkspace: boolean; + onlyConfig: boolean; + assets: BackupAsset[]; + skipped: BackupCreateResult["skipped"]; + stateDir: string; + configPath: string; + oauthDir: string; + workspaceDirs: string[]; +}): BackupManifest { + return { + schemaVersion: 1, + createdAt: params.createdAt, + archiveRoot: params.archiveRoot, + runtimeVersion: resolveRuntimeServiceVersion(), + platform: process.platform, + nodeVersion: process.version, + options: { + includeWorkspace: params.includeWorkspace, + onlyConfig: params.onlyConfig, + }, + paths: { + stateDir: params.stateDir, + configPath: params.configPath, + oauthDir: params.oauthDir, + workspaceDirs: params.workspaceDirs, + }, + assets: params.assets.map((asset) => ({ + kind: asset.kind, + sourcePath: asset.sourcePath, + archivePath: asset.archivePath, + })), + skipped: params.skipped.map((entry) => ({ + kind: entry.kind, + sourcePath: entry.sourcePath, + reason: entry.reason, + coveredBy: entry.coveredBy, + })), + }; +} + +export function formatBackupCreateSummary(result: BackupCreateResult): string[] { + const lines = [`Backup archive: ${result.archivePath}`]; + lines.push(`Included ${result.assets.length} path${result.assets.length === 1 ? "" : "s"}:`); + for (const asset of result.assets) { + lines.push(`- ${asset.kind}: ${asset.displayPath}`); + } + if (result.skipped.length > 0) { + lines.push(`Skipped ${result.skipped.length} path${result.skipped.length === 1 ? "" : "s"}:`); + for (const entry of result.skipped) { + if (entry.reason === "covered" && entry.coveredBy) { + lines.push(`- ${entry.kind}: ${entry.displayPath} (${entry.reason} by ${entry.coveredBy})`); + } else { + lines.push(`- ${entry.kind}: ${entry.displayPath} (${entry.reason})`); + } + } + } + if (result.dryRun) { + lines.push("Dry run only; archive was not written."); + } else { + lines.push(`Created ${result.archivePath}`); + if (result.verified) { + lines.push("Archive verification: passed"); + } + } + return lines; +} + +function remapArchiveEntryPath(params: { + entryPath: string; + manifestPath: string; + archiveRoot: string; +}): string { + const normalizedEntry = path.resolve(params.entryPath); + if (normalizedEntry === params.manifestPath) { + return path.posix.join(params.archiveRoot, "manifest.json"); + } + return buildBackupArchivePath(params.archiveRoot, normalizedEntry); +} + +export async function createBackupArchive( + opts: BackupCreateOptions = {}, +): Promise { + const nowMs = opts.nowMs ?? Date.now(); + const archiveRoot = buildBackupArchiveRoot(nowMs); + const onlyConfig = Boolean(opts.onlyConfig); + const includeWorkspace = onlyConfig ? false : (opts.includeWorkspace ?? true); + const plan = await resolveBackupPlanFromDisk({ includeWorkspace, onlyConfig, nowMs }); + const outputPath = await resolveOutputPath({ + output: opts.output, + nowMs, + includedAssets: plan.included, + stateDir: plan.stateDir, + }); + + if (plan.included.length === 0) { + throw new Error( + onlyConfig + ? "No OpenClaw config file was found to back up." + : "No local OpenClaw state was found to back up.", + ); + } + + const canonicalOutputPath = await canonicalizePathForContainment(outputPath); + const overlappingAsset = plan.included.find((asset) => + isPathWithin(canonicalOutputPath, asset.sourcePath), + ); + if (overlappingAsset) { + throw new Error( + `Backup output must not be written inside a source path: ${outputPath} is inside ${overlappingAsset.sourcePath}`, + ); + } + + if (!opts.dryRun) { + await assertOutputPathReady(outputPath); + } + + const createdAt = new Date(nowMs).toISOString(); + const result: BackupCreateResult = { + createdAt, + archiveRoot, + archivePath: outputPath, + dryRun: Boolean(opts.dryRun), + includeWorkspace, + onlyConfig, + verified: false, + assets: plan.included, + skipped: plan.skipped, + }; + + if (opts.dryRun) { + return result; + } + + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-")); + const manifestPath = path.join(tempDir, "manifest.json"); + const tempArchivePath = buildTempArchivePath(outputPath); + try { + const manifest = buildManifest({ + createdAt, + archiveRoot, + includeWorkspace, + onlyConfig, + assets: result.assets, + skipped: result.skipped, + stateDir: plan.stateDir, + configPath: plan.configPath, + oauthDir: plan.oauthDir, + workspaceDirs: plan.workspaceDirs, + }); + await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + + await tar.c( + { + file: tempArchivePath, + gzip: true, + portable: true, + preservePaths: true, + onWriteEntry: (entry) => { + entry.path = remapArchiveEntryPath({ + entryPath: entry.path, + manifestPath, + archiveRoot, + }); + }, + }, + [manifestPath, ...result.assets.map((asset) => asset.sourcePath)], + ); + await publishTempArchive({ tempArchivePath, outputPath }); + } finally { + await fs.rm(tempArchivePath, { force: true }).catch(() => undefined); + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); + } + + return result; +} diff --git a/src/infra/heartbeat-reason.test.ts b/src/infra/heartbeat-reason.test.ts index 6c2fdc68f97..69d23e3219d 100644 --- a/src/infra/heartbeat-reason.test.ts +++ b/src/infra/heartbeat-reason.test.ts @@ -19,6 +19,7 @@ describe("heartbeat-reason", () => { expect(resolveHeartbeatReasonKind("manual")).toBe("manual"); expect(resolveHeartbeatReasonKind("exec-event")).toBe("exec-event"); expect(resolveHeartbeatReasonKind("wake")).toBe("wake"); + expect(resolveHeartbeatReasonKind("acp:spawn:stream")).toBe("wake"); expect(resolveHeartbeatReasonKind("cron:job-1")).toBe("cron"); expect(resolveHeartbeatReasonKind("hook:wake")).toBe("hook"); expect(resolveHeartbeatReasonKind(" hook:wake ")).toBe("hook"); @@ -35,6 +36,7 @@ describe("heartbeat-reason", () => { expect(isHeartbeatEventDrivenReason("exec-event")).toBe(true); expect(isHeartbeatEventDrivenReason("cron:job-1")).toBe(true); expect(isHeartbeatEventDrivenReason("wake")).toBe(true); + expect(isHeartbeatEventDrivenReason("acp:spawn:stream")).toBe(true); expect(isHeartbeatEventDrivenReason("hook:gmail:sync")).toBe(true); expect(isHeartbeatEventDrivenReason("interval")).toBe(false); expect(isHeartbeatEventDrivenReason("manual")).toBe(false); diff --git a/src/infra/heartbeat-reason.ts b/src/infra/heartbeat-reason.ts index 968b1e24062..447ca733e53 100644 --- a/src/infra/heartbeat-reason.ts +++ b/src/infra/heartbeat-reason.ts @@ -34,6 +34,9 @@ export function resolveHeartbeatReasonKind(reason?: string): HeartbeatReasonKind if (trimmed === "wake") { return "wake"; } + if (trimmed.startsWith("acp:spawn:")) { + return "wake"; + } if (trimmed.startsWith("cron:")) { return "cron"; } diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index c3c58d34c1e..344fd22d8fc 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -38,7 +38,11 @@ import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { getQueueSize } from "../process/command-queue.js"; import { CommandLane } from "../process/lanes.js"; -import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.js"; +import { + normalizeAgentId, + parseAgentSessionKey, + toAgentStoreSessionKey, +} from "../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { escapeRegExp } from "../utils.js"; import { formatErrorMessage, hasErrnoCode } from "./errors.js"; @@ -53,9 +57,11 @@ import { emitHeartbeatEvent, resolveIndicatorType } from "./heartbeat-events.js" import { resolveHeartbeatReasonKind } from "./heartbeat-reason.js"; import { resolveHeartbeatVisibility } from "./heartbeat-visibility.js"; import { + areHeartbeatsEnabled, type HeartbeatRunResult, type HeartbeatWakeHandler, requestHeartbeatNow, + setHeartbeatsEnabled, setHeartbeatWakeHandler, } from "./heartbeat-wake.js"; import type { OutboundSendDeps } from "./outbound/deliver.js"; @@ -75,11 +81,8 @@ export type HeartbeatDeps = OutboundSendDeps & }; const log = createSubsystemLogger("gateway/heartbeat"); -let heartbeatsEnabled = true; -export function setHeartbeatsEnabled(enabled: boolean) { - heartbeatsEnabled = enabled; -} +export { areHeartbeatsEnabled, setHeartbeatsEnabled }; type HeartbeatConfig = AgentDefaultsConfig["heartbeat"]; type HeartbeatAgent = { @@ -611,9 +614,14 @@ export async function runHeartbeatOnce(opts: { deps?: HeartbeatDeps; }): Promise { const cfg = opts.cfg ?? loadConfig(); - const agentId = normalizeAgentId(opts.agentId ?? resolveDefaultAgentId(cfg)); + const explicitAgentId = typeof opts.agentId === "string" ? opts.agentId.trim() : ""; + const forcedSessionAgentId = + explicitAgentId.length > 0 ? undefined : parseAgentSessionKey(opts.sessionKey)?.agentId; + const agentId = normalizeAgentId( + explicitAgentId || forcedSessionAgentId || resolveDefaultAgentId(cfg), + ); const heartbeat = opts.heartbeat ?? resolveHeartbeatConfig(cfg, agentId); - if (!heartbeatsEnabled) { + if (!areHeartbeatsEnabled()) { return { status: "skipped", reason: "disabled" }; } if (!isHeartbeatEnabledForAgent(cfg, agentId)) { @@ -1114,7 +1122,7 @@ export function startHeartbeatRunner(opts: { reason: "disabled", } satisfies HeartbeatRunResult; } - if (!heartbeatsEnabled) { + if (!areHeartbeatsEnabled()) { return { status: "skipped", reason: "disabled", diff --git a/src/infra/heartbeat-wake.ts b/src/infra/heartbeat-wake.ts index bccfdfe9829..3aaaca5ed96 100644 --- a/src/infra/heartbeat-wake.ts +++ b/src/infra/heartbeat-wake.ts @@ -15,6 +15,16 @@ export type HeartbeatWakeHandler = (opts: { sessionKey?: string; }) => Promise; +let heartbeatsEnabled = true; + +export function setHeartbeatsEnabled(enabled: boolean) { + heartbeatsEnabled = enabled; +} + +export function areHeartbeatsEnabled(): boolean { + return heartbeatsEnabled; +} + type WakeTimerKind = "normal" | "retry"; type PendingWakeReason = { reason: string; diff --git a/src/infra/outbound/cfg-threading.guard.test.ts b/src/infra/outbound/cfg-threading.guard.test.ts index 306170281c8..ff4d0533c1b 100644 --- a/src/infra/outbound/cfg-threading.guard.test.ts +++ b/src/infra/outbound/cfg-threading.guard.test.ts @@ -59,6 +59,15 @@ function listExtensionFiles(): { }; } +function listHighRiskRuntimeCfgFiles(): string[] { + return [ + "src/agents/tools/telegram-actions.ts", + "src/discord/monitor/reply-delivery.ts", + "src/discord/monitor/thread-bindings.discord-api.ts", + "src/discord/monitor/thread-bindings.manager.ts", + ]; +} + function extractOutboundBlock(source: string, file: string): string { const outboundKeyIndex = source.indexOf("outbound:"); expect(outboundKeyIndex, `${file} should define outbound:`).toBeGreaterThanOrEqual(0); @@ -176,4 +185,12 @@ describe("outbound cfg-threading guard", () => { ); } }); + + it("keeps high-risk runtime delivery paths free of loadConfig calls", () => { + const runtimeFiles = listHighRiskRuntimeCfgFiles(); + for (const file of runtimeFiles) { + const source = readRepoFile(file); + expect(source, `${file} must not call loadConfig`).not.toMatch(loadConfigPattern); + } + }); }); diff --git a/src/infra/plugin-install-path-warnings.test.ts b/src/infra/plugin-install-path-warnings.test.ts new file mode 100644 index 00000000000..6c24e57623f --- /dev/null +++ b/src/infra/plugin-install-path-warnings.test.ts @@ -0,0 +1,61 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { withTempHome } from "../../test/helpers/temp-home.js"; +import { + detectPluginInstallPathIssue, + formatPluginInstallPathIssue, +} from "./plugin-install-path-warnings.js"; + +describe("plugin install path warnings", () => { + it("detects stale custom plugin install paths", async () => { + const issue = await detectPluginInstallPathIssue({ + pluginId: "matrix", + install: { + source: "path", + sourcePath: "/tmp/openclaw-matrix-missing", + installPath: "/tmp/openclaw-matrix-missing", + }, + }); + + expect(issue).toEqual({ + kind: "missing-path", + pluginId: "matrix", + path: "/tmp/openclaw-matrix-missing", + }); + expect( + formatPluginInstallPathIssue({ + issue: issue!, + pluginLabel: "Matrix", + defaultInstallCommand: "openclaw plugins install @openclaw/matrix", + repoInstallCommand: "openclaw plugins install ./extensions/matrix", + }), + ).toEqual([ + "Matrix is installed from a custom path that no longer exists: /tmp/openclaw-matrix-missing", + 'Reinstall with "openclaw plugins install @openclaw/matrix".', + 'If you are running from a repo checkout, you can also use "openclaw plugins install ./extensions/matrix".', + ]); + }); + + it("detects active custom plugin install paths", async () => { + await withTempHome(async (home) => { + const pluginPath = path.join(home, "matrix-plugin"); + await fs.mkdir(pluginPath, { recursive: true }); + + const issue = await detectPluginInstallPathIssue({ + pluginId: "matrix", + install: { + source: "path", + sourcePath: pluginPath, + installPath: pluginPath, + }, + }); + + expect(issue).toEqual({ + kind: "custom-path", + pluginId: "matrix", + path: pluginPath, + }); + }); + }); +}); diff --git a/src/infra/plugin-install-path-warnings.ts b/src/infra/plugin-install-path-warnings.ts new file mode 100644 index 00000000000..8435a93e33f --- /dev/null +++ b/src/infra/plugin-install-path-warnings.ts @@ -0,0 +1,73 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { PluginInstallRecord } from "../config/types.plugins.js"; + +export type PluginInstallPathIssue = { + kind: "custom-path" | "missing-path"; + pluginId: string; + path: string; +}; + +function resolvePluginInstallCandidatePaths( + install: PluginInstallRecord | null | undefined, +): string[] { + if (!install || install.source !== "path") { + return []; + } + + return [install.sourcePath, install.installPath] + .map((value) => (typeof value === "string" ? value.trim() : "")) + .filter(Boolean); +} + +export async function detectPluginInstallPathIssue(params: { + pluginId: string; + install: PluginInstallRecord | null | undefined; +}): Promise { + const candidatePaths = resolvePluginInstallCandidatePaths(params.install); + if (candidatePaths.length === 0) { + return null; + } + + for (const candidatePath of candidatePaths) { + try { + await fs.access(path.resolve(candidatePath)); + return { + kind: "custom-path", + pluginId: params.pluginId, + path: candidatePath, + }; + } catch { + // Keep checking remaining candidate paths before warning about a stale install. + } + } + + return { + kind: "missing-path", + pluginId: params.pluginId, + path: candidatePaths[0] ?? "(unknown)", + }; +} + +export function formatPluginInstallPathIssue(params: { + issue: PluginInstallPathIssue; + pluginLabel: string; + defaultInstallCommand: string; + repoInstallCommand: string; + formatCommand?: (command: string) => string; +}): string[] { + const formatCommand = params.formatCommand ?? ((command: string) => command); + if (params.issue.kind === "custom-path") { + return [ + `${params.pluginLabel} is installed from a custom path: ${params.issue.path}`, + `Main updates will not automatically replace that plugin with the repo's default ${params.pluginLabel} package.`, + `Reinstall with "${formatCommand(params.defaultInstallCommand)}" when you want to return to the standard ${params.pluginLabel} plugin.`, + `If you are intentionally running from a repo checkout, reinstall that checkout explicitly with "${formatCommand(params.repoInstallCommand)}" after updates.`, + ]; + } + return [ + `${params.pluginLabel} is installed from a custom path that no longer exists: ${params.issue.path}`, + `Reinstall with "${formatCommand(params.defaultInstallCommand)}".`, + `If you are running from a repo checkout, you can also use "${formatCommand(params.repoInstallCommand)}".`, + ]; +} diff --git a/src/infra/provider-usage.auth.ts b/src/infra/provider-usage.auth.ts index 6afa4bebaad..3c6246d0786 100644 --- a/src/infra/provider-usage.auth.ts +++ b/src/infra/provider-usage.auth.ts @@ -9,7 +9,7 @@ import { resolveAuthProfileOrder, } from "../agents/auth-profiles.js"; import { isNonSecretApiKeyMarker } from "../agents/model-auth-markers.js"; -import { getCustomProviderApiKey } from "../agents/model-auth.js"; +import { resolveUsableCustomProviderApiKey } from "../agents/model-auth.js"; import { normalizeProviderId } from "../agents/model-selection.js"; import { loadConfig } from "../config/config.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; @@ -42,7 +42,9 @@ function resolveZaiApiKey(): string | undefined { } const cfg = loadConfig(); - const key = getCustomProviderApiKey(cfg, "zai") || getCustomProviderApiKey(cfg, "z-ai"); + const key = + resolveUsableCustomProviderApiKey({ cfg, provider: "zai" })?.apiKey ?? + resolveUsableCustomProviderApiKey({ cfg, provider: "z-ai" })?.apiKey; if (key) { return key; } @@ -103,8 +105,11 @@ function resolveProviderApiKeyFromConfigAndStore(params: { } const cfg = loadConfig(); - const key = getCustomProviderApiKey(cfg, params.providerId); - if (key && !isNonSecretApiKeyMarker(key)) { + const key = resolveUsableCustomProviderApiKey({ + cfg, + provider: params.providerId, + })?.apiKey; + if (key) { return key; } diff --git a/src/infra/secret-file.test.ts b/src/infra/secret-file.test.ts new file mode 100644 index 00000000000..788b4c75e23 --- /dev/null +++ b/src/infra/secret-file.test.ts @@ -0,0 +1,70 @@ +import { mkdir, symlink, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js"; +import { + DEFAULT_SECRET_FILE_MAX_BYTES, + readSecretFileSync, + tryReadSecretFileSync, +} from "./secret-file.js"; + +const tempDirs = createTrackedTempDirs(); +const createTempDir = () => tempDirs.make("openclaw-secret-file-test-"); + +afterEach(async () => { + await tempDirs.cleanup(); +}); + +describe("readSecretFileSync", () => { + it("reads and trims a regular secret file", async () => { + const dir = await createTempDir(); + const file = path.join(dir, "secret.txt"); + await writeFile(file, " top-secret \n", "utf8"); + + expect(readSecretFileSync(file, "Gateway password")).toBe("top-secret"); + }); + + it("rejects files larger than the secret-file limit", async () => { + const dir = await createTempDir(); + const file = path.join(dir, "secret.txt"); + await writeFile(file, "x".repeat(DEFAULT_SECRET_FILE_MAX_BYTES + 1), "utf8"); + + expect(() => readSecretFileSync(file, "Gateway password")).toThrow( + `Gateway password file at ${file} exceeds ${DEFAULT_SECRET_FILE_MAX_BYTES} bytes.`, + ); + }); + + it("rejects non-regular files", async () => { + const dir = await createTempDir(); + const nestedDir = path.join(dir, "secret-dir"); + await mkdir(nestedDir); + + expect(() => readSecretFileSync(nestedDir, "Gateway password")).toThrow( + `Gateway password file at ${nestedDir} must be a regular file.`, + ); + }); + + it("rejects symlinks when configured", async () => { + const dir = await createTempDir(); + const target = path.join(dir, "target.txt"); + const link = path.join(dir, "secret-link.txt"); + await writeFile(target, "top-secret\n", "utf8"); + await symlink(target, link); + + expect(() => readSecretFileSync(link, "Gateway password", { rejectSymlink: true })).toThrow( + `Gateway password file at ${link} must not be a symlink.`, + ); + }); + + it("returns undefined from the non-throwing helper for rejected files", async () => { + const dir = await createTempDir(); + const target = path.join(dir, "target.txt"); + const link = path.join(dir, "secret-link.txt"); + await writeFile(target, "top-secret\n", "utf8"); + await symlink(target, link); + + expect(tryReadSecretFileSync(link, "Telegram bot token", { rejectSymlink: true })).toBe( + undefined, + ); + }); +}); diff --git a/src/infra/secret-file.ts b/src/infra/secret-file.ts new file mode 100644 index 00000000000..d62fb326d6b --- /dev/null +++ b/src/infra/secret-file.ts @@ -0,0 +1,133 @@ +import fs from "node:fs"; +import { resolveUserPath } from "../utils.js"; +import { openVerifiedFileSync } from "./safe-open-sync.js"; + +export const DEFAULT_SECRET_FILE_MAX_BYTES = 16 * 1024; + +export type SecretFileReadOptions = { + maxBytes?: number; + rejectSymlink?: boolean; +}; + +export type SecretFileReadResult = + | { + ok: true; + secret: string; + resolvedPath: string; + } + | { + ok: false; + message: string; + resolvedPath?: string; + error?: unknown; + }; + +export function loadSecretFileSync( + filePath: string, + label: string, + options: SecretFileReadOptions = {}, +): SecretFileReadResult { + const trimmedPath = filePath.trim(); + const resolvedPath = resolveUserPath(trimmedPath); + if (!resolvedPath) { + return { ok: false, message: `${label} file path is empty.` }; + } + + const maxBytes = options.maxBytes ?? DEFAULT_SECRET_FILE_MAX_BYTES; + + let previewStat: fs.Stats; + try { + previewStat = fs.lstatSync(resolvedPath); + } catch (error) { + return { + ok: false, + resolvedPath, + error, + message: `Failed to inspect ${label} file at ${resolvedPath}: ${String(error)}`, + }; + } + + if (options.rejectSymlink && previewStat.isSymbolicLink()) { + return { + ok: false, + resolvedPath, + message: `${label} file at ${resolvedPath} must not be a symlink.`, + }; + } + if (!previewStat.isFile()) { + return { + ok: false, + resolvedPath, + message: `${label} file at ${resolvedPath} must be a regular file.`, + }; + } + if (previewStat.size > maxBytes) { + return { + ok: false, + resolvedPath, + message: `${label} file at ${resolvedPath} exceeds ${maxBytes} bytes.`, + }; + } + + const opened = openVerifiedFileSync({ + filePath: resolvedPath, + rejectPathSymlink: options.rejectSymlink, + maxBytes, + }); + if (!opened.ok) { + const error = + opened.reason === "validation" ? new Error("security validation failed") : opened.error; + return { + ok: false, + resolvedPath, + error, + message: `Failed to read ${label} file at ${resolvedPath}: ${String(error)}`, + }; + } + + try { + const raw = fs.readFileSync(opened.fd, "utf8"); + const secret = raw.trim(); + if (!secret) { + return { + ok: false, + resolvedPath, + message: `${label} file at ${resolvedPath} is empty.`, + }; + } + return { ok: true, secret, resolvedPath }; + } catch (error) { + return { + ok: false, + resolvedPath, + error, + message: `Failed to read ${label} file at ${resolvedPath}: ${String(error)}`, + }; + } finally { + fs.closeSync(opened.fd); + } +} + +export function readSecretFileSync( + filePath: string, + label: string, + options: SecretFileReadOptions = {}, +): string { + const result = loadSecretFileSync(filePath, label, options); + if (result.ok) { + return result.secret; + } + throw new Error(result.message, result.error ? { cause: result.error } : undefined); +} + +export function tryReadSecretFileSync( + filePath: string | undefined, + label: string, + options: SecretFileReadOptions = {}, +): string | undefined { + if (!filePath?.trim()) { + return undefined; + } + const result = loadSecretFileSync(filePath, label, options); + return result.ok ? result.secret : undefined; +} diff --git a/src/line/accounts.test.ts b/src/line/accounts.test.ts index 6a4770c379a..06433f6f8e7 100644 --- a/src/line/accounts.test.ts +++ b/src/line/accounts.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { @@ -97,6 +100,33 @@ describe("LINE accounts", () => { expect(account.channelSecret).toBe(""); expect(account.tokenSource).toBe("none"); }); + + it.runIf(process.platform !== "win32")("rejects symlinked token and secret files", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-line-account-")); + const tokenFile = path.join(dir, "token.txt"); + const tokenLink = path.join(dir, "token-link.txt"); + const secretFile = path.join(dir, "secret.txt"); + const secretLink = path.join(dir, "secret-link.txt"); + fs.writeFileSync(tokenFile, "file-token\n", "utf8"); + fs.writeFileSync(secretFile, "file-secret\n", "utf8"); + fs.symlinkSync(tokenFile, tokenLink); + fs.symlinkSync(secretFile, secretLink); + + const cfg: OpenClawConfig = { + channels: { + line: { + tokenFile: tokenLink, + secretFile: secretLink, + }, + }, + }; + + const account = resolveLineAccount({ cfg }); + expect(account.channelAccessToken).toBe(""); + expect(account.channelSecret).toBe(""); + expect(account.tokenSource).toBe("none"); + fs.rmSync(dir, { recursive: true, force: true }); + }); }); describe("resolveDefaultLineAccountId", () => { diff --git a/src/line/accounts.ts b/src/line/accounts.ts index 6e93cf40fea..9047ceccaa3 100644 --- a/src/line/accounts.ts +++ b/src/line/accounts.ts @@ -1,5 +1,5 @@ -import fs from "node:fs"; import type { OpenClawConfig } from "../config/config.js"; +import { tryReadSecretFileSync } from "../infra/secret-file.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId as normalizeSharedAccountId, @@ -16,14 +16,7 @@ import type { export { DEFAULT_ACCOUNT_ID } from "../routing/account-id.js"; function readFileIfExists(filePath: string | undefined): string | undefined { - if (!filePath) { - return undefined; - } - try { - return fs.readFileSync(filePath, "utf-8").trim(); - } catch { - return undefined; - } + return tryReadSecretFileSync(filePath, "LINE credential file", { rejectSymlink: true }); } function resolveToken(params: { diff --git a/src/plugin-sdk/acpx.ts b/src/plugin-sdk/acpx.ts index 7a719800227..36da2f48810 100644 --- a/src/plugin-sdk/acpx.ts +++ b/src/plugin-sdk/acpx.ts @@ -32,3 +32,7 @@ export { materializeWindowsSpawnProgram, resolveWindowsSpawnProgramCandidate, } from "./windows-spawn.js"; +export { + listKnownProviderAuthEnvVarNames, + omitEnvKeysCaseInsensitive, +} from "../secrets/provider-env-vars.js"; diff --git a/src/plugin-sdk/bluebubbles.ts b/src/plugin-sdk/bluebubbles.ts index 19f74c30c28..7b01eec368b 100644 --- a/src/plugin-sdk/bluebubbles.ts +++ b/src/plugin-sdk/bluebubbles.ts @@ -46,6 +46,7 @@ export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js export { applyAccountNameToChannelSection, migrateBaseNameToDefaultAccount, + patchScopedAccountConfig, } from "../channels/plugins/setup-helpers.js"; export { createAccountListHelpers } from "../channels/plugins/account-helpers.js"; export { collectBlueBubblesStatusIssues } from "../channels/plugins/status-issues/bluebubbles.js"; diff --git a/src/plugin-sdk/channel-config-helpers.ts b/src/plugin-sdk/channel-config-helpers.ts index afcd312f1c8..a0e9f25f3d8 100644 --- a/src/plugin-sdk/channel-config-helpers.ts +++ b/src/plugin-sdk/channel-config-helpers.ts @@ -2,6 +2,7 @@ import { deleteAccountFromConfigSection, setAccountEnabledInConfigSection, } from "../channels/plugins/config-helpers.js"; +import { buildAccountScopedDmSecurityPolicy } from "../channels/plugins/helpers.js"; import { normalizeWhatsAppAllowFromEntries } from "../channels/plugins/normalize/whatsapp.js"; import type { ChannelConfigAdapter } from "../channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../config/config.js"; @@ -104,6 +105,45 @@ export function createScopedChannelConfigBase< }; } +export function createScopedDmSecurityResolver< + ResolvedAccount extends { accountId?: string | null }, +>(params: { + channelKey: string; + resolvePolicy: (account: ResolvedAccount) => string | null | undefined; + resolveAllowFrom: (account: ResolvedAccount) => Array | null | undefined; + resolveFallbackAccountId?: (account: ResolvedAccount) => string | null | undefined; + defaultPolicy?: string; + allowFromPathSuffix?: string; + policyPathSuffix?: string; + approveChannelId?: string; + approveHint?: string; + normalizeEntry?: (raw: string) => string; +}) { + return ({ + cfg, + accountId, + account, + }: { + cfg: OpenClawConfig; + accountId?: string | null; + account: ResolvedAccount; + }) => + buildAccountScopedDmSecurityPolicy({ + cfg, + channelKey: params.channelKey, + accountId, + fallbackAccountId: params.resolveFallbackAccountId?.(account) ?? account.accountId, + policy: params.resolvePolicy(account), + allowFrom: params.resolveAllowFrom(account) ?? [], + defaultPolicy: params.defaultPolicy, + allowFromPathSuffix: params.allowFromPathSuffix, + policyPathSuffix: params.policyPathSuffix, + approveChannelId: params.approveChannelId, + approveHint: params.approveHint, + normalizeEntry: params.normalizeEntry, + }); +} + export function resolveWhatsAppConfigAllowFrom(params: { cfg: OpenClawConfig; accountId?: string | null; diff --git a/src/plugin-sdk/channel-lifecycle.test.ts b/src/plugin-sdk/channel-lifecycle.test.ts index 020510c914a..6295a5aedf9 100644 --- a/src/plugin-sdk/channel-lifecycle.test.ts +++ b/src/plugin-sdk/channel-lifecycle.test.ts @@ -1,6 +1,11 @@ import { EventEmitter } from "node:events"; import { describe, expect, it, vi } from "vitest"; -import { keepHttpServerTaskAlive, waitUntilAbort } from "./channel-lifecycle.js"; +import { + createAccountStatusSink, + keepHttpServerTaskAlive, + runPassiveAccountLifecycle, + waitUntilAbort, +} from "./channel-lifecycle.js"; type FakeServer = EventEmitter & { close: (callback?: () => void) => void; @@ -18,6 +23,22 @@ function createFakeServer(): FakeServer { } describe("plugin-sdk channel lifecycle helpers", () => { + it("binds account id onto status patches", () => { + const setStatus = vi.fn(); + const statusSink = createAccountStatusSink({ + accountId: "default", + setStatus, + }); + + statusSink({ running: true, lastStartAt: 123 }); + + expect(setStatus).toHaveBeenCalledWith({ + accountId: "default", + running: true, + lastStartAt: 123, + }); + }); + it("resolves waitUntilAbort when signal aborts", async () => { const abort = new AbortController(); const task = waitUntilAbort(abort.signal); @@ -32,6 +53,40 @@ describe("plugin-sdk channel lifecycle helpers", () => { await expect(task).resolves.toBeUndefined(); }); + it("runs abort cleanup before resolving", async () => { + const abort = new AbortController(); + const onAbort = vi.fn(async () => undefined); + + const task = waitUntilAbort(abort.signal, onAbort); + abort.abort(); + + await expect(task).resolves.toBeUndefined(); + expect(onAbort).toHaveBeenCalledOnce(); + }); + + it("keeps passive account lifecycle pending until abort, then stops once", async () => { + const abort = new AbortController(); + const stop = vi.fn(); + const task = runPassiveAccountLifecycle({ + abortSignal: abort.signal, + start: async () => ({ stop }), + stop: async (handle) => { + handle.stop(); + }, + }); + + const early = await Promise.race([ + task.then(() => "resolved"), + new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 25)), + ]); + expect(early).toBe("pending"); + expect(stop).not.toHaveBeenCalled(); + + abort.abort(); + await expect(task).resolves.toBeUndefined(); + expect(stop).toHaveBeenCalledOnce(); + }); + it("keeps server task pending until close, then resolves", async () => { const server = createFakeServer(); const task = keepHttpServerTaskAlive({ server }); diff --git a/src/plugin-sdk/channel-lifecycle.ts b/src/plugin-sdk/channel-lifecycle.ts index 4687e167352..7d4fea578d5 100644 --- a/src/plugin-sdk/channel-lifecycle.ts +++ b/src/plugin-sdk/channel-lifecycle.ts @@ -1,25 +1,66 @@ +import type { ChannelAccountSnapshot } from "../channels/plugins/types.core.js"; + type CloseAwareServer = { once: (event: "close", listener: () => void) => unknown; }; +type PassiveAccountLifecycleParams = { + abortSignal?: AbortSignal; + start: () => Promise; + stop?: (handle: Handle) => void | Promise; + onStop?: () => void | Promise; +}; + +export function createAccountStatusSink(params: { + accountId: string; + setStatus: (next: ChannelAccountSnapshot) => void; +}): (patch: Omit) => void { + return (patch) => { + params.setStatus({ accountId: params.accountId, ...patch }); + }; +} + /** * Return a promise that resolves when the signal is aborted. * - * If no signal is provided, the promise stays pending forever. + * If no signal is provided, the promise stays pending forever. When provided, + * `onAbort` runs once before the promise resolves. */ -export function waitUntilAbort(signal?: AbortSignal): Promise { - return new Promise((resolve) => { +export function waitUntilAbort( + signal?: AbortSignal, + onAbort?: () => void | Promise, +): Promise { + return new Promise((resolve, reject) => { + const complete = () => { + Promise.resolve(onAbort?.()).then(() => resolve(), reject); + }; if (!signal) { return; } if (signal.aborted) { - resolve(); + complete(); return; } - signal.addEventListener("abort", () => resolve(), { once: true }); + signal.addEventListener("abort", complete, { once: true }); }); } +/** + * Keep a passive account task alive until abort, then run optional cleanup. + */ +export async function runPassiveAccountLifecycle( + params: PassiveAccountLifecycleParams, +): Promise { + const handle = await params.start(); + + try { + await waitUntilAbort(params.abortSignal); + } finally { + await params.stop?.(handle); + await params.onStop?.(); + } +} + /** * Keep a channel/provider task pending until the HTTP server closes. * diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index d70ea17738f..5a74c6e089c 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -18,6 +18,13 @@ export { listDevicePairing, rejectDevicePairing, } from "../infra/device-pairing.js"; +export { + DEFAULT_SECRET_FILE_MAX_BYTES, + loadSecretFileSync, + readSecretFileSync, + tryReadSecretFileSync, +} from "../infra/secret-file.js"; +export type { SecretFileReadOptions, SecretFileReadResult } from "../infra/secret-file.js"; export { runPluginCommandWithTimeout, diff --git a/src/plugin-sdk/googlechat.ts b/src/plugin-sdk/googlechat.ts index 38d1594406a..17bc36daab1 100644 --- a/src/plugin-sdk/googlechat.ts +++ b/src/plugin-sdk/googlechat.ts @@ -20,6 +20,7 @@ export { } from "../channels/plugins/directory-config-helpers.js"; export { buildComputedAccountStatusSnapshot } from "./status-helpers.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; +export { createAccountStatusSink, runPassiveAccountLifecycle } from "./channel-lifecycle.js"; export { resolveGoogleChatGroupRequireMention } from "../channels/plugins/group-mentions.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 35709dc4fec..2aaafca8ccb 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -173,7 +173,12 @@ export { WEBHOOK_IN_FLIGHT_DEFAULTS, } from "./webhook-request-guards.js"; export type { WebhookBodyReadProfile, WebhookInFlightLimiter } from "./webhook-request-guards.js"; -export { keepHttpServerTaskAlive, waitUntilAbort } from "./channel-lifecycle.js"; +export { + createAccountStatusSink, + keepHttpServerTaskAlive, + runPassiveAccountLifecycle, + waitUntilAbort, +} from "./channel-lifecycle.js"; export type { AgentMediaPayload } from "./agent-media-payload.js"; export { buildAgentMediaPayload } from "./agent-media-payload.js"; export { @@ -198,11 +203,17 @@ export { createPluginRuntimeStore } from "./runtime-store.js"; export { createScopedChannelConfigBase } from "./channel-config-helpers.js"; export { AllowFromEntrySchema, + AllowFromListSchema, + buildNestedDmConfigSchema, buildCatchallMultiAccountChannelSchema, } from "../channels/plugins/config-schema.js"; export type { ChannelDock } from "../channels/dock.js"; export { getChatChannelMeta } from "../channels/registry.js"; -export { resolveAllowlistMatchByCandidates } from "../channels/allowlist-match.js"; +export { + compileAllowlist, + resolveAllowlistCandidates, + resolveAllowlistMatchByCandidates, +} from "../channels/allowlist-match.js"; export type { BlockStreamingCoalesceConfig, DmPolicy, @@ -390,6 +401,7 @@ export { formatTrimmedAllowFromEntries, mapAllowFromEntries, resolveOptionalConfigString, + createScopedDmSecurityResolver, formatWhatsAppConfigAllowFromEntries, resolveIMessageConfigAllowFrom, resolveIMessageConfigDefaultTo, @@ -546,7 +558,9 @@ export { } from "../channels/plugins/config-helpers.js"; export { applyAccountNameToChannelSection, + applySetupAccountConfigPatch, migrateBaseNameToDefaultAccount, + patchScopedAccountConfig, } from "../channels/plugins/setup-helpers.js"; export { buildOpenGroupPolicyConfigureRouteAllowlistWarning, diff --git a/src/plugin-sdk/irc.ts b/src/plugin-sdk/irc.ts index 969099ec3c1..7b2e6d07c8a 100644 --- a/src/plugin-sdk/irc.ts +++ b/src/plugin-sdk/irc.ts @@ -23,6 +23,7 @@ export { setTopLevelChannelDmPolicyWithAllowFrom, } from "../channels/plugins/onboarding/helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; +export { patchScopedAccountConfig } from "../channels/plugins/setup-helpers.js"; export type { BaseProbeResult } from "../channels/plugins/types.js"; export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export { getChatChannelMeta } from "../channels/registry.js"; @@ -60,6 +61,7 @@ export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; export { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; export type { RuntimeEnv } from "../runtime.js"; +export { createAccountStatusSink, runPassiveAccountLifecycle } from "./channel-lifecycle.js"; export { readStoreAllowFromForDmPolicy, resolveEffectiveAllowFromLists, diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index c1c29a776a1..577a690a4cb 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -9,7 +9,11 @@ export { readStringParam, } from "../agents/tools/common.js"; export type { ReplyPayload } from "../auto-reply/types.js"; -export { resolveAllowlistMatchByCandidates } from "../channels/allowlist-match.js"; +export { + compileAllowlist, + resolveAllowlistCandidates, + resolveAllowlistMatchByCandidates, +} from "../channels/allowlist-match.js"; export { mergeAllowlist, summarizeMapping } from "../channels/allowlists/resolve-utils.js"; export { resolveControlCommandGate } from "../channels/command-gating.js"; export type { NormalizedLocation } from "../channels/location.js"; diff --git a/src/plugin-sdk/mattermost.ts b/src/plugin-sdk/mattermost.ts index 7b574dd3eeb..ac4c8a9b437 100644 --- a/src/plugin-sdk/mattermost.ts +++ b/src/plugin-sdk/mattermost.ts @@ -33,6 +33,7 @@ export { buildSingleChannelSecretPromptState, promptAccountId, promptSingleChannelSecretInput, + runSingleChannelSecretStep, resolveAccountIdForConfigure, } from "../channels/plugins/onboarding/helpers.js"; export { @@ -40,6 +41,7 @@ export { applySetupAccountConfigPatch, migrateBaseNameToDefaultAccount, } from "../channels/plugins/setup-helpers.js"; +export { createAccountStatusSink } from "./channel-lifecycle.js"; export { buildComputedAccountStatusSnapshot } from "./status-helpers.js"; export { createAccountListHelpers } from "../channels/plugins/account-helpers.js"; export type { diff --git a/src/plugin-sdk/nextcloud-talk.ts b/src/plugin-sdk/nextcloud-talk.ts index 3f534a0ab5d..6e5c6a28b5b 100644 --- a/src/plugin-sdk/nextcloud-talk.ts +++ b/src/plugin-sdk/nextcloud-talk.ts @@ -27,10 +27,14 @@ export { mergeAllowFromEntries, promptAccountId, promptSingleChannelSecretInput, + runSingleChannelSecretStep, resolveAccountIdForConfigure, setTopLevelChannelDmPolicyWithAllowFrom, } from "../channels/plugins/onboarding/helpers.js"; -export { applyAccountNameToChannelSection } from "../channels/plugins/setup-helpers.js"; +export { + applyAccountNameToChannelSection, + patchScopedAccountConfig, +} from "../channels/plugins/setup-helpers.js"; export { createAccountListHelpers } from "../channels/plugins/account-helpers.js"; export type { ChannelGroupContext, ChannelSetupInput } from "../channels/plugins/types.js"; export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; diff --git a/src/plugin-sdk/secret-input-schema.test.ts b/src/plugin-sdk/secret-input-schema.test.ts new file mode 100644 index 00000000000..1a4463c830a --- /dev/null +++ b/src/plugin-sdk/secret-input-schema.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { + INVALID_EXEC_SECRET_REF_IDS, + VALID_EXEC_SECRET_REF_IDS, +} from "../test-utils/secret-ref-test-vectors.js"; +import { buildSecretInputSchema } from "./secret-input-schema.js"; + +describe("plugin-sdk secret input schema", () => { + const schema = buildSecretInputSchema(); + + it("accepts plaintext and valid refs", () => { + expect(schema.safeParse("sk-plain").success).toBe(true); + expect( + schema.safeParse({ source: "env", provider: "default", id: "OPENAI_API_KEY" }).success, + ).toBe(true); + expect( + schema.safeParse({ source: "file", provider: "filemain", id: "/providers/openai/apiKey" }) + .success, + ).toBe(true); + for (const id of VALID_EXEC_SECRET_REF_IDS) { + expect(schema.safeParse({ source: "exec", provider: "vault", id }).success, id).toBe(true); + } + }); + + it("rejects invalid exec refs", () => { + for (const id of INVALID_EXEC_SECRET_REF_IDS) { + expect(schema.safeParse({ source: "exec", provider: "vault", id }).success, id).toBe(false); + } + }); +}); diff --git a/src/plugin-sdk/secret-input-schema.ts b/src/plugin-sdk/secret-input-schema.ts index d5eb3a0767e..579d80df441 100644 --- a/src/plugin-sdk/secret-input-schema.ts +++ b/src/plugin-sdk/secret-input-schema.ts @@ -1,12 +1,48 @@ import { z } from "zod"; +import { ENV_SECRET_REF_ID_RE } from "../config/types.secrets.js"; +import { + formatExecSecretRefIdValidationMessage, + isValidExecSecretRefId, + isValidFileSecretRefId, + SECRET_PROVIDER_ALIAS_PATTERN, +} from "../secrets/ref-contract.js"; export function buildSecretInputSchema() { + const providerSchema = z + .string() + .regex( + SECRET_PROVIDER_ALIAS_PATTERN, + 'Secret reference provider must match /^[a-z][a-z0-9_-]{0,63}$/ (example: "default").', + ); + return z.union([ z.string(), - z.object({ - source: z.enum(["env", "file", "exec"]), - provider: z.string().min(1), - id: z.string().min(1), - }), + z.discriminatedUnion("source", [ + z.object({ + source: z.literal("env"), + provider: providerSchema, + id: z + .string() + .regex( + ENV_SECRET_REF_ID_RE, + 'Env secret reference id must match /^[A-Z][A-Z0-9_]{0,127}$/ (example: "OPENAI_API_KEY").', + ), + }), + z.object({ + source: z.literal("file"), + provider: providerSchema, + id: z + .string() + .refine( + isValidFileSecretRefId, + 'File secret reference id must be an absolute JSON pointer (example: "/providers/openai/apiKey"), or "value" for singleValue mode.', + ), + }), + z.object({ + source: z.literal("exec"), + provider: providerSchema, + id: z.string().refine(isValidExecSecretRefId, formatExecSecretRefIdValidationMessage()), + }), + ]), ]); } diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index aff93389421..ccdcd1eeb5e 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -98,6 +98,12 @@ describe("plugin-sdk subpath exports", () => { expect(typeof msteamsSdk.loadOutboundMediaFromUrl).toBe("function"); }); + it("exports acpx helpers", async () => { + const acpxSdk = await import("openclaw/plugin-sdk/acpx"); + expect(typeof acpxSdk.listKnownProviderAuthEnvVarNames).toBe("function"); + expect(typeof acpxSdk.omitEnvKeysCaseInsensitive).toBe("function"); + }); + it("resolves bundled extension subpaths", async () => { for (const { id, load } of bundledExtensionSubpathLoaders) { const mod = await load(); diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index 53167998404..cdbfc317208 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -53,6 +53,7 @@ export { parseTelegramThreadId, } from "../telegram/outbound-params.js"; export { collectTelegramStatusIssues } from "../channels/plugins/status-issues/telegram.js"; +export { sendTelegramPayloadMessages } from "../channels/plugins/outbound/telegram.js"; export { resolveAllowlistProviderRuntimeGroupPolicy, diff --git a/src/plugin-sdk/tlon.ts b/src/plugin-sdk/tlon.ts index 6858bde8bff..06ddcc8e256 100644 --- a/src/plugin-sdk/tlon.ts +++ b/src/plugin-sdk/tlon.ts @@ -8,7 +8,10 @@ export { promptAccountId, resolveAccountIdForConfigure, } from "../channels/plugins/onboarding/helpers.js"; -export { applyAccountNameToChannelSection } from "../channels/plugins/setup-helpers.js"; +export { + applyAccountNameToChannelSection, + patchScopedAccountConfig, +} from "../channels/plugins/setup-helpers.js"; export type { ChannelAccountSnapshot, ChannelOutboundAdapter, diff --git a/src/plugin-sdk/zalo.ts b/src/plugin-sdk/zalo.ts index 2196493009e..b5c69486f60 100644 --- a/src/plugin-sdk/zalo.ts +++ b/src/plugin-sdk/zalo.ts @@ -21,6 +21,7 @@ export { mergeAllowFromEntries, promptAccountId, promptSingleChannelSecretInput, + runSingleChannelSecretStep, resolveAccountIdForConfigure, setTopLevelChannelDmPolicyWithAllowFrom, } from "../channels/plugins/onboarding/helpers.js"; diff --git a/src/plugin-sdk/zalouser.ts b/src/plugin-sdk/zalouser.ts index fc1c6aebfc0..cb18efb4e32 100644 --- a/src/plugin-sdk/zalouser.ts +++ b/src/plugin-sdk/zalouser.ts @@ -27,6 +27,7 @@ export { applyAccountNameToChannelSection, applySetupAccountConfigPatch, migrateBaseNameToDefaultAccount, + patchScopedAccountConfig, } from "../channels/plugins/setup-helpers.js"; export { createAccountListHelpers } from "../channels/plugins/account-helpers.js"; export type { diff --git a/src/process/exec.ts b/src/process/exec.ts index 3464a083894..7692fb9ad21 100644 --- a/src/process/exec.ts +++ b/src/process/exec.ts @@ -7,6 +7,7 @@ import { danger, shouldLogVerbose } from "../globals.js"; import { markOpenClawExecEnv } from "../infra/openclaw-exec-env.js"; import { logDebug, logError } from "../logger.js"; import { resolveCommandStdio } from "./spawn-utils.js"; +import { resolveWindowsCommandShim } from "./windows-command.js"; const execFileAsync = promisify(execFile); @@ -76,19 +77,10 @@ function resolveNpmArgvForWindows(argv: string[]): string[] | null { * are handled by resolveNpmArgvForWindows to avoid spawn EINVAL (no direct .cmd). */ function resolveCommand(command: string): string { - if (process.platform !== "win32") { - return command; - } - const basename = path.basename(command).toLowerCase(); - const ext = path.extname(basename); - if (ext) { - return command; - } - const cmdCommands = ["pnpm", "yarn"]; - if (cmdCommands.includes(basename)) { - return `${command}.cmd`; - } - return command; + return resolveWindowsCommandShim({ + command, + cmdCommands: ["pnpm", "yarn"], + }); } export function shouldSpawnWithShell(params: { diff --git a/src/process/supervisor/adapters/child.ts b/src/process/supervisor/adapters/child.ts index 44275df6e64..04d7e1d7aa1 100644 --- a/src/process/supervisor/adapters/child.ts +++ b/src/process/supervisor/adapters/child.ts @@ -1,22 +1,15 @@ import type { ChildProcessWithoutNullStreams, SpawnOptions } from "node:child_process"; import { killProcessTree } from "../../kill-tree.js"; import { spawnWithFallback } from "../../spawn-utils.js"; +import { resolveWindowsCommandShim } from "../../windows-command.js"; import type { ManagedRunStdin, SpawnProcessAdapter } from "../types.js"; import { toStringEnv } from "./env.js"; function resolveCommand(command: string): string { - if (process.platform !== "win32") { - return command; - } - const lower = command.toLowerCase(); - if (lower.endsWith(".exe") || lower.endsWith(".cmd") || lower.endsWith(".bat")) { - return command; - } - const basename = lower.split(/[\\/]/).pop() ?? lower; - if (basename === "npm" || basename === "pnpm" || basename === "yarn" || basename === "npx") { - return `${command}.cmd`; - } - return command; + return resolveWindowsCommandShim({ + command, + cmdCommands: ["npm", "pnpm", "yarn", "npx"], + }); } export type ChildAdapter = SpawnProcessAdapter; diff --git a/src/process/windows-command.test.ts b/src/process/windows-command.test.ts new file mode 100644 index 00000000000..47b1907fbf0 --- /dev/null +++ b/src/process/windows-command.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { resolveWindowsCommandShim } from "./windows-command.js"; + +describe("resolveWindowsCommandShim", () => { + it("leaves commands unchanged outside Windows", () => { + expect( + resolveWindowsCommandShim({ + command: "pnpm", + cmdCommands: ["pnpm"], + platform: "linux", + }), + ).toBe("pnpm"); + }); + + it("appends .cmd for configured Windows shims", () => { + expect( + resolveWindowsCommandShim({ + command: "pnpm", + cmdCommands: ["pnpm", "yarn"], + platform: "win32", + }), + ).toBe("pnpm.cmd"); + }); + + it("keeps explicit extensions on Windows", () => { + expect( + resolveWindowsCommandShim({ + command: "npm.cmd", + cmdCommands: ["npm", "npx"], + platform: "win32", + }), + ).toBe("npm.cmd"); + }); +}); diff --git a/src/process/windows-command.ts b/src/process/windows-command.ts new file mode 100644 index 00000000000..c8e5981e2ef --- /dev/null +++ b/src/process/windows-command.ts @@ -0,0 +1,20 @@ +import path from "node:path"; +import process from "node:process"; + +export function resolveWindowsCommandShim(params: { + command: string; + cmdCommands: readonly string[]; + platform?: NodeJS.Platform; +}): string { + if ((params.platform ?? process.platform) !== "win32") { + return params.command; + } + const basename = path.basename(params.command).toLowerCase(); + if (path.extname(basename)) { + return params.command; + } + if (params.cmdCommands.includes(basename)) { + return `${params.command}.cmd`; + } + return params.command; +} diff --git a/src/secrets/audit.test.ts b/src/secrets/audit.test.ts index b797494d54a..d71c9a46cd9 100644 --- a/src/secrets/audit.test.ts +++ b/src/secrets/audit.test.ts @@ -16,6 +16,7 @@ type AuditFixture = { }; const OPENAI_API_KEY_MARKER = "OPENAI_API_KEY"; // pragma: allowlist secret +const MAX_AUDIT_MODELS_JSON_BYTES = 5 * 1024 * 1024; async function writeJsonFile(filePath: string, value: unknown): Promise { await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); @@ -482,6 +483,73 @@ describe("secrets audit", () => { ).toBe(true); }); + it("reports non-regular models.json files as unresolved findings", async () => { + await fs.rm(fixture.modelsPath, { force: true }); + await fs.mkdir(fixture.modelsPath, { recursive: true }); + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => entry.code === "REF_UNRESOLVED" && entry.file === fixture.modelsPath, + ), + ).toBe(true); + }); + + it("reports oversized models.json as unresolved findings", async () => { + const oversizedApiKey = "a".repeat(MAX_AUDIT_MODELS_JSON_BYTES + 256); + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: oversizedApiKey, + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => entry.code === "REF_UNRESOLVED" && entry.file === fixture.modelsPath, + ), + ).toBe(true); + }); + + it("scans active agent-dir override models.json even when outside state dir", async () => { + const externalAgentDir = path.join(fixture.rootDir, "external-agent"); + const externalModelsPath = path.join(externalAgentDir, "models.json"); + await fs.mkdir(externalAgentDir, { recursive: true }); + await writeJsonFile(externalModelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: "sk-external-plaintext", // pragma: allowlist secret + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }); + + const report = await runSecretsAudit({ + env: { + ...fixture.env, + OPENCLAW_AGENT_DIR: externalAgentDir, + }, + }); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === externalModelsPath && + entry.jsonPath === "providers.openai.apiKey", + ), + ).toBe(true); + expect(report.filesScanned).toContain(externalModelsPath); + }); + it("does not flag non-sensitive routing headers in openclaw config", async () => { await writeJsonFile(fixture.configPath, { models: { diff --git a/src/secrets/audit.ts b/src/secrets/audit.ts index 3215b3ce855..15d7157acea 100644 --- a/src/secrets/audit.ts +++ b/src/secrets/audit.ts @@ -97,6 +97,7 @@ type AuditCollector = { }; const REF_RESOLVE_FALLBACK_CONCURRENCY = 8; +const MAX_AUDIT_MODELS_JSON_BYTES = 5 * 1024 * 1024; const ALWAYS_SENSITIVE_MODEL_PROVIDER_HEADER_NAMES = new Set([ "authorization", "proxy-authorization", @@ -369,7 +370,10 @@ function collectModelsJsonSecrets(params: { return; } params.collector.filesScanned.add(params.modelsJsonPath); - const parsedResult = readJsonObjectIfExists(params.modelsJsonPath); + const parsedResult = readJsonObjectIfExists(params.modelsJsonPath, { + requireRegularFile: true, + maxBytes: MAX_AUDIT_MODELS_JSON_BYTES, + }); if (parsedResult.error) { addFinding(params.collector, { code: "REF_UNRESOLVED", @@ -630,7 +634,7 @@ export async function runSecretsAudit( defaults, }); } - for (const modelsJsonPath of listAgentModelsJsonPaths(config, stateDir)) { + for (const modelsJsonPath of listAgentModelsJsonPaths(config, stateDir, env)) { collectModelsJsonSecrets({ modelsJsonPath, collector, diff --git a/src/secrets/configure.ts b/src/secrets/configure.ts index 0934c603c2d..a07d3b45903 100644 --- a/src/secrets/configure.ts +++ b/src/secrets/configure.ts @@ -20,7 +20,12 @@ import { } from "./configure-plan.js"; import type { SecretsApplyPlan } from "./plan.js"; import { PROVIDER_ENV_VARS } from "./provider-env-vars.js"; -import { isValidSecretProviderAlias, resolveDefaultSecretProviderAlias } from "./ref-contract.js"; +import { + formatExecSecretRefIdValidationMessage, + isValidExecSecretRefId, + isValidSecretProviderAlias, + resolveDefaultSecretProviderAlias, +} from "./ref-contract.js"; import { resolveSecretRefValue } from "./resolve.js"; import { assertExpectedResolvedSecretValue } from "./secret-value.js"; import { isRecord } from "./shared.js"; @@ -917,7 +922,16 @@ export async function runSecretsConfigureInteractive( await text({ message: "Secret id", initialValue: suggestedId, - validate: (value) => (String(value ?? "").trim().length > 0 ? undefined : "Required"), + validate: (value) => { + const trimmed = String(value ?? "").trim(); + if (!trimmed) { + return "Required"; + } + if (source === "exec" && !isValidExecSecretRefId(trimmed)) { + return formatExecSecretRefIdValidationMessage(); + } + return undefined; + }, }), "Secrets configure cancelled.", ); diff --git a/src/secrets/exec-secret-ref-id-parity.test.ts b/src/secrets/exec-secret-ref-id-parity.test.ts new file mode 100644 index 00000000000..c3d9cb10fbc --- /dev/null +++ b/src/secrets/exec-secret-ref-id-parity.test.ts @@ -0,0 +1,199 @@ +import AjvPkg from "ajv"; +import { describe, expect, it } from "vitest"; +import { validateConfigObjectRaw } from "../config/validation.js"; +import { SecretRefSchema as GatewaySecretRefSchema } from "../gateway/protocol/schema/primitives.js"; +import { buildSecretInputSchema } from "../plugin-sdk/secret-input-schema.js"; +import { + INVALID_EXEC_SECRET_REF_IDS, + VALID_EXEC_SECRET_REF_IDS, +} from "../test-utils/secret-ref-test-vectors.js"; +import { isSecretsApplyPlan } from "./plan.js"; +import { isValidExecSecretRefId } from "./ref-contract.js"; +import { materializePathTokens, parsePathPattern } from "./target-registry-pattern.js"; +import { listSecretTargetRegistryEntries } from "./target-registry.js"; + +describe("exec SecretRef id parity", () => { + const Ajv = AjvPkg as unknown as new (opts?: object) => import("ajv").default; + const ajv = new Ajv({ allErrors: true, strict: false }); + const validateGatewaySecretRef = ajv.compile(GatewaySecretRefSchema); + const pluginSdkSecretInput = buildSecretInputSchema(); + + function configAcceptsExecRef(id: string): boolean { + const result = validateConfigObjectRaw({ + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "exec", provider: "vault", id }, + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }, + }); + return result.ok; + } + + function planAcceptsExecRef(id: string): boolean { + return isSecretsApplyPlan({ + version: 1, + protocolVersion: 1, + generatedAt: "2026-03-10T00:00:00.000Z", + generatedBy: "manual", + targets: [ + { + type: "talk.apiKey", + path: "talk.apiKey", + pathSegments: ["talk", "apiKey"], + ref: { source: "exec", provider: "vault", id }, + }, + ], + }); + } + + for (const id of [...VALID_EXEC_SECRET_REF_IDS, ...INVALID_EXEC_SECRET_REF_IDS]) { + it(`keeps config/plan/gateway/plugin parity for exec id "${id}"`, () => { + const expected = isValidExecSecretRefId(id); + expect(configAcceptsExecRef(id)).toBe(expected); + expect(planAcceptsExecRef(id)).toBe(expected); + expect(validateGatewaySecretRef({ source: "exec", provider: "vault", id })).toBe(expected); + expect( + pluginSdkSecretInput.safeParse({ source: "exec", provider: "vault", id }).success, + ).toBe(expected); + }); + } + + function classifyTargetClass(id: string): string { + if (id.startsWith("auth-profiles.")) { + return "auth-profiles"; + } + if (id.startsWith("agents.")) { + return "agents"; + } + if (id.startsWith("channels.")) { + return "channels"; + } + if (id.startsWith("cron.")) { + return "cron"; + } + if (id.startsWith("gateway.auth.")) { + return "gateway.auth"; + } + if (id.startsWith("gateway.remote.")) { + return "gateway.remote"; + } + if (id.startsWith("messages.")) { + return "messages"; + } + if (id.startsWith("models.providers.") && id.includes(".headers.")) { + return "models.headers"; + } + if (id.startsWith("models.providers.")) { + return "models.apiKey"; + } + if (id.startsWith("skills.entries.")) { + return "skills"; + } + if (id.startsWith("talk.")) { + return "talk"; + } + if (id.startsWith("tools.web.fetch.")) { + return "tools.web.fetch"; + } + if (id.startsWith("tools.web.search.")) { + return "tools.web.search"; + } + return "unclassified"; + } + + function samplePathSegments(pathPattern: string): string[] { + const tokens = parsePathPattern(pathPattern); + const captures = tokens.flatMap((token) => { + if (token.kind === "literal") { + return []; + } + return [token.kind === "array" ? "0" : "sample"]; + }); + const segments = materializePathTokens(tokens, captures); + if (!segments) { + throw new Error(`failed to sample path segments for pattern "${pathPattern}"`); + } + return segments; + } + + const registryPlanTargets = listSecretTargetRegistryEntries().filter( + (entry) => entry.includeInPlan, + ); + const unclassifiedTargetIds = registryPlanTargets + .filter((entry) => classifyTargetClass(entry.id) === "unclassified") + .map((entry) => entry.id); + const sampledTargetsByClass = [ + ...new Set(registryPlanTargets.map((entry) => classifyTargetClass(entry.id))), + ] + .toSorted((a, b) => a.localeCompare(b)) + .map((className) => { + const candidates = registryPlanTargets + .filter((entry) => classifyTargetClass(entry.id) === className) + .toSorted((a, b) => a.id.localeCompare(b.id)); + const selected = candidates[0]; + if (!selected) { + throw new Error(`missing sampled target for class "${className}"`); + } + const pathSegments = samplePathSegments(selected.pathPattern); + return { + className, + id: selected.id, + type: selected.targetType, + configFile: selected.configFile, + pathSegments, + }; + }); + + function planAcceptsExecRefForSample(params: { + type: string; + configFile: "openclaw.json" | "auth-profiles.json"; + pathSegments: string[]; + id: string; + }): boolean { + return isSecretsApplyPlan({ + version: 1, + protocolVersion: 1, + generatedAt: "2026-03-10T00:00:00.000Z", + generatedBy: "manual", + targets: [ + { + type: params.type, + path: params.pathSegments.join("."), + pathSegments: params.pathSegments, + ref: { source: "exec", provider: "vault", id: params.id }, + ...(params.configFile === "auth-profiles.json" ? { agentId: "main" } : {}), + }, + ], + }); + } + + it("derives sampled class coverage from target registry metadata", () => { + expect(unclassifiedTargetIds).toEqual([]); + expect(sampledTargetsByClass.length).toBeGreaterThan(0); + }); + + for (const sample of sampledTargetsByClass) { + it(`rejects traversal-segment exec ids for sampled class "${sample.className}" (example: "${sample.id}")`, () => { + expect( + planAcceptsExecRefForSample({ + type: sample.type, + configFile: sample.configFile, + pathSegments: sample.pathSegments, + id: "vault/openai/apiKey", + }), + ).toBe(true); + expect( + planAcceptsExecRefForSample({ + type: sample.type, + configFile: sample.configFile, + pathSegments: sample.pathSegments, + id: "vault/../apiKey", + }), + ).toBe(false); + }); + } +}); diff --git a/src/secrets/plan.test.ts b/src/secrets/plan.test.ts index 01ee81ea551..ec4d2c8dcba 100644 --- a/src/secrets/plan.test.ts +++ b/src/secrets/plan.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it } from "vitest"; +import { + INVALID_EXEC_SECRET_REF_IDS, + VALID_EXEC_SECRET_REF_IDS, +} from "../test-utils/secret-ref-test-vectors.js"; import { isSecretsApplyPlan, resolveValidatedPlanTarget } from "./plan.js"; describe("secrets plan validation", () => { @@ -98,4 +102,44 @@ describe("secrets plan validation", () => { }); expect(withAgent).toBe(true); }); + + it("accepts valid exec secret ref ids in plans", () => { + for (const id of VALID_EXEC_SECRET_REF_IDS) { + const isValid = isSecretsApplyPlan({ + version: 1, + protocolVersion: 1, + generatedAt: "2026-03-10T00:00:00.000Z", + generatedBy: "manual", + targets: [ + { + type: "talk.apiKey", + path: "talk.apiKey", + pathSegments: ["talk", "apiKey"], + ref: { source: "exec", provider: "vault", id }, + }, + ], + }); + expect(isValid, `expected valid plan exec ref id: ${id}`).toBe(true); + } + }); + + it("rejects invalid exec secret ref ids in plans", () => { + for (const id of INVALID_EXEC_SECRET_REF_IDS) { + const isValid = isSecretsApplyPlan({ + version: 1, + protocolVersion: 1, + generatedAt: "2026-03-10T00:00:00.000Z", + generatedBy: "manual", + targets: [ + { + type: "talk.apiKey", + path: "talk.apiKey", + pathSegments: ["talk", "apiKey"], + ref: { source: "exec", provider: "vault", id }, + }, + ], + }); + expect(isValid, `expected invalid plan exec ref id: ${id}`).toBe(false); + } + }); }); diff --git a/src/secrets/plan.ts b/src/secrets/plan.ts index 3101e1b7828..4f576a4f25f 100644 --- a/src/secrets/plan.ts +++ b/src/secrets/plan.ts @@ -1,6 +1,6 @@ import type { SecretProviderConfig, SecretRef } from "../config/types.secrets.js"; import { SecretProviderSchema } from "../config/zod-schema.core.js"; -import { isValidSecretProviderAlias } from "./ref-contract.js"; +import { isValidExecSecretRefId, isValidSecretProviderAlias } from "./ref-contract.js"; import { parseDotPath, toDotPath } from "./shared.js"; import { isKnownSecretTargetType, @@ -140,7 +140,8 @@ export function isSecretsApplyPlan(value: unknown): value is SecretsApplyPlan { typeof ref.provider !== "string" || ref.provider.trim().length === 0 || typeof ref.id !== "string" || - ref.id.trim().length === 0 + ref.id.trim().length === 0 || + (ref.source === "exec" && !isValidExecSecretRefId(ref.id)) ) { return false; } diff --git a/src/secrets/provider-env-vars.test.ts b/src/secrets/provider-env-vars.test.ts new file mode 100644 index 00000000000..6e5b78f6643 --- /dev/null +++ b/src/secrets/provider-env-vars.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { + listKnownProviderAuthEnvVarNames, + listKnownSecretEnvVarNames, + omitEnvKeysCaseInsensitive, +} from "./provider-env-vars.js"; + +describe("provider env vars", () => { + it("keeps the auth scrub list broader than the global secret env list", () => { + expect(listKnownProviderAuthEnvVarNames()).toEqual( + expect.arrayContaining(["GITHUB_TOKEN", "GH_TOKEN", "ANTHROPIC_OAUTH_TOKEN"]), + ); + expect(listKnownSecretEnvVarNames()).not.toEqual(listKnownProviderAuthEnvVarNames()); + expect(listKnownSecretEnvVarNames()).not.toEqual( + expect.arrayContaining(["GITHUB_TOKEN", "GH_TOKEN", "ANTHROPIC_OAUTH_TOKEN"]), + ); + expect(listKnownSecretEnvVarNames()).not.toContain("OPENCLAW_API_KEY"); + }); + + it("omits env keys case-insensitively", () => { + const env = omitEnvKeysCaseInsensitive( + { + OpenAI_Api_Key: "openai-secret", + Github_Token: "gh-secret", + OPENCLAW_API_KEY: "keep-me", + }, + ["OPENAI_API_KEY", "GITHUB_TOKEN"], + ); + + expect(env.OpenAI_Api_Key).toBeUndefined(); + expect(env.Github_Token).toBeUndefined(); + expect(env.OPENCLAW_API_KEY).toBe("keep-me"); + }); +}); diff --git a/src/secrets/provider-env-vars.ts b/src/secrets/provider-env-vars.ts index 9d2100d1852..866fa6c33f7 100644 --- a/src/secrets/provider-env-vars.ts +++ b/src/secrets/provider-env-vars.ts @@ -21,10 +21,67 @@ export const PROVIDER_ENV_VARS: Record = { xai: ["XAI_API_KEY"], mistral: ["MISTRAL_API_KEY"], kilocode: ["KILOCODE_API_KEY"], + modelstudio: ["MODELSTUDIO_API_KEY"], volcengine: ["VOLCANO_ENGINE_API_KEY"], byteplus: ["BYTEPLUS_API_KEY"], }; -export function listKnownSecretEnvVarNames(): string[] { - return [...new Set(Object.values(PROVIDER_ENV_VARS).flatMap((keys) => keys))]; +const EXTRA_PROVIDER_AUTH_ENV_VARS = [ + "VOYAGE_API_KEY", + "GROQ_API_KEY", + "DEEPGRAM_API_KEY", + "CEREBRAS_API_KEY", + "NVIDIA_API_KEY", + "COPILOT_GITHUB_TOKEN", + "GH_TOKEN", + "GITHUB_TOKEN", + "ANTHROPIC_OAUTH_TOKEN", + "CHUTES_OAUTH_TOKEN", + "CHUTES_API_KEY", + "QWEN_OAUTH_TOKEN", + "QWEN_PORTAL_API_KEY", + "MINIMAX_OAUTH_TOKEN", + "OLLAMA_API_KEY", + "VLLM_API_KEY", +] as const; + +const KNOWN_SECRET_ENV_VARS = [ + ...new Set(Object.values(PROVIDER_ENV_VARS).flatMap((keys) => keys)), +]; + +// OPENCLAW_API_KEY authenticates the local OpenClaw bridge itself and must +// remain available to child bridge/runtime processes. +const KNOWN_PROVIDER_AUTH_ENV_VARS = [ + ...new Set([...KNOWN_SECRET_ENV_VARS, ...EXTRA_PROVIDER_AUTH_ENV_VARS]), +]; + +export function listKnownProviderAuthEnvVarNames(): string[] { + return [...KNOWN_PROVIDER_AUTH_ENV_VARS]; +} + +export function listKnownSecretEnvVarNames(): string[] { + return [...KNOWN_SECRET_ENV_VARS]; +} + +export function omitEnvKeysCaseInsensitive( + baseEnv: NodeJS.ProcessEnv, + keys: Iterable, +): NodeJS.ProcessEnv { + const env = { ...baseEnv }; + const denied = new Set(); + for (const key of keys) { + const normalizedKey = key.trim(); + if (normalizedKey) { + denied.add(normalizedKey.toUpperCase()); + } + } + if (denied.size === 0) { + return env; + } + for (const actualKey of Object.keys(env)) { + if (denied.has(actualKey.toUpperCase())) { + delete env[actualKey]; + } + } + return env; } diff --git a/src/secrets/ref-contract.test.ts b/src/secrets/ref-contract.test.ts new file mode 100644 index 00000000000..2820ee71f46 --- /dev/null +++ b/src/secrets/ref-contract.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { + INVALID_EXEC_SECRET_REF_IDS, + VALID_EXEC_SECRET_REF_IDS, +} from "../test-utils/secret-ref-test-vectors.js"; +import { isValidExecSecretRefId, validateExecSecretRefId } from "./ref-contract.js"; + +describe("exec secret ref id validation", () => { + it("accepts valid exec secret ref ids", () => { + for (const id of VALID_EXEC_SECRET_REF_IDS) { + expect(isValidExecSecretRefId(id), `expected valid id: ${id}`).toBe(true); + expect(validateExecSecretRefId(id)).toEqual({ ok: true }); + } + }); + + it("rejects invalid exec secret ref ids", () => { + for (const id of INVALID_EXEC_SECRET_REF_IDS) { + expect(isValidExecSecretRefId(id), `expected invalid id: ${id}`).toBe(false); + expect(validateExecSecretRefId(id).ok).toBe(false); + } + }); + + it("reports traversal segment failures separately", () => { + expect(validateExecSecretRefId("a/../b")).toEqual({ + ok: false, + reason: "traversal-segment", + }); + expect(validateExecSecretRefId("a/./b")).toEqual({ + ok: false, + reason: "traversal-segment", + }); + }); +}); diff --git a/src/secrets/ref-contract.ts b/src/secrets/ref-contract.ts index cd08b40a847..946e9ca1bb3 100644 --- a/src/secrets/ref-contract.ts +++ b/src/secrets/ref-contract.ts @@ -6,8 +6,21 @@ import { const FILE_SECRET_REF_SEGMENT_PATTERN = /^(?:[^~]|~0|~1)*$/; export const SECRET_PROVIDER_ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/; +const EXEC_SECRET_REF_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/; export const SINGLE_VALUE_FILE_REF_ID = "value"; +export const FILE_SECRET_REF_ID_PATTERN = /^(?:value|\/(?:[^~]|~0|~1)*(?:\/(?:[^~]|~0|~1)*)*)$/; +export const EXEC_SECRET_REF_ID_JSON_SCHEMA_PATTERN = + "^(?!.*(?:^|/)\\.{1,2}(?:/|$))[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$"; + +export type ExecSecretRefIdValidationReason = "pattern" | "traversal-segment"; + +export type ExecSecretRefIdValidationResult = + | { ok: true } + | { + ok: false; + reason: ExecSecretRefIdValidationReason; + }; export type SecretRefDefaultsCarrier = { secrets?: { @@ -69,3 +82,27 @@ export function isValidFileSecretRefId(value: string): boolean { export function isValidSecretProviderAlias(value: string): boolean { return SECRET_PROVIDER_ALIAS_PATTERN.test(value); } + +export function validateExecSecretRefId(value: string): ExecSecretRefIdValidationResult { + if (!EXEC_SECRET_REF_ID_PATTERN.test(value)) { + return { ok: false, reason: "pattern" }; + } + for (const segment of value.split("/")) { + if (segment === "." || segment === "..") { + return { ok: false, reason: "traversal-segment" }; + } + } + return { ok: true }; +} + +export function isValidExecSecretRefId(value: string): boolean { + return validateExecSecretRefId(value).ok; +} + +export function formatExecSecretRefIdValidationMessage(): string { + return [ + "Exec secret reference id must match /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/", + 'and must not include "." or ".." path segments', + '(example: "vault/openai/api-key").', + ].join(" "); +} diff --git a/src/secrets/resolve.test.ts b/src/secrets/resolve.test.ts index 7b74e582b85..7f906d00919 100644 --- a/src/secrets/resolve.test.ts +++ b/src/secrets/resolve.test.ts @@ -3,7 +3,12 @@ import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { resolveSecretRefString, resolveSecretRefValue } from "./resolve.js"; +import { INVALID_EXEC_SECRET_REF_IDS } from "../test-utils/secret-ref-test-vectors.js"; +import { + resolveSecretRefString, + resolveSecretRefValue, + resolveSecretRefValues, +} from "./resolve.js"; async function writeSecureFile(filePath: string, content: string, mode = 0o600): Promise { await fs.mkdir(path.dirname(filePath), { recursive: true }); @@ -232,12 +237,16 @@ describe("secret ref resolver", () => { expect(value).toBe("plain-secret"); }); - itPosix("ignores EPIPE when exec provider exits before consuming stdin", async () => { - const oversizedId = `openai/${"x".repeat(120_000)}`; - await expect( - resolveSecretRefString( - { source: "exec", provider: "execmain", id: oversizedId }, - { + itPosix( + "tolerates stdin write errors when exec provider exits before consuming a large request", + async () => { + const refs = Array.from({ length: 256 }, (_, index) => ({ + source: "exec" as const, + provider: "execmain", + id: `openai/${String(index).padStart(3, "0")}/${"x".repeat(240)}`, + })); + await expect( + resolveSecretRefValues(refs, { config: { secrets: { providers: { @@ -248,10 +257,10 @@ describe("secret ref resolver", () => { }, }, }, - }, - ), - ).rejects.toThrow('Exec provider "execmain" returned empty stdout.'); - }); + }), + ).rejects.toThrow('Exec provider "execmain" returned empty stdout.'); + }, + ); itPosix("rejects symlink command paths unless allowSymlinkCommand is enabled", async () => { const root = await createCaseDir("exec-link-reject"); @@ -432,4 +441,17 @@ describe("secret ref resolver", () => { ), ).rejects.toThrow('has source "env" but ref requests "exec"'); }); + + it("rejects invalid exec ids before provider resolution", async () => { + for (const id of INVALID_EXEC_SECRET_REF_IDS) { + await expect( + resolveSecretRefValue( + { source: "exec", provider: "vault", id }, + { + config: {}, + }, + ), + ).rejects.toThrow(/Exec secret reference id must match|Secret reference id is empty/); + } + }); }); diff --git a/src/secrets/resolve.ts b/src/secrets/resolve.ts index 039875c464c..103075b8cd9 100644 --- a/src/secrets/resolve.ts +++ b/src/secrets/resolve.ts @@ -15,6 +15,8 @@ import { resolveUserPath } from "../utils.js"; import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js"; import { readJsonPointer } from "./json-pointer.js"; import { + formatExecSecretRefIdValidationMessage, + isValidExecSecretRefId, SINGLE_VALUE_FILE_REF_ID, resolveDefaultSecretProviderAlias, secretRefKey, @@ -843,6 +845,11 @@ export async function resolveSecretRefValues( if (!id) { throw new Error("Secret reference id is empty."); } + if (ref.source === "exec" && !isValidExecSecretRefId(id)) { + throw new Error( + `${formatExecSecretRefIdValidationMessage()} (ref: ${ref.source}:${ref.provider}:${id}).`, + ); + } uniqueRefs.set(secretRefKey(ref), { ...ref, id }); } diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index f03ce73da3e..47628f1bfe2 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -1134,6 +1134,29 @@ describe("secrets runtime snapshot", () => { ).rejects.toThrow(/MISSING_GATEWAY_TOKEN_REF/i); }); + it("fails when an active exec ref id contains traversal segments", async () => { + await expect( + prepareSecretsRuntimeSnapshot({ + config: asConfig({ + talk: { + apiKey: { source: "exec", provider: "vault", id: "a/../b" }, + }, + secrets: { + providers: { + vault: { + source: "exec", + command: process.execPath, + }, + }, + }, + }), + env: {}, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => ({ version: 1, profiles: {} }), + }), + ).rejects.toThrow(/must not include "\." or "\.\." path segments/i); + }); + it("treats gateway.auth.password ref as inactive when auth mode is trusted-proxy", async () => { const snapshot = await prepareSecretsRuntimeSnapshot({ config: asConfig({ diff --git a/src/secrets/storage-scan.ts b/src/secrets/storage-scan.ts index 557f611c006..a63ced10a9b 100644 --- a/src/secrets/storage-scan.ts +++ b/src/secrets/storage-scan.ts @@ -32,11 +32,25 @@ export function listLegacyAuthJsonPaths(stateDir: string): string[] { return out; } -export function listAgentModelsJsonPaths(config: OpenClawConfig, stateDir: string): string[] { - const paths = new Set(); - paths.add(path.join(resolveUserPath(stateDir), "agents", "main", "agent", "models.json")); +function resolveActiveAgentDir(stateDir: string, env: NodeJS.ProcessEnv = process.env): string { + const override = env.OPENCLAW_AGENT_DIR?.trim() || env.PI_CODING_AGENT_DIR?.trim(); + if (override) { + return resolveUserPath(override); + } + return path.join(resolveUserPath(stateDir), "agents", "main", "agent"); +} - const agentsRoot = path.join(resolveUserPath(stateDir), "agents"); +export function listAgentModelsJsonPaths( + config: OpenClawConfig, + stateDir: string, + env: NodeJS.ProcessEnv = process.env, +): string[] { + const resolvedStateDir = resolveUserPath(stateDir); + const paths = new Set(); + paths.add(path.join(resolvedStateDir, "agents", "main", "agent", "models.json")); + paths.add(path.join(resolveActiveAgentDir(stateDir, env), "models.json")); + + const agentsRoot = path.join(resolvedStateDir, "agents"); if (fs.existsSync(agentsRoot)) { for (const entry of fs.readdirSync(agentsRoot, { withFileTypes: true })) { if (!entry.isDirectory()) { @@ -48,7 +62,7 @@ export function listAgentModelsJsonPaths(config: OpenClawConfig, stateDir: strin for (const agentId of listAgentIds(config)) { if (agentId === "main") { - paths.add(path.join(resolveUserPath(stateDir), "agents", "main", "agent", "models.json")); + paths.add(path.join(resolvedStateDir, "agents", "main", "agent", "models.json")); continue; } const agentDir = resolveAgentDir(config, agentId); @@ -58,14 +72,51 @@ export function listAgentModelsJsonPaths(config: OpenClawConfig, stateDir: strin return [...paths]; } +export type ReadJsonObjectOptions = { + maxBytes?: number; + requireRegularFile?: boolean; +}; + export function readJsonObjectIfExists(filePath: string): { value: Record | null; error?: string; +}; +export function readJsonObjectIfExists( + filePath: string, + options: ReadJsonObjectOptions, +): { + value: Record | null; + error?: string; +}; +export function readJsonObjectIfExists( + filePath: string, + options: ReadJsonObjectOptions = {}, +): { + value: Record | null; + error?: string; } { if (!fs.existsSync(filePath)) { return { value: null }; } try { + const stats = fs.statSync(filePath); + if (options.requireRegularFile && !stats.isFile()) { + return { + value: null, + error: `Refusing to read non-regular file: ${filePath}`, + }; + } + if ( + typeof options.maxBytes === "number" && + Number.isFinite(options.maxBytes) && + options.maxBytes >= 0 && + stats.size > options.maxBytes + ) { + return { + value: null, + error: `Refusing to read oversized JSON (${stats.size} bytes): ${filePath}`, + }; + } const raw = fs.readFileSync(filePath, "utf8"); const parsed = JSON.parse(raw) as unknown; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { diff --git a/src/slack/monitor/allow-list.ts b/src/slack/monitor/allow-list.ts index bc552c02cf4..53fc6c58505 100644 --- a/src/slack/monitor/allow-list.ts +++ b/src/slack/monitor/allow-list.ts @@ -1,5 +1,6 @@ import { - resolveAllowlistMatchByCandidates, + compileAllowlist, + resolveAllowlistCandidates, type AllowlistMatch, } from "../../channels/allowlist-match.js"; import { @@ -56,11 +57,11 @@ export function resolveSlackAllowListMatch(params: { name?: string; allowNameMatching?: boolean; }): SlackAllowListMatch { - const allowList = params.allowList; - if (allowList.length === 0) { + const compiledAllowList = compileAllowlist(params.allowList); + if (compiledAllowList.set.size === 0) { return { allowed: false }; } - if (allowList.includes("*")) { + if (compiledAllowList.wildcard) { return { allowed: true, matchKey: "*", matchSource: "wildcard" }; } const id = params.id?.toLowerCase(); @@ -78,7 +79,10 @@ export function resolveSlackAllowListMatch(params: { ] satisfies Array<{ value?: string; source: SlackAllowListSource }>) : []), ]; - return resolveAllowlistMatchByCandidates({ allowList, candidates }); + return resolveAllowlistCandidates({ + compiledAllowlist: compiledAllowList, + candidates, + }); } export function allowListMatches(params: { diff --git a/src/slack/monitor/provider.group-policy.test.ts b/src/slack/monitor/provider.group-policy.test.ts index 29478d13e7a..e71e25eb565 100644 --- a/src/slack/monitor/provider.group-policy.test.ts +++ b/src/slack/monitor/provider.group-policy.test.ts @@ -1,29 +1,13 @@ -import { describe, expect, it } from "vitest"; +import { describe } from "vitest"; +import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../test-utils/runtime-group-policy-contract.js"; import { __testing } from "./provider.js"; describe("resolveSlackRuntimeGroupPolicy", () => { - it("fails closed when channels.slack is missing and no defaults are set", () => { - const resolved = __testing.resolveSlackRuntimeGroupPolicy({ - providerConfigPresent: false, - }); - expect(resolved.groupPolicy).toBe("allowlist"); - expect(resolved.providerMissingFallbackApplied).toBe(true); - }); - - it("keeps open default when channels.slack is configured", () => { - const resolved = __testing.resolveSlackRuntimeGroupPolicy({ - providerConfigPresent: true, - }); - expect(resolved.groupPolicy).toBe("open"); - expect(resolved.providerMissingFallbackApplied).toBe(false); - }); - - it("ignores explicit global defaults when provider config is missing", () => { - const resolved = __testing.resolveSlackRuntimeGroupPolicy({ - providerConfigPresent: false, - defaultGroupPolicy: "open", - }); - expect(resolved.groupPolicy).toBe("allowlist"); - expect(resolved.providerMissingFallbackApplied).toBe(true); + installProviderRuntimeGroupPolicyFallbackSuite({ + resolve: __testing.resolveSlackRuntimeGroupPolicy, + configuredLabel: "keeps open default when channels.slack is configured", + defaultGroupPolicyUnderTest: "open", + missingConfigLabel: "fails closed when channels.slack is missing and no defaults are set", + missingDefaultLabel: "ignores explicit global defaults when provider config is missing", }); }); diff --git a/src/telegram/account-inspect.test.ts b/src/telegram/account-inspect.test.ts index 83ad113202b..b25bd223667 100644 --- a/src/telegram/account-inspect.test.ts +++ b/src/telegram/account-inspect.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { withEnv } from "../test-utils/env.js"; @@ -76,4 +79,29 @@ describe("inspectTelegramAccount SecretRef resolution", () => { expect(account.token).toBe(""); }); }); + + it.runIf(process.platform !== "win32")( + "treats symlinked token files as configured_unavailable", + () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-inspect-")); + const tokenFile = path.join(dir, "token.txt"); + const tokenLink = path.join(dir, "token-link.txt"); + fs.writeFileSync(tokenFile, "123:token\n", "utf8"); + fs.symlinkSync(tokenFile, tokenLink); + + const cfg: OpenClawConfig = { + channels: { + telegram: { + tokenFile: tokenLink, + }, + }, + }; + + const account = inspectTelegramAccount({ cfg, accountId: "default" }); + expect(account.tokenSource).toBe("tokenFile"); + expect(account.tokenStatus).toBe("configured_unavailable"); + expect(account.token).toBe(""); + fs.rmSync(dir, { recursive: true, force: true }); + }, + ); }); diff --git a/src/telegram/account-inspect.ts b/src/telegram/account-inspect.ts index 0ffbe0281ff..2db9db06e3e 100644 --- a/src/telegram/account-inspect.ts +++ b/src/telegram/account-inspect.ts @@ -1,4 +1,3 @@ -import fs from "node:fs"; import type { OpenClawConfig } from "../config/config.js"; import { coerceSecretRef, @@ -6,6 +5,7 @@ import { normalizeSecretInputString, } from "../config/types.secrets.js"; import type { TelegramAccountConfig } from "../config/types.telegram.js"; +import { tryReadSecretFileSync } from "../infra/secret-file.js"; import { resolveAccountWithDefaultFallback } from "../plugin-sdk/account-resolution.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; import { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js"; @@ -37,27 +37,14 @@ function inspectTokenFile(pathValue: unknown): { if (!tokenFile) { return null; } - if (!fs.existsSync(tokenFile)) { - return { - token: "", - tokenSource: "tokenFile", - tokenStatus: "configured_unavailable", - }; - } - try { - const token = fs.readFileSync(tokenFile, "utf-8").trim(); - return { - token, - tokenSource: "tokenFile", - tokenStatus: token ? "available" : "configured_unavailable", - }; - } catch { - return { - token: "", - tokenSource: "tokenFile", - tokenStatus: "configured_unavailable", - }; - } + const token = tryReadSecretFileSync(tokenFile, "Telegram bot token", { + rejectSymlink: true, + }); + return { + token: token ?? "", + tokenSource: "tokenFile", + tokenStatus: token ? "available" : "configured_unavailable", + }; } function canResolveEnvSecretRefInReadOnlyPath(params: { diff --git a/src/telegram/bot-access.ts b/src/telegram/bot-access.ts index d08a54616f0..60b3f5582a9 100644 --- a/src/telegram/bot-access.ts +++ b/src/telegram/bot-access.ts @@ -31,7 +31,8 @@ function warnInvalidAllowFromEntries(entries: string[]) { [ "Invalid allowFrom entry:", JSON.stringify(entry), - "- allowFrom/groupAllowFrom authorization requires numeric Telegram sender IDs only.", + "- allowFrom/groupAllowFrom authorization expects numeric Telegram sender user IDs only.", + 'To allow a Telegram group or supergroup, add its negative chat ID under "channels.telegram.groups" instead.', 'If you had "@username" entries, re-run onboarding (it resolves @username to IDs) or replace them manually.', ].join(" "), ); diff --git a/src/telegram/exec-approvals-handler.ts b/src/telegram/exec-approvals-handler.ts index cc3d735e6a6..1a2e4ce21ce 100644 --- a/src/telegram/exec-approvals-handler.ts +++ b/src/telegram/exec-approvals-handler.ts @@ -1,8 +1,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; -import { buildGatewayConnectionDetails } from "../gateway/call.js"; import { GatewayClient } from "../gateway/client.js"; -import { resolveGatewayConnectionAuth } from "../gateway/connection-auth.js"; +import { createOperatorApprovalsGatewayClient } from "../gateway/operator-approvals-client.js"; import type { EventFrame } from "../gateway/protocol/index.js"; import { buildExecApprovalPendingReplyPayload, @@ -14,7 +13,6 @@ import { createSubsystemLogger } from "../logging/subsystem.js"; import { normalizeAccountId, parseAgentSessionKey } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import { compileSafeRegex, testRegexWithBoundedInput } from "../security/safe-regex.js"; -import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { buildTelegramExecApprovalButtons } from "./approval-buttons.js"; import { getTelegramExecApprovalApprovers, @@ -248,31 +246,10 @@ export class TelegramExecApprovalHandler { return; } - const { url: gatewayUrl, urlSource } = buildGatewayConnectionDetails({ + this.gatewayClient = await createOperatorApprovalsGatewayClient({ config: this.opts.cfg, - url: this.opts.gatewayUrl, - }); - const gatewayUrlOverrideSource = - urlSource === "cli --url" - ? "cli" - : urlSource === "env OPENCLAW_GATEWAY_URL" - ? "env" - : undefined; - const auth = await resolveGatewayConnectionAuth({ - config: this.opts.cfg, - env: process.env, - urlOverride: gatewayUrlOverrideSource ? gatewayUrl : undefined, - urlOverrideSource: gatewayUrlOverrideSource, - }); - - this.gatewayClient = new GatewayClient({ - url: gatewayUrl, - token: auth.token, - password: auth.password, - clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, + gatewayUrl: this.opts.gatewayUrl, clientDisplayName: `Telegram Exec Approvals (${this.opts.accountId})`, - mode: GATEWAY_CLIENT_MODES.BACKEND, - scopes: ["operator.approvals"], onEvent: (evt) => this.handleGatewayEvent(evt), onConnectError: (err) => { log.error(`telegram exec approvals: connect error: ${err.message}`); diff --git a/src/telegram/format.test.ts b/src/telegram/format.test.ts index ac4163b96f0..2fcd06663e0 100644 --- a/src/telegram/format.test.ts +++ b/src/telegram/format.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { markdownToTelegramHtml } from "./format.js"; +import { markdownToTelegramHtml, splitTelegramHtmlChunks } from "./format.js"; describe("markdownToTelegramHtml", () => { it("handles core markdown-to-telegram conversions", () => { @@ -112,4 +112,26 @@ describe("markdownToTelegramHtml", () => { expect(res).toContain("secret"); expect(res).toContain("trailing ||"); }); + + it("splits long multiline html text without breaking balanced tags", () => { + const chunks = splitTelegramHtmlChunks(`${"A\n".repeat(2500)}`, 4000); + expect(chunks.length).toBeGreaterThan(1); + expect(chunks.every((chunk) => chunk.length <= 4000)).toBe(true); + expect(chunks[0]).toMatch(/^[\s\S]*<\/b>$/); + expect(chunks[1]).toMatch(/^[\s\S]*<\/b>$/); + }); + + it("fails loudly when a leading entity cannot fit inside a chunk", () => { + expect(() => splitTelegramHtmlChunks(`A&${"B".repeat(20)}`, 4)).toThrow(/leading entity/i); + }); + + it("treats malformed leading ampersands as plain text when chunking html", () => { + const chunks = splitTelegramHtmlChunks(`&${"A".repeat(5000)}`, 4000); + expect(chunks.length).toBeGreaterThan(1); + expect(chunks.every((chunk) => chunk.length <= 4000)).toBe(true); + }); + + it("fails loudly when tag overhead leaves no room for text", () => { + expect(() => splitTelegramHtmlChunks("x", 10)).toThrow(/tag overhead/i); + }); }); diff --git a/src/telegram/format.ts b/src/telegram/format.ts index f74b508b42d..ed1f6c822f8 100644 --- a/src/telegram/format.ts +++ b/src/telegram/format.ts @@ -241,6 +241,217 @@ export function renderTelegramHtmlText( return markdownToTelegramHtml(text, { tableMode: options.tableMode }); } +type TelegramHtmlTag = { + name: string; + openTag: string; + closeTag: string; +}; + +const TELEGRAM_SELF_CLOSING_HTML_TAGS = new Set(["br"]); + +function buildTelegramHtmlOpenPrefix(tags: TelegramHtmlTag[]): string { + return tags.map((tag) => tag.openTag).join(""); +} + +function buildTelegramHtmlCloseSuffix(tags: TelegramHtmlTag[]): string { + return tags + .slice() + .toReversed() + .map((tag) => tag.closeTag) + .join(""); +} + +function buildTelegramHtmlCloseSuffixLength(tags: TelegramHtmlTag[]): number { + return tags.reduce((total, tag) => total + tag.closeTag.length, 0); +} + +function findTelegramHtmlEntityEnd(text: string, start: number): number { + if (text[start] !== "&") { + return -1; + } + let index = start + 1; + if (index >= text.length) { + return -1; + } + if (text[index] === "#") { + index += 1; + if (index >= text.length) { + return -1; + } + const isHex = text[index] === "x" || text[index] === "X"; + if (isHex) { + index += 1; + const hexStart = index; + while (/[0-9A-Fa-f]/.test(text[index] ?? "")) { + index += 1; + } + if (index === hexStart) { + return -1; + } + } else { + const digitStart = index; + while (/[0-9]/.test(text[index] ?? "")) { + index += 1; + } + if (index === digitStart) { + return -1; + } + } + } else { + const nameStart = index; + while (/[A-Za-z0-9]/.test(text[index] ?? "")) { + index += 1; + } + if (index === nameStart) { + return -1; + } + } + return text[index] === ";" ? index : -1; +} + +function findTelegramHtmlSafeSplitIndex(text: string, maxLength: number): number { + if (text.length <= maxLength) { + return text.length; + } + const normalizedMaxLength = Math.max(1, Math.floor(maxLength)); + const lastAmpersand = text.lastIndexOf("&", normalizedMaxLength - 1); + if (lastAmpersand === -1) { + return normalizedMaxLength; + } + const lastSemicolon = text.lastIndexOf(";", normalizedMaxLength - 1); + if (lastAmpersand < lastSemicolon) { + return normalizedMaxLength; + } + const entityEnd = findTelegramHtmlEntityEnd(text, lastAmpersand); + if (entityEnd === -1 || entityEnd < normalizedMaxLength) { + return normalizedMaxLength; + } + return lastAmpersand; +} + +function popTelegramHtmlTag(tags: TelegramHtmlTag[], name: string): void { + for (let index = tags.length - 1; index >= 0; index -= 1) { + if (tags[index]?.name === name) { + tags.splice(index, 1); + return; + } + } +} + +export function splitTelegramHtmlChunks(html: string, limit: number): string[] { + if (!html) { + return []; + } + const normalizedLimit = Math.max(1, Math.floor(limit)); + if (html.length <= normalizedLimit) { + return [html]; + } + + const chunks: string[] = []; + const openTags: TelegramHtmlTag[] = []; + let current = ""; + let chunkHasPayload = false; + + const resetCurrent = () => { + current = buildTelegramHtmlOpenPrefix(openTags); + chunkHasPayload = false; + }; + + const flushCurrent = () => { + if (!chunkHasPayload) { + return; + } + chunks.push(`${current}${buildTelegramHtmlCloseSuffix(openTags)}`); + resetCurrent(); + }; + + const appendText = (segment: string) => { + let remaining = segment; + while (remaining.length > 0) { + const available = + normalizedLimit - current.length - buildTelegramHtmlCloseSuffixLength(openTags); + if (available <= 0) { + if (!chunkHasPayload) { + throw new Error( + `Telegram HTML chunk limit exceeded by tag overhead (limit=${normalizedLimit})`, + ); + } + flushCurrent(); + continue; + } + if (remaining.length <= available) { + current += remaining; + chunkHasPayload = true; + break; + } + const splitAt = findTelegramHtmlSafeSplitIndex(remaining, available); + if (splitAt <= 0) { + if (!chunkHasPayload) { + throw new Error( + `Telegram HTML chunk limit exceeded by leading entity (limit=${normalizedLimit})`, + ); + } + flushCurrent(); + continue; + } + current += remaining.slice(0, splitAt); + chunkHasPayload = true; + remaining = remaining.slice(splitAt); + flushCurrent(); + } + }; + + resetCurrent(); + HTML_TAG_PATTERN.lastIndex = 0; + let lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = HTML_TAG_PATTERN.exec(html)) !== null) { + const tagStart = match.index; + const tagEnd = HTML_TAG_PATTERN.lastIndex; + appendText(html.slice(lastIndex, tagStart)); + + const rawTag = match[0]; + const isClosing = match[1] === "")); + + if (!isClosing) { + const nextCloseLength = isSelfClosing ? 0 : ``.length; + if ( + chunkHasPayload && + current.length + + rawTag.length + + buildTelegramHtmlCloseSuffixLength(openTags) + + nextCloseLength > + normalizedLimit + ) { + flushCurrent(); + } + } + + current += rawTag; + if (isSelfClosing) { + chunkHasPayload = true; + } + if (isClosing) { + popTelegramHtmlTag(openTags, tagName); + } else if (!isSelfClosing) { + openTags.push({ + name: tagName, + openTag: rawTag, + closeTag: ``, + }); + } + lastIndex = tagEnd; + } + + appendText(html.slice(lastIndex)); + flushCurrent(); + return chunks.length > 0 ? chunks : [html]; +} + function splitTelegramChunkByHtmlLimit( chunk: MarkdownIR, htmlLimit: number, diff --git a/src/telegram/group-access.group-policy.test.ts b/src/telegram/group-access.group-policy.test.ts index 9374230e1b1..07e05780536 100644 --- a/src/telegram/group-access.group-policy.test.ts +++ b/src/telegram/group-access.group-policy.test.ts @@ -1,29 +1,13 @@ -import { describe, expect, it } from "vitest"; +import { describe } from "vitest"; +import { installProviderRuntimeGroupPolicyFallbackSuite } from "../test-utils/runtime-group-policy-contract.js"; import { resolveTelegramRuntimeGroupPolicy } from "./group-access.js"; describe("resolveTelegramRuntimeGroupPolicy", () => { - it("fails closed when channels.telegram is missing and no defaults are set", () => { - const resolved = resolveTelegramRuntimeGroupPolicy({ - providerConfigPresent: false, - }); - expect(resolved.groupPolicy).toBe("allowlist"); - expect(resolved.providerMissingFallbackApplied).toBe(true); - }); - - it("keeps open fallback when channels.telegram is configured", () => { - const resolved = resolveTelegramRuntimeGroupPolicy({ - providerConfigPresent: true, - }); - expect(resolved.groupPolicy).toBe("open"); - expect(resolved.providerMissingFallbackApplied).toBe(false); - }); - - it("ignores explicit defaults when provider config is missing", () => { - const resolved = resolveTelegramRuntimeGroupPolicy({ - providerConfigPresent: false, - defaultGroupPolicy: "disabled", - }); - expect(resolved.groupPolicy).toBe("allowlist"); - expect(resolved.providerMissingFallbackApplied).toBe(true); + installProviderRuntimeGroupPolicyFallbackSuite({ + resolve: resolveTelegramRuntimeGroupPolicy, + configuredLabel: "keeps open fallback when channels.telegram is configured", + defaultGroupPolicyUnderTest: "disabled", + missingConfigLabel: "fails closed when channels.telegram is missing and no defaults are set", + missingDefaultLabel: "ignores explicit defaults when provider config is missing", }); }); diff --git a/src/telegram/send.test.ts b/src/telegram/send.test.ts index a34f27d196f..a00d1b2e89e 100644 --- a/src/telegram/send.test.ts +++ b/src/telegram/send.test.ts @@ -1135,6 +1135,31 @@ describe("sendMessageTelegram", () => { }); }); + it("keeps disable_notification on plain-text fallback when silent is true", async () => { + const chatId = "123"; + const parseErr = new Error( + "400: Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 9", + ); + const sendMessage = vi + .fn() + .mockRejectedValueOnce(parseErr) + .mockResolvedValueOnce({ message_id: 2, chat: { id: chatId } }); + const api = { sendMessage } as unknown as { + sendMessage: typeof sendMessage; + }; + + await sendMessageTelegram(chatId, "_oops_", { + token: "tok", + api, + silent: true, + }); + + expect(sendMessage.mock.calls).toEqual([ + [chatId, "oops", { parse_mode: "HTML", disable_notification: true }], + [chatId, "_oops_", { disable_notification: true }], + ]); + }); + it("parses message_thread_id from recipient string (telegram:group:...:topic:...)", async () => { const chatId = "-1001234567890"; const sendMessage = vi.fn().mockResolvedValue({ @@ -1257,6 +1282,120 @@ describe("sendMessageTelegram", () => { expect.objectContaining({ maxBytes: 42 * 1024 * 1024 }), ); }); + + it("chunks long html-mode text and keeps buttons on the last chunk only", async () => { + const chatId = "123"; + const htmlText = `${"A".repeat(5000)}`; + + const sendMessage = vi + .fn() + .mockResolvedValueOnce({ message_id: 90, chat: { id: chatId } }) + .mockResolvedValueOnce({ message_id: 91, chat: { id: chatId } }); + const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage }; + + const res = await sendMessageTelegram(chatId, htmlText, { + token: "tok", + api, + textMode: "html", + buttons: [[{ text: "OK", callback_data: "ok" }]], + }); + + expect(sendMessage).toHaveBeenCalledTimes(2); + const firstCall = sendMessage.mock.calls[0]; + const secondCall = sendMessage.mock.calls[1]; + expect(firstCall).toBeDefined(); + expect(secondCall).toBeDefined(); + expect((firstCall[1] as string).length).toBeLessThanOrEqual(4000); + expect((secondCall[1] as string).length).toBeLessThanOrEqual(4000); + expect(firstCall[2]?.reply_markup).toBeUndefined(); + expect(secondCall[2]?.reply_markup).toEqual({ + inline_keyboard: [[{ text: "OK", callback_data: "ok" }]], + }); + expect(res.messageId).toBe("91"); + }); + + it("preserves caller plain-text fallback across chunked html parse retries", async () => { + const chatId = "123"; + const htmlText = `${"A".repeat(5000)}`; + const plainText = `${"P".repeat(2500)}${"Q".repeat(2500)}`; + const parseErr = new Error( + "400: Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 9", + ); + const sendMessage = vi + .fn() + .mockRejectedValueOnce(parseErr) + .mockResolvedValueOnce({ message_id: 90, chat: { id: chatId } }) + .mockRejectedValueOnce(parseErr) + .mockResolvedValueOnce({ message_id: 91, chat: { id: chatId } }); + const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage }; + + const res = await sendMessageTelegram(chatId, htmlText, { + token: "tok", + api, + textMode: "html", + plainText, + }); + + expect(sendMessage).toHaveBeenCalledTimes(4); + const plainFallbackCalls = [sendMessage.mock.calls[1], sendMessage.mock.calls[3]]; + expect(plainFallbackCalls.map((call) => String(call?.[1] ?? "")).join("")).toBe(plainText); + expect(plainFallbackCalls.every((call) => !String(call?.[1] ?? "").includes("<"))).toBe(true); + expect(res.messageId).toBe("91"); + }); + + it("keeps malformed leading ampersands on the chunked plain-text fallback path", async () => { + const chatId = "123"; + const htmlText = `&${"A".repeat(5000)}`; + const plainText = "fallback!!"; + const parseErr = new Error( + "400: Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 0", + ); + const sendMessage = vi + .fn() + .mockRejectedValueOnce(parseErr) + .mockResolvedValueOnce({ message_id: 92, chat: { id: chatId } }) + .mockRejectedValueOnce(parseErr) + .mockResolvedValueOnce({ message_id: 93, chat: { id: chatId } }); + const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage }; + + const res = await sendMessageTelegram(chatId, htmlText, { + token: "tok", + api, + textMode: "html", + plainText, + }); + + expect(sendMessage).toHaveBeenCalledTimes(4); + expect(String(sendMessage.mock.calls[0]?.[1] ?? "")).toMatch(/^&/); + const plainFallbackCalls = [sendMessage.mock.calls[1], sendMessage.mock.calls[3]]; + expect(plainFallbackCalls.map((call) => String(call?.[1] ?? "")).join("")).toBe(plainText); + expect(plainFallbackCalls.every((call) => String(call?.[1] ?? "").length > 0)).toBe(true); + expect(res.messageId).toBe("93"); + }); + + it("cuts over to plain text when fallback text needs more chunks than html", async () => { + const chatId = "123"; + const htmlText = `${"A".repeat(5000)}`; + const plainText = "P".repeat(9000); + const sendMessage = vi + .fn() + .mockResolvedValueOnce({ message_id: 94, chat: { id: chatId } }) + .mockResolvedValueOnce({ message_id: 95, chat: { id: chatId } }) + .mockResolvedValueOnce({ message_id: 96, chat: { id: chatId } }); + const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage }; + + const res = await sendMessageTelegram(chatId, htmlText, { + token: "tok", + api, + textMode: "html", + plainText, + }); + + expect(sendMessage).toHaveBeenCalledTimes(3); + expect(sendMessage.mock.calls.every((call) => call[2]?.parse_mode === undefined)).toBe(true); + expect(sendMessage.mock.calls.map((call) => String(call[1] ?? "")).join("")).toBe(plainText); + expect(res.messageId).toBe("96"); + }); }); describe("reactMessageTelegram", () => { diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 313abf361e8..44e18ee2340 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -26,7 +26,7 @@ import { buildTelegramThreadParams, buildTypingThreadParams } from "./bot/helper import type { TelegramInlineButtons } from "./button-types.js"; import { splitTelegramCaption } from "./caption.js"; import { resolveTelegramFetch } from "./fetch.js"; -import { renderTelegramHtmlText } from "./format.js"; +import { renderTelegramHtmlText, splitTelegramHtmlChunks } from "./format.js"; import { isRecoverableTelegramNetworkError, isSafeToRetrySendError } from "./network-errors.js"; import { makeProxyFetch } from "./proxy.js"; import { recordSentMessage } from "./sent-message-cache.js"; @@ -80,6 +80,7 @@ type TelegramMessageLike = { }; type TelegramReactionOpts = { + cfg?: ReturnType; token?: string; accountId?: string; api?: TelegramApiOverride; @@ -108,6 +109,42 @@ function resolveTelegramMessageIdOrThrow( throw new Error(`Telegram ${context} returned no message_id`); } +function splitTelegramPlainTextChunks(text: string, limit: number): string[] { + if (!text) { + return []; + } + const normalizedLimit = Math.max(1, Math.floor(limit)); + const chunks: string[] = []; + for (let start = 0; start < text.length; start += normalizedLimit) { + chunks.push(text.slice(start, start + normalizedLimit)); + } + return chunks; +} + +function splitTelegramPlainTextFallback(text: string, chunkCount: number, limit: number): string[] { + if (!text) { + return []; + } + const normalizedLimit = Math.max(1, Math.floor(limit)); + const fixedChunks = splitTelegramPlainTextChunks(text, normalizedLimit); + if (chunkCount <= 1 || fixedChunks.length >= chunkCount) { + return fixedChunks; + } + const chunks: string[] = []; + let offset = 0; + for (let index = 0; index < chunkCount; index += 1) { + const remainingChars = text.length - offset; + const remainingChunks = chunkCount - index; + const nextChunkLength = + remainingChunks === 1 + ? remainingChars + : Math.min(normalizedLimit, Math.ceil(remainingChars / remainingChunks)); + chunks.push(text.slice(offset, offset + nextChunkLength)); + offset += nextChunkLength; + } + return chunks; +} + const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; const THREAD_NOT_FOUND_RE = /400:\s*Bad Request:\s*message thread not found/i; const MESSAGE_NOT_MODIFIED_RE = @@ -596,27 +633,49 @@ export async function sendMessageTelegram( const linkPreviewEnabled = account.config.linkPreview ?? true; const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true }; - const sendTelegramText = async ( - rawText: string, + type TelegramTextChunk = { + plainText: string; + htmlText?: string; + }; + + const sendTelegramTextChunk = async ( + chunk: TelegramTextChunk, params?: Record, - fallbackText?: string, ) => { return await withTelegramThreadFallback( params, "message", opts.verbose, async (effectiveParams, label) => { - const htmlText = renderHtmlText(rawText); const baseParams = effectiveParams ? { ...effectiveParams } : {}; if (linkPreviewOptions) { baseParams.link_preview_options = linkPreviewOptions; } - const hasBaseParams = Object.keys(baseParams).length > 0; - const sendParams = { - parse_mode: "HTML" as const, + const plainParams = { ...baseParams, ...(opts.silent === true ? { disable_notification: true } : {}), }; + const hasPlainParams = Object.keys(plainParams).length > 0; + const requestPlain = (retryLabel: string) => + requestWithChatNotFound( + () => + hasPlainParams + ? api.sendMessage( + chatId, + chunk.plainText, + plainParams as Parameters[2], + ) + : api.sendMessage(chatId, chunk.plainText), + retryLabel, + ); + if (!chunk.htmlText) { + return await requestPlain(label); + } + const htmlText = chunk.htmlText; + const htmlParams = { + parse_mode: "HTML" as const, + ...plainParams, + }; return await withTelegramHtmlParseFallback({ label, verbose: opts.verbose, @@ -626,27 +685,74 @@ export async function sendMessageTelegram( api.sendMessage( chatId, htmlText, - sendParams as Parameters[2], + htmlParams as Parameters[2], ), retryLabel, ), - requestPlain: (retryLabel) => { - const plainParams = hasBaseParams - ? (baseParams as Parameters[2]) - : undefined; - return requestWithChatNotFound( - () => - plainParams - ? api.sendMessage(chatId, fallbackText ?? rawText, plainParams) - : api.sendMessage(chatId, fallbackText ?? rawText), - retryLabel, - ); - }, + requestPlain, }); }, ); }; + const buildTextParams = (isLastChunk: boolean) => + hasThreadParams || (isLastChunk && replyMarkup) + ? { + ...threadParams, + ...(isLastChunk && replyMarkup ? { reply_markup: replyMarkup } : {}), + } + : undefined; + + const sendTelegramTextChunks = async ( + chunks: TelegramTextChunk[], + context: string, + ): Promise<{ messageId: string; chatId: string }> => { + let lastMessageId = ""; + let lastChatId = chatId; + for (let index = 0; index < chunks.length; index += 1) { + const chunk = chunks[index]; + if (!chunk) { + continue; + } + const res = await sendTelegramTextChunk(chunk, buildTextParams(index === chunks.length - 1)); + const messageId = resolveTelegramMessageIdOrThrow(res, context); + recordSentMessage(chatId, messageId); + lastMessageId = String(messageId); + lastChatId = String(res?.chat?.id ?? chatId); + } + return { messageId: lastMessageId, chatId: lastChatId }; + }; + + const buildChunkedTextPlan = (rawText: string, context: string): TelegramTextChunk[] => { + const fallbackText = opts.plainText ?? rawText; + let htmlChunks: string[]; + try { + htmlChunks = splitTelegramHtmlChunks(rawText, 4000); + } catch (error) { + logVerbose( + `telegram ${context} failed HTML chunk planning, retrying as plain text: ${formatErrorMessage( + error, + )}`, + ); + return splitTelegramPlainTextChunks(fallbackText, 4000).map((plainText) => ({ plainText })); + } + const fixedPlainTextChunks = splitTelegramPlainTextChunks(fallbackText, 4000); + if (fixedPlainTextChunks.length > htmlChunks.length) { + logVerbose( + `telegram ${context} plain-text fallback needs more chunks than HTML; sending plain text`, + ); + return fixedPlainTextChunks.map((plainText) => ({ plainText })); + } + const plainTextChunks = splitTelegramPlainTextFallback(fallbackText, htmlChunks.length, 4000); + return htmlChunks.map((htmlText, index) => ({ + htmlText, + plainText: plainTextChunks[index] ?? htmlText, + })); + }; + + const sendChunkedText = async (rawText: string, context: string) => + await sendTelegramTextChunks(buildChunkedTextPlan(rawText, context), context); + if (mediaUrl) { const media = await loadWebMedia( mediaUrl, @@ -801,21 +907,15 @@ export async function sendMessageTelegram( // If text was too long for a caption, send it as a separate follow-up message. // Use HTML conversion so markdown renders like captions. if (needsSeparateText && followUpText) { - const textParams = - hasThreadParams || replyMarkup - ? { - ...threadParams, - ...(replyMarkup ? { reply_markup: replyMarkup } : {}), - } - : undefined; - const textRes = await sendTelegramText(followUpText, textParams); - // Return the text message ID as the "main" message (it's the actual content). - const textMessageId = resolveTelegramMessageIdOrThrow(textRes, "text follow-up send"); - recordSentMessage(chatId, textMessageId); - return { - messageId: String(textMessageId), - chatId: resolvedChatId, - }; + if (textMode === "html") { + const textResult = await sendChunkedText(followUpText, "text follow-up send"); + return { messageId: textResult.messageId, chatId: resolvedChatId }; + } + const textResult = await sendTelegramTextChunks( + [{ plainText: followUpText, htmlText: renderHtmlText(followUpText) }], + "text follow-up send", + ); + return { messageId: textResult.messageId, chatId: resolvedChatId }; } return { messageId: String(mediaMessageId), chatId: resolvedChatId }; @@ -824,22 +924,21 @@ export async function sendMessageTelegram( if (!text || !text.trim()) { throw new Error("Message must be non-empty for Telegram sends"); } - const textParams = - hasThreadParams || replyMarkup - ? { - ...threadParams, - ...(replyMarkup ? { reply_markup: replyMarkup } : {}), - } - : undefined; - const res = await sendTelegramText(text, textParams, opts.plainText); - const messageId = resolveTelegramMessageIdOrThrow(res, "text send"); - recordSentMessage(chatId, messageId); + let textResult: { messageId: string; chatId: string }; + if (textMode === "html") { + textResult = await sendChunkedText(text, "text send"); + } else { + textResult = await sendTelegramTextChunks( + [{ plainText: opts.plainText ?? text, htmlText: renderHtmlText(text) }], + "text send", + ); + } recordChannelActivity({ channel: "telegram", accountId: account.accountId, direction: "outbound", }); - return { messageId: String(messageId), chatId: String(res?.chat?.id ?? chatId) }; + return textResult; } export async function sendTypingTelegram( @@ -922,6 +1021,7 @@ export async function reactMessageTelegram( } type TelegramDeleteOpts = { + cfg?: ReturnType; token?: string; accountId?: string; verbose?: boolean; @@ -1136,6 +1236,7 @@ function inferFilename(kind: MediaKind) { } type TelegramStickerOpts = { + cfg?: ReturnType; token?: string; accountId?: string; verbose?: boolean; @@ -1328,9 +1429,10 @@ export async function sendPollTelegram( // --------------------------------------------------------------------------- type TelegramCreateForumTopicOpts = { + cfg?: ReturnType; token?: string; accountId?: string; - api?: Bot["api"]; + api?: TelegramApiOverride; verbose?: boolean; retry?: RetryConfig; /** Icon color for the topic (must be one of 0x6FB9F0, 0xFFD67E, 0xCB86DB, 0x8EEE98, 0xFF93B2, 0xFB6F5F). */ @@ -1366,16 +1468,9 @@ export async function createForumTopicTelegram( throw new Error("Forum topic name must be 128 characters or fewer"); } - const cfg = loadConfig(); - const account = resolveTelegramAccount({ - cfg, - accountId: opts.accountId, - }); - const token = resolveToken(opts.token, account); + const { cfg, account, api } = resolveTelegramApiContext(opts); // Accept topic-qualified targets (e.g. telegram:group::topic:) // but createForumTopic must always target the base supergroup chat id. - const client = resolveTelegramClientOptions(account); - const api = opts.api ?? new Bot(token, client ? { client } : undefined).api; const target = parseTelegramTarget(chatId); const normalizedChatId = await resolveAndPersistChatId({ cfg, diff --git a/src/telegram/token.test.ts b/src/telegram/token.test.ts index fa1dc037b0c..f888ddbfc36 100644 --- a/src/telegram/token.test.ts +++ b/src/telegram/token.test.ts @@ -48,6 +48,21 @@ describe("resolveTelegramToken", () => { fs.rmSync(dir, { recursive: true, force: true }); }); + it.runIf(process.platform !== "win32")("rejects symlinked tokenFile paths", () => { + vi.stubEnv("TELEGRAM_BOT_TOKEN", ""); + const dir = withTempDir(); + const tokenFile = path.join(dir, "token.txt"); + const tokenLink = path.join(dir, "token-link.txt"); + fs.writeFileSync(tokenFile, "file-token\n", "utf-8"); + fs.symlinkSync(tokenFile, tokenLink); + + const cfg = { channels: { telegram: { tokenFile: tokenLink } } } as OpenClawConfig; + const res = resolveTelegramToken(cfg); + expect(res.token).toBe(""); + expect(res.source).toBe("none"); + fs.rmSync(dir, { recursive: true, force: true }); + }); + it("falls back to config token when no env or tokenFile", () => { vi.stubEnv("TELEGRAM_BOT_TOKEN", ""); const cfg = { diff --git a/src/telegram/token.ts b/src/telegram/token.ts index 81b0ac49d70..3615c703582 100644 --- a/src/telegram/token.ts +++ b/src/telegram/token.ts @@ -1,8 +1,8 @@ -import fs from "node:fs"; import type { BaseTokenResolution } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { normalizeResolvedSecretInputString } from "../config/types.secrets.js"; import type { TelegramAccountConfig } from "../config/types.telegram.js"; +import { tryReadSecretFileSync } from "../infra/secret-file.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; export type TelegramTokenSource = "env" | "tokenFile" | "config" | "none"; @@ -46,23 +46,17 @@ export function resolveTelegramToken( ); const accountTokenFile = accountCfg?.tokenFile?.trim(); if (accountTokenFile) { - if (!fs.existsSync(accountTokenFile)) { - opts.logMissingFile?.( - `channels.telegram.accounts.${accountId}.tokenFile not found: ${accountTokenFile}`, - ); - return { token: "", source: "none" }; - } - try { - const token = fs.readFileSync(accountTokenFile, "utf-8").trim(); - if (token) { - return { token, source: "tokenFile" }; - } - } catch (err) { - opts.logMissingFile?.( - `channels.telegram.accounts.${accountId}.tokenFile read failed: ${String(err)}`, - ); - return { token: "", source: "none" }; + const token = tryReadSecretFileSync( + accountTokenFile, + `channels.telegram.accounts.${accountId}.tokenFile`, + { rejectSymlink: true }, + ); + if (token) { + return { token, source: "tokenFile" }; } + opts.logMissingFile?.( + `channels.telegram.accounts.${accountId}.tokenFile not found or unreadable: ${accountTokenFile}`, + ); return { token: "", source: "none" }; } @@ -77,19 +71,14 @@ export function resolveTelegramToken( const allowEnv = accountId === DEFAULT_ACCOUNT_ID; const tokenFile = telegramCfg?.tokenFile?.trim(); if (tokenFile) { - if (!fs.existsSync(tokenFile)) { - opts.logMissingFile?.(`channels.telegram.tokenFile not found: ${tokenFile}`); - return { token: "", source: "none" }; - } - try { - const token = fs.readFileSync(tokenFile, "utf-8").trim(); - if (token) { - return { token, source: "tokenFile" }; - } - } catch (err) { - opts.logMissingFile?.(`channels.telegram.tokenFile read failed: ${String(err)}`); - return { token: "", source: "none" }; + const token = tryReadSecretFileSync(tokenFile, "channels.telegram.tokenFile", { + rejectSymlink: true, + }); + if (token) { + return { token, source: "tokenFile" }; } + opts.logMissingFile?.(`channels.telegram.tokenFile not found or unreadable: ${tokenFile}`); + return { token: "", source: "none" }; } const configToken = normalizeResolvedSecretInputString({ diff --git a/src/test-utils/runtime-group-policy-contract.ts b/src/test-utils/runtime-group-policy-contract.ts new file mode 100644 index 00000000000..65a0e0b8ef3 --- /dev/null +++ b/src/test-utils/runtime-group-policy-contract.ts @@ -0,0 +1,43 @@ +import { expect, it } from "vitest"; +import type { + ResolveProviderRuntimeGroupPolicyParams, + RuntimeGroupPolicyResolution, +} from "../config/runtime-group-policy.js"; +import type { GroupPolicy } from "../config/types.base.js"; + +type RuntimeGroupPolicyResolver = ( + params: ResolveProviderRuntimeGroupPolicyParams, +) => RuntimeGroupPolicyResolution; + +export function installProviderRuntimeGroupPolicyFallbackSuite(params: { + configuredLabel: string; + defaultGroupPolicyUnderTest: GroupPolicy; + missingConfigLabel: string; + missingDefaultLabel: string; + resolve: RuntimeGroupPolicyResolver; +}) { + it(params.missingConfigLabel, () => { + const resolved = params.resolve({ + providerConfigPresent: false, + }); + expect(resolved.groupPolicy).toBe("allowlist"); + expect(resolved.providerMissingFallbackApplied).toBe(true); + }); + + it(params.configuredLabel, () => { + const resolved = params.resolve({ + providerConfigPresent: true, + }); + expect(resolved.groupPolicy).toBe("open"); + expect(resolved.providerMissingFallbackApplied).toBe(false); + }); + + it(params.missingDefaultLabel, () => { + const resolved = params.resolve({ + providerConfigPresent: false, + defaultGroupPolicy: params.defaultGroupPolicyUnderTest, + }); + expect(resolved.groupPolicy).toBe("allowlist"); + expect(resolved.providerMissingFallbackApplied).toBe(true); + }); +} diff --git a/src/test-utils/secret-file-fixture.ts b/src/test-utils/secret-file-fixture.ts new file mode 100644 index 00000000000..8e780929f94 --- /dev/null +++ b/src/test-utils/secret-file-fixture.ts @@ -0,0 +1,30 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +export type SecretFiles = { + passwordFile?: string; + tokenFile?: string; +}; + +export async function withTempSecretFiles( + prefix: string, + secrets: { password?: string; token?: string }, + run: (files: SecretFiles) => Promise, +): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + try { + const files: SecretFiles = {}; + if (secrets.token !== undefined) { + files.tokenFile = path.join(dir, "token.txt"); + await fs.writeFile(files.tokenFile, secrets.token, "utf8"); + } + if (secrets.password !== undefined) { + files.passwordFile = path.join(dir, "password.txt"); + await fs.writeFile(files.passwordFile, secrets.password, "utf8"); + } + return await run(files); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +} diff --git a/src/test-utils/secret-ref-test-vectors.ts b/src/test-utils/secret-ref-test-vectors.ts new file mode 100644 index 00000000000..7645f4c24f2 --- /dev/null +++ b/src/test-utils/secret-ref-test-vectors.ts @@ -0,0 +1,24 @@ +export const VALID_EXEC_SECRET_REF_IDS = [ + "vault/openai/api-key", + "vault:secret/mykey", + "providers/openai/apiKey", + "a..b/c", + "a/.../b", + "a/.well-known/key", + `a/${"b".repeat(254)}`, +] as const; + +export const INVALID_EXEC_SECRET_REF_IDS = [ + "", + " ", + "a/../b", + "a/./b", + "../b", + "./b", + "a/..", + "a/.", + "/absolute/path", + "bad id", + "a\\b", + `a${"b".repeat(256)}`, +] as const; diff --git a/src/test-utils/send-payload-contract.ts b/src/test-utils/send-payload-contract.ts new file mode 100644 index 00000000000..5e78e406a74 --- /dev/null +++ b/src/test-utils/send-payload-contract.ts @@ -0,0 +1,138 @@ +import { expect, it, type Mock } from "vitest"; + +type PayloadLike = { + mediaUrl?: string; + mediaUrls?: string[]; + text?: string; +}; + +type SendResultLike = { + messageId: string; + [key: string]: unknown; +}; + +type ChunkingMode = + | { + longTextLength: number; + maxChunkLength: number; + mode: "split"; + } + | { + longTextLength: number; + mode: "passthrough"; + }; + +export function installSendPayloadContractSuite(params: { + channel: string; + chunking: ChunkingMode; + createHarness: (params: { payload: PayloadLike; sendResults?: SendResultLike[] }) => { + run: () => Promise>; + sendMock: Mock; + to: string; + }; +}) { + it("text-only delegates to sendText", async () => { + const { run, sendMock, to } = params.createHarness({ + payload: { text: "hello" }, + }); + const result = await run(); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock).toHaveBeenCalledWith(to, "hello", expect.any(Object)); + expect(result).toMatchObject({ channel: params.channel }); + }); + + it("single media delegates to sendMedia", async () => { + const { run, sendMock, to } = params.createHarness({ + payload: { text: "cap", mediaUrl: "https://example.com/a.jpg" }, + }); + const result = await run(); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock).toHaveBeenCalledWith( + to, + "cap", + expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }), + ); + expect(result).toMatchObject({ channel: params.channel }); + }); + + it("multi-media iterates URLs with caption on first", async () => { + const { run, sendMock, to } = params.createHarness({ + payload: { + text: "caption", + mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"], + }, + sendResults: [{ messageId: "m-1" }, { messageId: "m-2" }], + }); + const result = await run(); + + expect(sendMock).toHaveBeenCalledTimes(2); + expect(sendMock).toHaveBeenNthCalledWith( + 1, + to, + "caption", + expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }), + ); + expect(sendMock).toHaveBeenNthCalledWith( + 2, + to, + "", + expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }), + ); + expect(result).toMatchObject({ channel: params.channel, messageId: "m-2" }); + }); + + it("empty payload returns no-op", async () => { + const { run, sendMock } = params.createHarness({ payload: {} }); + const result = await run(); + + expect(sendMock).not.toHaveBeenCalled(); + expect(result).toEqual({ channel: params.channel, messageId: "" }); + }); + + if (params.chunking.mode === "passthrough") { + it("text exceeding chunk limit is sent as-is when chunker is null", async () => { + const text = "a".repeat(params.chunking.longTextLength); + const { run, sendMock, to } = params.createHarness({ payload: { text } }); + const result = await run(); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock).toHaveBeenCalledWith(to, text, expect.any(Object)); + expect(result).toMatchObject({ channel: params.channel }); + }); + return; + } + + const chunking = params.chunking; + + it("chunking splits long text", async () => { + const text = "a".repeat(chunking.longTextLength); + const { run, sendMock } = params.createHarness({ + payload: { text }, + sendResults: [{ messageId: "c-1" }, { messageId: "c-2" }], + }); + const result = await run(); + + expect(sendMock.mock.calls.length).toBeGreaterThanOrEqual(2); + for (const call of sendMock.mock.calls) { + expect((call[1] as string).length).toBeLessThanOrEqual(chunking.maxChunkLength); + } + expect(result).toMatchObject({ channel: params.channel }); + }); +} + +export function primeSendMock( + sendMock: Mock, + fallbackResult: Record, + sendResults: SendResultLike[] = [], +) { + sendMock.mockReset(); + if (sendResults.length === 0) { + sendMock.mockResolvedValue(fallbackResult); + return; + } + for (const result of sendResults) { + sendMock.mockResolvedValueOnce(result); + } +} diff --git a/src/web/inbound/access-control.group-policy.test.ts b/src/web/inbound/access-control.group-policy.test.ts index 8419a1e5d7a..9b546f7a423 100644 --- a/src/web/inbound/access-control.group-policy.test.ts +++ b/src/web/inbound/access-control.group-policy.test.ts @@ -1,29 +1,13 @@ -import { describe, expect, it } from "vitest"; +import { describe } from "vitest"; +import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../test-utils/runtime-group-policy-contract.js"; import { __testing } from "./access-control.js"; describe("resolveWhatsAppRuntimeGroupPolicy", () => { - it("fails closed when channels.whatsapp is missing and no defaults are set", () => { - const resolved = __testing.resolveWhatsAppRuntimeGroupPolicy({ - providerConfigPresent: false, - }); - expect(resolved.groupPolicy).toBe("allowlist"); - expect(resolved.providerMissingFallbackApplied).toBe(true); - }); - - it("keeps open fallback when channels.whatsapp is configured", () => { - const resolved = __testing.resolveWhatsAppRuntimeGroupPolicy({ - providerConfigPresent: true, - }); - expect(resolved.groupPolicy).toBe("open"); - expect(resolved.providerMissingFallbackApplied).toBe(false); - }); - - it("ignores explicit default policy when provider config is missing", () => { - const resolved = __testing.resolveWhatsAppRuntimeGroupPolicy({ - providerConfigPresent: false, - defaultGroupPolicy: "disabled", - }); - expect(resolved.groupPolicy).toBe("allowlist"); - expect(resolved.providerMissingFallbackApplied).toBe(true); + installProviderRuntimeGroupPolicyFallbackSuite({ + resolve: __testing.resolveWhatsAppRuntimeGroupPolicy, + configuredLabel: "keeps open fallback when channels.whatsapp is configured", + defaultGroupPolicyUnderTest: "disabled", + missingConfigLabel: "fails closed when channels.whatsapp is missing and no defaults are set", + missingDefaultLabel: "ignores explicit default policy when provider config is missing", }); }); diff --git a/test/openclaw-npm-release-check.test.ts b/test/openclaw-npm-release-check.test.ts new file mode 100644 index 00000000000..7bd1c98d92d --- /dev/null +++ b/test/openclaw-npm-release-check.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { + collectReleasePackageMetadataErrors, + collectReleaseTagErrors, + parseReleaseVersion, + utcCalendarDayDistance, +} from "../scripts/openclaw-npm-release-check.ts"; + +describe("parseReleaseVersion", () => { + it("parses stable CalVer releases", () => { + expect(parseReleaseVersion("2026.3.9")).toMatchObject({ + version: "2026.3.9", + channel: "stable", + year: 2026, + month: 3, + day: 9, + }); + }); + + it("parses beta CalVer releases", () => { + expect(parseReleaseVersion("2026.3.9-beta.2")).toMatchObject({ + version: "2026.3.9-beta.2", + channel: "beta", + year: 2026, + month: 3, + day: 9, + betaNumber: 2, + }); + }); + + it("rejects legacy and malformed release formats", () => { + expect(parseReleaseVersion("2026.3.9-1")).toBeNull(); + expect(parseReleaseVersion("2026.03.09")).toBeNull(); + expect(parseReleaseVersion("v2026.3.9")).toBeNull(); + expect(parseReleaseVersion("2026.2.30")).toBeNull(); + expect(parseReleaseVersion("2.0.0-beta2")).toBeNull(); + }); +}); + +describe("utcCalendarDayDistance", () => { + it("compares UTC calendar days rather than wall-clock hours", () => { + const left = new Date("2026-03-09T23:59:59Z"); + const right = new Date("2026-03-11T00:00:01Z"); + expect(utcCalendarDayDistance(left, right)).toBe(2); + }); +}); + +describe("collectReleaseTagErrors", () => { + it("accepts versions within the two-day CalVer window", () => { + expect( + collectReleaseTagErrors({ + packageVersion: "2026.3.9", + releaseTag: "v2026.3.9", + now: new Date("2026-03-11T12:00:00Z"), + }), + ).toEqual([]); + }); + + it("rejects versions outside the two-day CalVer window", () => { + expect( + collectReleaseTagErrors({ + packageVersion: "2026.3.9", + releaseTag: "v2026.3.9", + now: new Date("2026-03-12T00:00:00Z"), + }), + ).toContainEqual(expect.stringContaining("must be within 2 days")); + }); + + it("rejects tags that do not match the current release format", () => { + expect( + collectReleaseTagErrors({ + packageVersion: "2026.3.9", + releaseTag: "v2026.3.9-1", + now: new Date("2026-03-09T00:00:00Z"), + }), + ).toContainEqual(expect.stringContaining("must match vYYYY.M.D or vYYYY.M.D-beta.N")); + }); +}); + +describe("collectReleasePackageMetadataErrors", () => { + it("validates the expected npm package metadata", () => { + expect( + collectReleasePackageMetadataErrors({ + name: "openclaw", + description: "Multi-channel AI gateway with extensible messaging integrations", + license: "MIT", + repository: { url: "git+https://github.com/openclaw/openclaw.git" }, + bin: { openclaw: "openclaw.mjs" }, + }), + ).toEqual([]); + }); +}); diff --git a/ui/src/ui/gateway.node.test.ts b/ui/src/ui/gateway.node.test.ts index 07c63a7117b..c77f3a3684c 100644 --- a/ui/src/ui/gateway.node.test.ts +++ b/ui/src/ui/gateway.node.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { storeDeviceAuthToken } from "./device-auth.ts"; +import { loadDeviceAuthToken, storeDeviceAuthToken } from "./device-auth.ts"; import type { DeviceIdentity } from "./device-identity.ts"; const wsInstances = vi.hoisted((): MockWebSocket[] => []); @@ -54,6 +54,12 @@ class MockWebSocket { this.readyState = 3; } + emitClose(code = 1000, reason = "") { + for (const handler of this.handlers.close) { + handler({ code, reason }); + } + } + emitOpen() { for (const handler of this.handlers.open) { handler(); @@ -106,6 +112,7 @@ describe("GatewayBrowserClient", () => { }); afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -166,4 +173,212 @@ describe("GatewayBrowserClient", () => { const signedPayload = signDevicePayloadMock.mock.calls[0]?.[1]; expect(signedPayload).toContain("|stored-device-token|nonce-1"); }); + + it("retries once with device token after token mismatch when shared token is explicit", async () => { + vi.useFakeTimers(); + const client = new GatewayBrowserClient({ + url: "ws://127.0.0.1:18789", + token: "shared-auth-token", + }); + + client.start(); + const ws1 = getLatestWebSocket(); + ws1.emitOpen(); + ws1.emitMessage({ + type: "event", + event: "connect.challenge", + payload: { nonce: "nonce-1" }, + }); + await vi.waitFor(() => expect(ws1.sent.length).toBeGreaterThan(0)); + const firstConnect = JSON.parse(ws1.sent.at(-1) ?? "{}") as { + id: string; + params?: { auth?: { token?: string; deviceToken?: string } }; + }; + expect(firstConnect.params?.auth?.token).toBe("shared-auth-token"); + expect(firstConnect.params?.auth?.deviceToken).toBeUndefined(); + + ws1.emitMessage({ + type: "res", + id: firstConnect.id, + ok: false, + error: { + code: "INVALID_REQUEST", + message: "unauthorized", + details: { code: "AUTH_TOKEN_MISMATCH", canRetryWithDeviceToken: true }, + }, + }); + await vi.waitFor(() => expect(ws1.readyState).toBe(3)); + ws1.emitClose(4008, "connect failed"); + + await vi.advanceTimersByTimeAsync(800); + const ws2 = getLatestWebSocket(); + expect(ws2).not.toBe(ws1); + ws2.emitOpen(); + ws2.emitMessage({ + type: "event", + event: "connect.challenge", + payload: { nonce: "nonce-2" }, + }); + await vi.waitFor(() => expect(ws2.sent.length).toBeGreaterThan(0)); + const secondConnect = JSON.parse(ws2.sent.at(-1) ?? "{}") as { + id: string; + params?: { auth?: { token?: string; deviceToken?: string } }; + }; + expect(secondConnect.params?.auth?.token).toBe("shared-auth-token"); + expect(secondConnect.params?.auth?.deviceToken).toBe("stored-device-token"); + + ws2.emitMessage({ + type: "res", + id: secondConnect.id, + ok: false, + error: { + code: "INVALID_REQUEST", + message: "unauthorized", + details: { code: "AUTH_TOKEN_MISMATCH" }, + }, + }); + await vi.waitFor(() => expect(ws2.readyState).toBe(3)); + ws2.emitClose(4008, "connect failed"); + expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator" })?.token).toBe( + "stored-device-token", + ); + await vi.advanceTimersByTimeAsync(30_000); + expect(wsInstances).toHaveLength(2); + + vi.useRealTimers(); + }); + + it("treats IPv6 loopback as trusted for bounded device-token retry", async () => { + vi.useFakeTimers(); + const client = new GatewayBrowserClient({ + url: "ws://[::1]:18789", + token: "shared-auth-token", + }); + + client.start(); + const ws1 = getLatestWebSocket(); + ws1.emitOpen(); + ws1.emitMessage({ + type: "event", + event: "connect.challenge", + payload: { nonce: "nonce-1" }, + }); + await vi.waitFor(() => expect(ws1.sent.length).toBeGreaterThan(0)); + const firstConnect = JSON.parse(ws1.sent.at(-1) ?? "{}") as { + id: string; + params?: { auth?: { token?: string; deviceToken?: string } }; + }; + expect(firstConnect.params?.auth?.token).toBe("shared-auth-token"); + expect(firstConnect.params?.auth?.deviceToken).toBeUndefined(); + + ws1.emitMessage({ + type: "res", + id: firstConnect.id, + ok: false, + error: { + code: "INVALID_REQUEST", + message: "unauthorized", + details: { code: "AUTH_TOKEN_MISMATCH", canRetryWithDeviceToken: true }, + }, + }); + await vi.waitFor(() => expect(ws1.readyState).toBe(3)); + ws1.emitClose(4008, "connect failed"); + + await vi.advanceTimersByTimeAsync(800); + const ws2 = getLatestWebSocket(); + expect(ws2).not.toBe(ws1); + ws2.emitOpen(); + ws2.emitMessage({ + type: "event", + event: "connect.challenge", + payload: { nonce: "nonce-2" }, + }); + await vi.waitFor(() => expect(ws2.sent.length).toBeGreaterThan(0)); + const secondConnect = JSON.parse(ws2.sent.at(-1) ?? "{}") as { + params?: { auth?: { token?: string; deviceToken?: string } }; + }; + expect(secondConnect.params?.auth?.token).toBe("shared-auth-token"); + expect(secondConnect.params?.auth?.deviceToken).toBe("stored-device-token"); + + client.stop(); + vi.useRealTimers(); + }); + + it("continues reconnecting on first token mismatch when no retry was attempted", async () => { + vi.useFakeTimers(); + window.localStorage.clear(); + + const client = new GatewayBrowserClient({ + url: "ws://127.0.0.1:18789", + token: "shared-auth-token", + }); + + client.start(); + const ws1 = getLatestWebSocket(); + ws1.emitOpen(); + ws1.emitMessage({ + type: "event", + event: "connect.challenge", + payload: { nonce: "nonce-1" }, + }); + await vi.waitFor(() => expect(ws1.sent.length).toBeGreaterThan(0)); + const firstConnect = JSON.parse(ws1.sent.at(-1) ?? "{}") as { id: string }; + + ws1.emitMessage({ + type: "res", + id: firstConnect.id, + ok: false, + error: { + code: "INVALID_REQUEST", + message: "unauthorized", + details: { code: "AUTH_TOKEN_MISMATCH" }, + }, + }); + await vi.waitFor(() => expect(ws1.readyState).toBe(3)); + ws1.emitClose(4008, "connect failed"); + + await vi.advanceTimersByTimeAsync(800); + expect(wsInstances).toHaveLength(2); + + client.stop(); + vi.useRealTimers(); + }); + + it("does not auto-reconnect on AUTH_TOKEN_MISSING", async () => { + vi.useFakeTimers(); + window.localStorage.clear(); + + const client = new GatewayBrowserClient({ + url: "ws://127.0.0.1:18789", + }); + + client.start(); + const ws1 = getLatestWebSocket(); + ws1.emitOpen(); + ws1.emitMessage({ + type: "event", + event: "connect.challenge", + payload: { nonce: "nonce-1" }, + }); + await vi.waitFor(() => expect(ws1.sent.length).toBeGreaterThan(0)); + const connect = JSON.parse(ws1.sent.at(-1) ?? "{}") as { id: string }; + + ws1.emitMessage({ + type: "res", + id: connect.id, + ok: false, + error: { + code: "INVALID_REQUEST", + message: "unauthorized", + details: { code: "AUTH_TOKEN_MISSING" }, + }, + }); + await vi.waitFor(() => expect(ws1.readyState).toBe(3)); + ws1.emitClose(4008, "connect failed"); + + await vi.advanceTimersByTimeAsync(30_000); + expect(wsInstances).toHaveLength(1); + + vi.useRealTimers(); + }); }); diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index c5d4bad86a3..c0d9ef71271 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -7,6 +7,7 @@ import { } from "../../../src/gateway/protocol/client-info.js"; import { ConnectErrorDetailCodes, + readConnectErrorRecoveryAdvice, readConnectErrorDetailCode, } from "../../../src/gateway/protocol/connect-error-details.js"; import { clearDeviceAuthToken, loadDeviceAuthToken, storeDeviceAuthToken } from "./device-auth.ts"; @@ -57,11 +58,9 @@ export function resolveGatewayErrorDetailCode( * Auth errors that won't resolve without user action — don't auto-reconnect. * * NOTE: AUTH_TOKEN_MISMATCH is intentionally NOT included here because the - * browser client has a device-token fallback flow: a stale cached device token - * triggers a mismatch, sendConnect() clears it, and the next reconnect retries - * with opts.token (the shared gateway token). Blocking reconnect on mismatch - * would break that fallback. The rate limiter still catches persistent wrong - * tokens after N failures → AUTH_RATE_LIMITED stops the loop. + * browser client supports a bounded one-time retry with a cached device token + * when the endpoint is trusted. Reconnect suppression for mismatch is handled + * with client state (after retry budget is exhausted). */ export function isNonRecoverableAuthError(error: GatewayErrorInfo | undefined): boolean { if (!error) { @@ -72,10 +71,30 @@ export function isNonRecoverableAuthError(error: GatewayErrorInfo | undefined): code === ConnectErrorDetailCodes.AUTH_TOKEN_MISSING || code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING || code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH || - code === ConnectErrorDetailCodes.AUTH_RATE_LIMITED + code === ConnectErrorDetailCodes.AUTH_RATE_LIMITED || + code === ConnectErrorDetailCodes.PAIRING_REQUIRED || + code === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED || + code === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED ); } +function isTrustedRetryEndpoint(url: string): boolean { + try { + const gatewayUrl = new URL(url, window.location.href); + const host = gatewayUrl.hostname.trim().toLowerCase(); + const isLoopbackHost = + host === "localhost" || host === "::1" || host === "[::1]" || host === "127.0.0.1"; + const isLoopbackIPv4 = host.startsWith("127."); + if (isLoopbackHost || isLoopbackIPv4) { + return true; + } + const pageUrl = new URL(window.location.href); + return gatewayUrl.host === pageUrl.host; + } catch { + return false; + } +} + export type GatewayHelloOk = { type: "hello-ok"; protocol: number; @@ -127,6 +146,8 @@ export class GatewayBrowserClient { private connectTimer: number | null = null; private backoffMs = 800; private pendingConnectError: GatewayErrorInfo | undefined; + private pendingDeviceTokenRetry = false; + private deviceTokenRetryBudgetUsed = false; constructor(private opts: GatewayBrowserClientOptions) {} @@ -140,6 +161,8 @@ export class GatewayBrowserClient { this.ws?.close(); this.ws = null; this.pendingConnectError = undefined; + this.pendingDeviceTokenRetry = false; + this.deviceTokenRetryBudgetUsed = false; this.flushPending(new Error("gateway client stopped")); } @@ -161,6 +184,14 @@ export class GatewayBrowserClient { this.ws = null; this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`)); this.opts.onClose?.({ code: ev.code, reason, error: connectError }); + const connectErrorCode = resolveGatewayErrorDetailCode(connectError); + if ( + connectErrorCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH && + this.deviceTokenRetryBudgetUsed && + !this.pendingDeviceTokenRetry + ) { + return; + } if (!isNonRecoverableAuthError(connectError)) { this.scheduleReconnect(); } @@ -215,9 +246,20 @@ export class GatewayBrowserClient { deviceId: deviceIdentity.deviceId, role, })?.token; - deviceToken = !(explicitGatewayToken || this.opts.password?.trim()) - ? (storedToken ?? undefined) - : undefined; + const shouldUseDeviceRetryToken = + this.pendingDeviceTokenRetry && + !deviceToken && + Boolean(explicitGatewayToken) && + Boolean(storedToken) && + isTrustedRetryEndpoint(this.opts.url); + if (shouldUseDeviceRetryToken) { + deviceToken = storedToken ?? undefined; + this.pendingDeviceTokenRetry = false; + } else { + deviceToken = !(explicitGatewayToken || this.opts.password?.trim()) + ? (storedToken ?? undefined) + : undefined; + } canFallbackToShared = Boolean(deviceToken && explicitGatewayToken); } authToken = explicitGatewayToken ?? deviceToken; @@ -225,6 +267,7 @@ export class GatewayBrowserClient { authToken || this.opts.password ? { token: authToken, + deviceToken, password: this.opts.password, } : undefined; @@ -282,6 +325,8 @@ export class GatewayBrowserClient { void this.request("connect", params) .then((hello) => { + this.pendingDeviceTokenRetry = false; + this.deviceTokenRetryBudgetUsed = false; if (hello?.auth?.deviceToken && deviceIdentity) { storeDeviceAuthToken({ deviceId: deviceIdentity.deviceId, @@ -294,6 +339,33 @@ export class GatewayBrowserClient { this.opts.onHello?.(hello); }) .catch((err: unknown) => { + const connectErrorCode = + err instanceof GatewayRequestError ? resolveGatewayErrorDetailCode(err) : null; + const recoveryAdvice = + err instanceof GatewayRequestError ? readConnectErrorRecoveryAdvice(err.details) : {}; + const retryWithDeviceTokenRecommended = + recoveryAdvice.recommendedNextStep === "retry_with_device_token"; + const canRetryWithDeviceTokenHint = + recoveryAdvice.canRetryWithDeviceToken === true || + retryWithDeviceTokenRecommended || + connectErrorCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH; + const shouldRetryWithDeviceToken = + !this.deviceTokenRetryBudgetUsed && + !deviceToken && + Boolean(explicitGatewayToken) && + Boolean(deviceIdentity) && + Boolean( + loadDeviceAuthToken({ + deviceId: deviceIdentity?.deviceId ?? "", + role, + })?.token, + ) && + canRetryWithDeviceTokenHint && + isTrustedRetryEndpoint(this.opts.url); + if (shouldRetryWithDeviceToken) { + this.pendingDeviceTokenRetry = true; + this.deviceTokenRetryBudgetUsed = true; + } if (err instanceof GatewayRequestError) { this.pendingConnectError = { code: err.gatewayCode, @@ -303,7 +375,11 @@ export class GatewayBrowserClient { } else { this.pendingConnectError = undefined; } - if (canFallbackToShared && deviceIdentity) { + if ( + canFallbackToShared && + deviceIdentity && + connectErrorCode === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH + ) { clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role }); } this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed");