diff --git a/.gitignore b/.gitignore index 120ff08b8..69d89b2c4 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ __pycache__/ ui/src/ui/__screenshots__/ ui/playwright-report/ ui/test-results/ +packages/dashboard-next/.next/ +packages/dashboard-next/out/ # Mise configuration files mise.toml @@ -101,3 +103,4 @@ package-lock.json apps/ios/LocalSigning.xcconfig # Generated protocol schema (produced via pnpm protocol:gen) dist/protocol.schema.json +.ant-colony/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 89021c87f..7d58308a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Docs/Subagents: make thread-bound session guidance channel-first instead of Discord-specific, and list thread-supporting channels explicitly. (#23589) Thanks @osolmaz. - Channels/Config: unify channel preview streaming config handling with a shared resolver and canonical migration path. - Discord/Allowlist: canonicalize resolved Discord allowlist names to IDs and split resolution flow for clearer fail-closed behavior. - Memory/FTS: add Korean stop-word filtering and particle-aware keyword extraction (including mixed Korean/English stems) for query expansion in FTS-only search mode. (#18899) Thanks @ruypang. @@ -15,9 +16,16 @@ Docs: https://docs.openclaw.ai - **BREAKING:** remove legacy Gateway device-auth signature `v1`. Device-auth clients must now sign `v2` payloads with the per-connection `connect.challenge` nonce and send `device.nonce`; nonce-less connects are rejected. - **BREAKING:** unify channel preview-streaming config to `channels..streaming` with enum values `off | partial | block | progress`, and move Slack native stream toggle to `channels.slack.nativeStreaming`. Legacy keys (`streamMode`, Slack boolean `streaming`) are still read and migrated by `openclaw doctor --fix`, but canonical saved config/docs now use the unified names. +- **BREAKING:** CLI local onboarding now sets `session.dmScope` to `per-channel-peer` by default for new/implicit DM scope configuration. If you depend on shared DM continuity across senders, explicitly set `session.dmScope` to `main`. (#23468) Thanks @bmendonca3. ### Fixes +- Gateway/Subagents: guard gateway and subagent session-key/message trim paths against undefined inputs to prevent early `Cannot read properties of undefined (reading 'trim')` crashes during subagent spawn and wait flows. +- Agents/Workspace: guard `resolveUserPath` against undefined/null input to prevent `Cannot read properties of undefined (reading 'trim')` crashes when workspace paths are missing in embedded runner flows. +- Auth/Profiles: keep active `cooldownUntil`/`disabledUntil` windows immutable across retries so mid-window failures cannot extend recovery indefinitely; only recompute a backoff window after the previous deadline has expired. This resolves cron/inbound retry loops that could trap gateways until manual `usageStats` cleanup. (#23516, #23536) Thanks @arosstale. +- Channels/Security: fail closed on missing provider group policy config by defaulting runtime group policy to `allowlist` (instead of inheriting `channels.defaults.groupPolicy`) when `channels.` is absent across message channels, and align runtime + security warnings/docs to the same fallback behavior (Slack, Discord, iMessage, Telegram, WhatsApp, Signal, LINE, Matrix, Mattermost, Google Chat, IRC, Nextcloud Talk, Feishu, and Zalo user flows; plus Discord message/native-command paths). (#23367) Thanks @bmendonca3. +- Gateway/Onboarding: harden remote gateway onboarding defaults and guidance by defaulting discovered direct URLs to `wss://`, rejecting insecure non-loopback `ws://` targets in onboarding validation, and expanding remote-security remediation messaging across gateway client/call/doctor flows. (#23476) Thanks @bmendonca3. +- CLI/Sessions: pass the configured sessions directory when resolving transcript paths in `agentCommand`, so custom `session.store` locations resume sessions reliably. Thanks @davidrudduck. - Gateway/Chat UI: strip inline reply/audio directive tags from non-streaming final webchat broadcasts (including `chat.inject`) while preserving empty-string message content when tags are the entire reply. (#23298) Thanks @SidQin-cyber. - Gateway/Restart: fix restart-loop edge cases by keeping `openclaw.mjs -> dist/entry.js` bootstrap detection explicit, reacquiring the gateway lock for in-process restart fallback paths, and tightening restart-loop regression coverage. (#23416) Thanks @jeffwnli. - Signal/Monitor: treat user-initiated abort shutdowns as clean exits when auto-started `signal-cli` is terminated, while still surfacing unexpected daemon exits as startup/runtime failures. (#23379) Thanks @frankekn. @@ -27,9 +35,11 @@ Docs: https://docs.openclaw.ai - Security/Audit: add `openclaw security audit` detection for open group policies that expose runtime/filesystem tools without sandbox/workspace guards (`security.exposure.open_groups_with_runtime_or_fs`). - Security/Audit: make `gateway.real_ip_fallback_enabled` severity conditional for loopback trusted-proxy setups (warn for loopback-only `trustedProxies`, critical when non-loopback proxies are trusted). (#23428) Thanks @bmendonca3. - Security/Exec env: block request-scoped `HOME` and `ZDOTDIR` overrides in host exec env sanitizers (Node + macOS), preventing shell startup-file execution before allowlist-evaluated command bodies. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec env: block `SHELLOPTS`/`PS4` in host exec env sanitizers and restrict shell-wrapper (`bash|sh|zsh ... -c/-lc`) request env overrides to a small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`) on both node host and macOS companion paths, preventing xtrace prompt command-substitution allowlist bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Gateway: emit a startup security warning when insecure/dangerous config flags are enabled (including `gateway.controlUi.dangerouslyDisableDeviceAuth=true`) and point operators to `openclaw security audit`. - Security/Hooks auth: normalize hook auth rate-limit client IP keys so IPv4 and IPv4-mapped IPv6 addresses share one throttle bucket, preventing dual-form auth-attempt budget bypasses. This ships in the next npm release. Thanks @aether-ai-agent for reporting. - Security/Exec approvals: treat `env` and shell-dispatch wrappers as transparent during allowlist analysis on node-host and macOS companion paths so policy checks match the effective executable/inline shell payload instead of the wrapper binary, blocking wrapper-smuggled allowlist bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec approvals: require explicit safe-bin profiles for `tools.exec.safeBins` entries in allowlist mode (remove generic safe-bin profile fallback), and add `tools.exec.safeBinProfiles` for safe custom binaries so unprofiled interpreter-style entries cannot be treated as stdin-safe. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Channels: harden Slack external menu token handling by switching to CSPRNG tokens, validating token shape, requiring user identity for external option lookups, and avoiding fabricated timestamp `trigger_id` fallbacks; also switch Tlon Urbit channel IDs to CSPRNG UUIDs, centralize secure ID/token generation via shared infra helpers, and add a guardrail test to block new runtime `Date.now()+Math.random()` token/id patterns. - Security/Hooks transforms: enforce symlink-safe containment for webhook transform module paths (including `hooks.transformsDir` and `hooks.mappings[].transform.module`) by resolving existing-path ancestors via realpath before import, while preserving in-root symlink support; add regression coverage for both escape and allow cases. This ships in the next npm release. Thanks @aether-ai-agent for reporting. - Telegram/WSL2: disable `autoSelectFamily` by default on WSL2 and memoize WSL2 detection in Telegram network decision logic to avoid repeated sync `/proc/version` probes on fetch/send paths. (#21916) Thanks @MizukiMachine. @@ -48,6 +58,7 @@ Docs: https://docs.openclaw.ai - TUI/Status: request immediate renders after setting `sending`/`waiting` activity states so in-flight runs always show visible progress indicators instead of appearing idle until completion. (#21549) Thanks @13Guinness. - TUI/Input: arm Ctrl+C exit timing when clearing non-empty composer text and add a SIGINT fallback path so double Ctrl+C exits remain responsive during active runs instead of requiring an extra press or appearing stuck. (#23407) Thanks @tinybluedev. - Agents/Fallbacks: treat JSON payloads with `type: "api_error"` + `"Internal server error"` as transient failover errors so Anthropic 500-style failures trigger model fallback. (#23193) Thanks @jarvis-lane. +- Agents/Google: sanitize non-base64 `thought_signature`/`thoughtSignature` values from assistant replay transcripts for native Google Gemini requests while preserving valid signatures and tool-call order. (#23457) Thanks @echoVic. - Agents/Transcripts: validate assistant tool-call names (syntax/length + registered tool allowlist) before transcript persistence and during replay sanitization so malformed failover tool names no longer poison sessions with repeated provider HTTP 400 errors. (#23324) Thanks @johnsantry. - Agents/Compaction: strip stale assistant usage snapshots from pre-compaction turns when replaying history after a compaction summary so context-token estimation no longer reuses pre-compaction totals and immediately re-triggers destructive follow-up compactions. (#19127) Thanks @tedwatson. - Agents/Replies: emit a default completion acknowledgement (`✅ Done.`) when runs execute tools successfully but return no final assistant text, preventing silent no-reply turns after tool-only completions. (#22834) Thanks @Oldshue. @@ -58,10 +69,13 @@ Docs: https://docs.openclaw.ai - Gateway/Pairing: treat operator.admin pairing tokens as satisfying operator.write requests so legacy devices stop looping through scope-upgrade prompts introduced in 2026.2.19. (#23125, #23006) Thanks @vignesh07. - Gateway/Pairing: treat `operator.admin` as satisfying other `operator.*` scope checks during device-auth verification so local CLI/TUI sessions stop entering pairing-required loops for pairing/approval-scoped commands. (#22062, #22193, #21191) Thanks @Botaccess, @jhartshorn, and @ctbritt. - Gateway/Pairing: preserve existing approved token scopes when processing repair pairings that omit `scopes`, preventing empty-scope token regressions on reconnecting clients. (#21906) Thanks @paki81. +- Gateway/Scopes: include `operator.read` and `operator.write` in default operator connect scope bundles across CLI, Control UI, and macOS clients so write-scoped announce/sub-agent follow-up calls no longer hit `pairing required` disconnects on loopback gateways. (#22582) thanks @YuzuruS. - Plugins/CLI: make `openclaw plugins enable` and plugin install/link flows update allowlists via shared plugin-enable policy so enabled plugins are not left disabled by allowlist mismatch. (#23190) Thanks @downwind7clawd-ctrl. - Memory/QMD: add optional `memory.qmd.mcporter` search routing so QMD `query/search/vsearch` can run through mcporter keep-alive flows (including multi-collection paths) to reduce cold starts, while keeping searches on agent-scoped QMD state for consistent recall. (#19617) Thanks @nicole-luxe and @vignesh07. - Chat/UI: strip inline reply/audio directive tags (`[[reply_to_current]]`, `[[reply_to:]]`, `[[audio_as_voice]]`) from displayed chat history, live chat event output, and session preview snippets so control tags no longer leak into user-visible surfaces. +- Telegram/Retry: classify undici `TypeError: fetch failed` as recoverable in both polling and send retry paths so transient fetch failures no longer fail fast. (#16699) thanks @Glucksberg. - BlueBubbles/DM history: restore DM backfill context with account-scoped rolling history, bounded backfill retries, and safer history payload limits. (#20302) Thanks @Ryan-Haines. +- BlueBubbles/Private API cache: treat unknown (`null`) private-API cache status as disabled for send/attachment/reply flows to avoid stale-cache 500s, and log a warning when reply/effect features are requested while capability is unknown. (#23459) Thanks @echoVic. - BlueBubbles/Webhooks: accept inbound/reaction webhook payloads when BlueBubbles omits `handle` but provides DM `chatGuid`, and harden payload extraction for array/string-wrapped message bodies so valid webhook events no longer get rejected as unparseable. (#23275) Thanks @toph31. - Security/Audit: add `openclaw security audit` finding `gateway.nodes.allow_commands_dangerous` for risky `gateway.nodes.allowCommands` overrides, with severity upgraded to critical on remote gateway exposure. - Gateway/Control plane: reduce cross-client write limiter contention by adding `connId` fallback keying when device ID and client IP are both unavailable. @@ -92,6 +106,7 @@ Docs: https://docs.openclaw.ai - Cron: persist `delivered` state in cron job records so delivery failures remain visible in status and logs. (#19174) Thanks @simonemacario. - Config/Doctor: only repair the OAuth credentials directory when affected channels are configured, avoiding fresh-install noise. - Config/Channels: whitelist `channels.modelByChannel` in config validation and exclude it from plugin auto-enable channel detection so model overrides no longer trigger `unknown channel id` validation errors or bogus `modelByChannel` plugin enables. (#23412) Thanks @ProspectOre. +- Config/Bindings: allow optional `bindings[].comment` in strict config validation so annotated binding entries no longer fail load. (#23458) Thanks @echoVic. - Usage/Pricing: correct MiniMax M2.5 pricing defaults to fix inflated cost reporting. (#22755) Thanks @miloudbelarebia. - Gateway/Daemon: verify gateway health after daemon restart. - Agents/UI text: stop rewriting normal assistant billing/payment language outside explicit error contexts. (#17834) Thanks @niceysam. @@ -126,6 +141,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/Bootstrap: skip malformed bootstrap files with missing/invalid paths instead of crashing agent sessions; hooks using `filePath` (or non-string `path`) are skipped with a warning. (#22693, #22698) Thanks @arosstale. - Security/Agents: cap embedded Pi runner outer retry loop with a higher profile-aware dynamic limit (32-160 attempts) and return an explicit `retry_limit` error payload when retries never converge, preventing unbounded internal retry cycles (`GHSA-76m6-pj3w-v7mf`). - Telegram: detect duplicate bot-token ownership across Telegram accounts at startup/status time, mark secondary accounts as not configured with an explicit fix message, and block duplicate account startup before polling to avoid endless `getUpdates` conflict loops. - Agents/Tool images: include source filenames in `agents/tool-images` resize logs so compression events can be traced back to specific files. diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift b/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift index 7bb05aff0..c7d9d0928 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift @@ -28,7 +28,8 @@ enum ExecApprovalEvaluator { let approvals = ExecApprovalsStore.resolve(agentId: normalizedAgentId) let security = approvals.agent.security let ask = approvals.agent.ask - let env = HostEnvSanitizer.sanitize(overrides: envOverrides) + let shellWrapper = ExecShellWrapperParser.extract(command: command, rawCommand: rawCommand).isWrapper + let env = HostEnvSanitizer.sanitize(overrides: envOverrides, shellWrapper: shellWrapper) let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: rawCommand) let allowlistResolutions = ExecCommandResolution.resolveForAllowlist( command: command, diff --git a/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift b/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift index 846c89781..b9b993299 100644 --- a/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift +++ b/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift @@ -15,6 +15,8 @@ enum HostEnvSanitizer { "BASH_ENV", "ENV", "SHELL", + "SHELLOPTS", + "PS4", "GCONV_PATH", "IFS", "SSLKEYLOGFILE", @@ -29,13 +31,36 @@ enum HostEnvSanitizer { "HOME", "ZDOTDIR", ] + private static let shellWrapperAllowedOverrideKeys: Set = [ + "TERM", + "LANG", + "LC_ALL", + "LC_CTYPE", + "LC_MESSAGES", + "COLORTERM", + "NO_COLOR", + "FORCE_COLOR", + ] private static func isBlocked(_ upperKey: String) -> Bool { if self.blockedKeys.contains(upperKey) { return true } return self.blockedPrefixes.contains(where: { upperKey.hasPrefix($0) }) } - static func sanitize(overrides: [String: String]?) -> [String: String] { + private static func filterOverridesForShellWrapper(_ overrides: [String: String]?) -> [String: String]? { + guard let overrides else { return nil } + var filtered: [String: String] = [:] + for (rawKey, value) in overrides { + let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { continue } + if self.shellWrapperAllowedOverrideKeys.contains(key.uppercased()) { + filtered[key] = value + } + } + return filtered.isEmpty ? nil : filtered + } + + static func sanitize(overrides: [String: String]?, shellWrapper: Bool = false) -> [String: String] { var merged: [String: String] = [:] for (rawKey, value) in ProcessInfo.processInfo.environment { let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) @@ -45,8 +70,12 @@ enum HostEnvSanitizer { merged[key] = value } - guard let overrides else { return merged } - for (rawKey, value) in overrides { + let effectiveOverrides = shellWrapper + ? self.filterOverridesForShellWrapper(overrides) + : overrides + + guard let effectiveOverrides else { return merged } + for (rawKey, value) in effectiveOverrides { let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !key.isEmpty else { continue } let upper = key.uppercased() diff --git a/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift b/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift index 0989164a0..151b7fdda 100644 --- a/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift +++ b/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift @@ -15,7 +15,7 @@ struct ConnectOptions { var clientMode: String = "ui" var displayName: String? var role: String = "operator" - var scopes: [String] = ["operator.admin", "operator.approvals", "operator.pairing"] + var scopes: [String] = defaultOperatorConnectScopes var help: Bool = false static func parse(_ args: [String]) -> ConnectOptions { diff --git a/apps/macos/Sources/OpenClawMacCLI/GatewayScopes.swift b/apps/macos/Sources/OpenClawMacCLI/GatewayScopes.swift new file mode 100644 index 000000000..479c176d5 --- /dev/null +++ b/apps/macos/Sources/OpenClawMacCLI/GatewayScopes.swift @@ -0,0 +1,7 @@ +let defaultOperatorConnectScopes: [String] = [ + "operator.admin", + "operator.read", + "operator.write", + "operator.approvals", + "operator.pairing", +] diff --git a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift index 2d36bac3c..ebe3e8ae6 100644 --- a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift +++ b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift @@ -251,7 +251,7 @@ actor GatewayWizardClient { let clientMode = "ui" let role = "operator" // Explicit scopes; gateway no longer defaults empty scopes to admin. - let scopes: [String] = ["operator.admin", "operator.approvals", "operator.pairing"] + let scopes = defaultOperatorConnectScopes let client: [String: ProtoAnyCodable] = [ "id": ProtoAnyCodable(clientId), "displayName": ProtoAnyCodable(Host.current().localizedName ?? "OpenClaw macOS Wizard CLI"), diff --git a/apps/macos/Tests/OpenClawIPCTests/HostEnvSanitizerTests.swift b/apps/macos/Tests/OpenClawIPCTests/HostEnvSanitizerTests.swift new file mode 100644 index 000000000..7ee15107f --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/HostEnvSanitizerTests.swift @@ -0,0 +1,36 @@ +import Testing +@testable import OpenClaw + +struct HostEnvSanitizerTests { + @Test func sanitizeBlocksShellTraceVariables() { + let env = HostEnvSanitizer.sanitize(overrides: [ + "SHELLOPTS": "xtrace", + "PS4": "$(touch /tmp/pwned)", + "OPENCLAW_TEST": "1", + ]) + #expect(env["SHELLOPTS"] == nil) + #expect(env["PS4"] == nil) + #expect(env["OPENCLAW_TEST"] == "1") + } + + @Test func sanitizeShellWrapperAllowsOnlyExplicitOverrideKeys() { + let env = HostEnvSanitizer.sanitize( + overrides: [ + "LANG": "C", + "LC_ALL": "C", + "OPENCLAW_TOKEN": "secret", + "PS4": "$(touch /tmp/pwned)", + ], + shellWrapper: true) + + #expect(env["LANG"] == "C") + #expect(env["LC_ALL"] == "C") + #expect(env["OPENCLAW_TOKEN"] == nil) + #expect(env["PS4"] == nil) + } + + @Test func sanitizeNonShellWrapperKeepsRegularOverrides() { + let env = HostEnvSanitizer.sanitize(overrides: ["OPENCLAW_TOKEN": "secret"]) + #expect(env["OPENCLAW_TOKEN"] == "secret") + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index 1aa1b5ae3..30935df79 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -127,6 +127,14 @@ private enum ConnectChallengeError: Error { case timeout } +private let defaultOperatorConnectScopes: [String] = [ + "operator.admin", + "operator.read", + "operator.write", + "operator.approvals", + "operator.pairing", +] + public actor GatewayChannelActor { private let logger = Logger(subsystem: "ai.openclaw", category: "gateway") private var task: WebSocketTaskBox? @@ -318,7 +326,7 @@ public actor GatewayChannelActor { let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier let options = self.connectOptions ?? GatewayConnectOptions( role: "operator", - scopes: ["operator.admin", "operator.approvals", "operator.pairing"], + scopes: defaultOperatorConnectScopes, caps: [], commands: [], permissions: [:], diff --git a/docs/channels/discord.md b/docs/channels/discord.md index d725b5c2e..334c6d78e 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -425,7 +425,7 @@ Example: } ``` - If you only set `DISCORD_BOT_TOKEN` and do not create a `channels.discord` block, runtime fallback is `groupPolicy="open"` (with a warning in logs). + If you only set `DISCORD_BOT_TOKEN` and do not create a `channels.discord` block, runtime fallback is `groupPolicy="allowlist"` (with a warning in logs), even if `channels.defaults.groupPolicy` is `open`. diff --git a/docs/channels/groups.md b/docs/channels/groups.md index 6bd278846..00118c546 100644 --- a/docs/channels/groups.md +++ b/docs/channels/groups.md @@ -190,6 +190,7 @@ Notes: - Group DMs are controlled separately (`channels.discord.dm.*`, `channels.slack.dm.*`). - Telegram allowlist can match user IDs (`"123456789"`, `"telegram:123456789"`, `"tg:123456789"`) or usernames (`"@alice"` or `"alice"`); prefixes are case-insensitive. - Default is `groupPolicy: "allowlist"`; if your group allowlist is empty, group messages are blocked. +- Runtime safety: when a provider block is completely missing (`channels.` absent), group policy falls back to a fail-closed mode (typically `allowlist`) instead of inheriting `channels.defaults.groupPolicy`. Quick mental model (evaluation order for group messages): diff --git a/docs/channels/imessage.md b/docs/channels/imessage.md index d7a1b6335..5720da171 100644 --- a/docs/channels/imessage.md +++ b/docs/channels/imessage.md @@ -158,6 +158,7 @@ imsg send "test" Group sender allowlist: `channels.imessage.groupAllowFrom`. Runtime fallback: if `groupAllowFrom` is unset, iMessage group sender checks fall back to `allowFrom` when available. + Runtime note: if `channels.imessage` is completely missing, runtime falls back to `groupPolicy="allowlist"` and logs a warning (even if `channels.defaults.groupPolicy` is set). Mention gating for groups: diff --git a/docs/channels/line.md b/docs/channels/line.md index d32e683fb..b87cbd3f5 100644 --- a/docs/channels/line.md +++ b/docs/channels/line.md @@ -118,6 +118,7 @@ Allowlists and policies: - `channels.line.groupPolicy`: `allowlist | open | disabled` - `channels.line.groupAllowFrom`: allowlisted LINE user IDs for groups - Per-group overrides: `channels.line.groups..allowFrom` +- Runtime note: if `channels.line` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set). LINE IDs are case-sensitive. Valid IDs look like: diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 04205d949..9bb56d1dd 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -195,6 +195,7 @@ Notes: ## Rooms (groups) - Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset. +- Runtime note: if `channels.matrix` is completely missing, runtime falls back to `groupPolicy="allowlist"` for room checks (even if `channels.defaults.groupPolicy` is set). - Allowlist rooms with `channels.matrix.groups` (room IDs or aliases; names are resolved to IDs when directory search finds a single exact match): ```json5 diff --git a/docs/channels/mattermost.md b/docs/channels/mattermost.md index fa0d9393e..350fa8429 100644 --- a/docs/channels/mattermost.md +++ b/docs/channels/mattermost.md @@ -103,6 +103,7 @@ Notes: - Default: `channels.mattermost.groupPolicy = "allowlist"` (mention-gated). - Allowlist senders with `channels.mattermost.groupAllowFrom` (user IDs or `@username`). - Open channels: `channels.mattermost.groupPolicy="open"` (mention-gated). +- Runtime note: if `channels.mattermost` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set). ## Targets for outbound delivery diff --git a/docs/channels/signal.md b/docs/channels/signal.md index 60bb5f7ce..b216af120 100644 --- a/docs/channels/signal.md +++ b/docs/channels/signal.md @@ -195,6 +195,7 @@ Groups: - `channels.signal.groupPolicy = open | allowlist | disabled`. - `channels.signal.groupAllowFrom` controls who can trigger in groups when `allowlist` is set. +- Runtime note: if `channels.signal` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set). ## How it works (behavior) diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 0d0bba3cb..4a1bda699 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -165,7 +165,7 @@ For actions/directory reads, user token can be preferred when configured. For wr Channel allowlist lives under `channels.slack.channels`. - Runtime note: if `channels.slack` is completely missing (env-only setup) and `channels.defaults.groupPolicy` is unset, runtime falls back to `groupPolicy="open"` and logs a warning. + Runtime note: if `channels.slack` is completely missing (env-only setup), runtime falls back to `groupPolicy="allowlist"` and logs a warning (even if `channels.defaults.groupPolicy` is set). Name/ID resolution: diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 8676bce4e..138b2b255 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -148,6 +148,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 must be numeric Telegram user IDs. + Runtime note: if `channels.telegram` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group policy evaluation (even if `channels.defaults.groupPolicy` is set). Example: allow any member in one specific group: @@ -670,6 +671,25 @@ openclaw message send --channel telegram --target @name --message "hi" - Node 22+ + custom fetch/proxy can trigger immediate abort behavior if AbortSignal types mismatch. - Some hosts resolve `api.telegram.org` to IPv6 first; broken IPv6 egress can cause intermittent Telegram API failures. + - If logs include `TypeError: fetch failed` or `Network request for 'getUpdates' failed!`, OpenClaw now retries these as recoverable network errors. + - On VPS hosts with unstable direct egress/TLS, route Telegram API calls through `channels.telegram.proxy`: + +```yaml +channels: + telegram: + proxy: socks5://user:pass@proxy-host:1080 +``` + + - If DNS/IPv6 selection is unstable, force Node family selection behavior explicitly: + +```yaml +channels: + telegram: + network: + autoSelectFamily: false +``` + + - Environment override (temporary): set `OPENCLAW_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY=1`. - Validate DNS answers: ```bash diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index a6fb427bd..d92dfda9c 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -171,7 +171,7 @@ OpenClaw recommends running WhatsApp on a separate number when possible. (The ch - if `groupAllowFrom` is unset, runtime falls back to `allowFrom` when available - sender allowlists are evaluated before mention/reply activation - Note: if no `channels.whatsapp` block exists at all, runtime group-policy fallback is effectively `open`. + Note: if no `channels.whatsapp` block exists at all, runtime group-policy fallback is `allowlist` (with a warning log), even if `channels.defaults.groupPolicy` is set. diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md index ee6f147f2..0d39ab87d 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -60,6 +60,7 @@ Flow notes: - `quickstart`: minimal prompts, auto-generates a gateway token. - `manual`: full prompts for port/bind/auth (alias of `advanced`). +- Local onboarding DM scope behavior: [CLI Onboarding Reference](/start/wizard-cli-reference#outputs-and-internals). - Fastest first chat: `openclaw dashboard` (Control UI, no channel setup). - Custom Provider: connect any OpenAI or Anthropic compatible endpoint, including hosted providers not listed. Use Unknown to auto-detect. diff --git a/docs/concepts/session.md b/docs/concepts/session.md index edd6f415d..3d1503ab8 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -49,6 +49,7 @@ Use `session.dmScope` to control how **direct messages** are grouped: Notes: - Default is `dmScope: "main"` for continuity (all DMs share the main session). This is fine for single-user setups. +- Local CLI onboarding writes `session.dmScope: "per-channel-peer"` by default when unset (existing explicit values are preserved). - For multi-account inboxes on the same channel, prefer `per-account-channel-peer`. - If the same person contacts you on multiple channels, use `session.identityLinks` to collapse their DM sessions into one canonical identity. - You can verify your DM settings with `openclaw security audit` (see [security](/cli/security)). diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index b11ea7a37..34478bb32 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -35,7 +35,7 @@ All channels support DM policies and group policies: `channels.defaults.groupPolicy` sets the default when a provider's `groupPolicy` is unset. Pairing codes expire after 1 hour. Pending DM pairing requests are capped at **3 per channel**. -Slack/Discord have a special fallback: if their provider section is missing entirely, runtime group policy can resolve to `open` (with a startup warning). +If a provider block is missing entirely (`channels.` absent), runtime group policy falls back to `allowlist` (fail-closed) with a startup warning. ### Channel model overrides diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index f5e46dce4..7abbea866 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -117,33 +117,34 @@ When the audit prints findings, treat this as a priority order: High-signal `checkId` values you will most likely see in real deployments (not exhaustive): -| `checkId` | Severity | Why it matters | Primary fix key/path | Auto-fix | -| -------------------------------------------------- | ------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -------- | -| `fs.state_dir.perms_world_writable` | critical | Other users/processes can modify full OpenClaw state | filesystem perms on `~/.openclaw` | yes | -| `fs.config.perms_writable` | critical | Others can change auth/tool policy/config | filesystem perms on `~/.openclaw/openclaw.json` | yes | -| `fs.config.perms_world_readable` | critical | Config can expose tokens/settings | filesystem perms on config file | yes | -| `gateway.bind_no_auth` | critical | Remote bind without shared secret | `gateway.bind`, `gateway.auth.*` | no | -| `gateway.loopback_no_auth` | critical | Reverse-proxied loopback may become unauthenticated | `gateway.auth.*`, proxy setup | no | -| `gateway.http.no_auth` | warn/critical | Gateway HTTP APIs reachable with `auth.mode="none"` | `gateway.auth.mode`, `gateway.http.endpoints.*` | no | -| `gateway.tools_invoke_http.dangerous_allow` | warn/critical | Re-enables dangerous tools over HTTP API | `gateway.tools.allow` | no | -| `gateway.nodes.allow_commands_dangerous` | warn/critical | Enables high-impact node commands (camera/screen/contacts/calendar/SMS) | `gateway.nodes.allowCommands` | no | -| `gateway.tailscale_funnel` | critical | Public internet exposure | `gateway.tailscale.mode` | no | -| `gateway.control_ui.insecure_auth` | warn | Insecure-auth compatibility toggle enabled | `gateway.controlUi.allowInsecureAuth` | no | -| `gateway.control_ui.device_auth_disabled` | critical | Disables device identity check | `gateway.controlUi.dangerouslyDisableDeviceAuth` | no | -| `gateway.real_ip_fallback_enabled` | warn/critical | Trusting `X-Real-IP` fallback can enable source-IP spoofing via proxy misconfig | `gateway.allowRealIpFallback`, `gateway.trustedProxies` | no | -| `discovery.mdns_full_mode` | warn/critical | mDNS full mode advertises `cliPath`/`sshPort` metadata on local network | `discovery.mdns.mode`, `gateway.bind` | no | -| `config.insecure_or_dangerous_flags` | warn | Any insecure/dangerous debug flags enabled | multiple keys (see finding detail) | no | -| `hooks.token_too_short` | warn | Easier brute force on hook ingress | `hooks.token` | no | -| `hooks.request_session_key_enabled` | warn/critical | External caller can choose sessionKey | `hooks.allowRequestSessionKey` | no | -| `hooks.request_session_key_prefixes_missing` | warn/critical | No bound on external session key shapes | `hooks.allowedSessionKeyPrefixes` | no | -| `logging.redact_off` | warn | Sensitive values leak to logs/status | `logging.redactSensitive` | yes | -| `sandbox.docker_config_mode_off` | warn | Sandbox Docker config present but inactive | `agents.*.sandbox.mode` | no | -| `tools.exec.host_sandbox_no_sandbox_defaults` | warn | `exec host=sandbox` resolves to host exec when sandbox is off | `tools.exec.host`, `agents.defaults.sandbox.mode` | no | -| `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` resolves to host exec when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no | -| `security.exposure.open_groups_with_runtime_or_fs` | critical/warn | Open groups can reach command/file tools without sandbox/workspace guards | `channels.*.groupPolicy`, `tools.profile/deny`, `tools.fs.workspaceOnly`, `agents.*.sandbox.mode` | no | -| `tools.profile_minimal_overridden` | warn | Agent overrides bypass global minimal profile | `agents.list[].tools.profile` | no | -| `plugins.tools_reachable_permissive_policy` | warn | Extension tools reachable in permissive contexts | `tools.profile` + tool allow/deny | no | -| `models.small_params` | critical/info | Small models + unsafe tool surfaces raise injection risk | model choice + sandbox/tool policy | no | +| `checkId` | Severity | Why it matters | Primary fix key/path | Auto-fix | +| -------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -------- | +| `fs.state_dir.perms_world_writable` | critical | Other users/processes can modify full OpenClaw state | filesystem perms on `~/.openclaw` | yes | +| `fs.config.perms_writable` | critical | Others can change auth/tool policy/config | filesystem perms on `~/.openclaw/openclaw.json` | yes | +| `fs.config.perms_world_readable` | critical | Config can expose tokens/settings | filesystem perms on config file | yes | +| `gateway.bind_no_auth` | critical | Remote bind without shared secret | `gateway.bind`, `gateway.auth.*` | no | +| `gateway.loopback_no_auth` | critical | Reverse-proxied loopback may become unauthenticated | `gateway.auth.*`, proxy setup | no | +| `gateway.http.no_auth` | warn/critical | Gateway HTTP APIs reachable with `auth.mode="none"` | `gateway.auth.mode`, `gateway.http.endpoints.*` | no | +| `gateway.tools_invoke_http.dangerous_allow` | warn/critical | Re-enables dangerous tools over HTTP API | `gateway.tools.allow` | no | +| `gateway.nodes.allow_commands_dangerous` | warn/critical | Enables high-impact node commands (camera/screen/contacts/calendar/SMS) | `gateway.nodes.allowCommands` | no | +| `gateway.tailscale_funnel` | critical | Public internet exposure | `gateway.tailscale.mode` | no | +| `gateway.control_ui.insecure_auth` | warn | Insecure-auth compatibility toggle enabled | `gateway.controlUi.allowInsecureAuth` | no | +| `gateway.control_ui.device_auth_disabled` | critical | Disables device identity check | `gateway.controlUi.dangerouslyDisableDeviceAuth` | no | +| `gateway.real_ip_fallback_enabled` | warn/critical | Trusting `X-Real-IP` fallback can enable source-IP spoofing via proxy misconfig | `gateway.allowRealIpFallback`, `gateway.trustedProxies` | no | +| `discovery.mdns_full_mode` | warn/critical | mDNS full mode advertises `cliPath`/`sshPort` metadata on local network | `discovery.mdns.mode`, `gateway.bind` | no | +| `config.insecure_or_dangerous_flags` | warn | Any insecure/dangerous debug flags enabled | multiple keys (see finding detail) | no | +| `hooks.token_too_short` | warn | Easier brute force on hook ingress | `hooks.token` | no | +| `hooks.request_session_key_enabled` | warn/critical | External caller can choose sessionKey | `hooks.allowRequestSessionKey` | no | +| `hooks.request_session_key_prefixes_missing` | warn/critical | No bound on external session key shapes | `hooks.allowedSessionKeyPrefixes` | no | +| `logging.redact_off` | warn | Sensitive values leak to logs/status | `logging.redactSensitive` | yes | +| `sandbox.docker_config_mode_off` | warn | Sandbox Docker config present but inactive | `agents.*.sandbox.mode` | no | +| `tools.exec.host_sandbox_no_sandbox_defaults` | warn | `exec host=sandbox` resolves to host exec when sandbox is off | `tools.exec.host`, `agents.defaults.sandbox.mode` | no | +| `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` resolves to host exec when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no | +| `tools.exec.safe_bins_interpreter_unprofiled` | warn | Interpreter/runtime bins in `safeBins` without explicit profiles broaden exec risk | `tools.exec.safeBins`, `tools.exec.safeBinProfiles`, `agents.list[].tools.exec.*` | no | +| `security.exposure.open_groups_with_runtime_or_fs` | critical/warn | Open groups can reach command/file tools without sandbox/workspace guards | `channels.*.groupPolicy`, `tools.profile/deny`, `tools.fs.workspaceOnly`, `agents.*.sandbox.mode` | no | +| `tools.profile_minimal_overridden` | warn | Agent overrides bypass global minimal profile | `agents.list[].tools.profile` | no | +| `plugins.tools_reachable_permissive_policy` | warn | Extension tools reachable in permissive contexts | `tools.profile` + tool allow/deny | no | +| `models.small_params` | critical/info | Small models + unsafe tool surfaces raise injection risk | model choice + sandbox/tool policy | no | ## Control UI over HTTP @@ -332,6 +333,7 @@ This is a messaging-context boundary, not a host-admin boundary. If users are mu Treat the snippet above as **secure DM mode**: - Default: `session.dmScope: "main"` (all DMs share one session for continuity). +- Local CLI onboarding default: writes `session.dmScope: "per-channel-peer"` when unset (keeps existing explicit values). - Secure DM mode: `session.dmScope: "per-channel-peer"` (each channel+sender pair gets an isolated DM context). If you run multiple accounts on the same channel, use `per-account-channel-peer` instead. If the same person contacts you on multiple channels, use `session.identityLinks` to collapse those DM sessions into one canonical identity. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration). diff --git a/docs/nodes/index.md b/docs/nodes/index.md index 9a6f3f1f7..430d07299 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -278,8 +278,9 @@ Notes: - `system.run` returns stdout/stderr/exit code in the payload. - `system.notify` respects notification permission state on the macOS app. - `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`. +- For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped `--env` values are reduced to an explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`). - `system.notify` supports `--priority ` and `--delivery `. -- Node hosts ignore `PATH` overrides. If you need extra PATH entries, configure the node host service environment (or install tools in standard locations) instead of passing `PATH` via `--env`. +- Node hosts ignore `PATH` overrides and strip dangerous startup/shell keys (`DYLD_*`, `LD_*`, `NODE_OPTIONS`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`). If you need extra PATH entries, configure the node host service environment (or install tools in standard locations) instead of passing `PATH` via `--env`. - On macOS node mode, `system.run` is gated by exec approvals in the macOS app (Settings → Exec approvals). Ask/allowlist/full behave the same as the headless node host; denied prompts return `SYSTEM_RUN_DENIED`. - On headless node host, `system.run` is gated by exec approvals (`~/.openclaw/exec-approvals.json`). diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md index 730d7015a..ce56aa107 100644 --- a/docs/platforms/macos.md +++ b/docs/platforms/macos.md @@ -105,7 +105,8 @@ Notes: - `allowlist` entries are glob patterns for resolved binary paths. - Raw shell command text that contains shell control or expansion syntax (`&&`, `||`, `;`, `|`, `` ` ``, `$`, `<`, `>`, `(`, `)`) is treated as an allowlist miss and requires explicit approval (or allowlisting the shell binary). - Choosing “Always Allow” in the prompt adds that command to the allowlist. -- `system.run` environment overrides are filtered (drops `PATH`, `DYLD_*`, `LD_*`, `NODE_OPTIONS`, `PYTHON*`, `PERL*`, `RUBYOPT`) and then merged with the app’s environment. +- `system.run` environment overrides are filtered (drops `PATH`, `DYLD_*`, `LD_*`, `NODE_OPTIONS`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`) and then merged with the app’s environment. +- For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped environment overrides are reduced to a small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`). ## Deep links diff --git a/docs/reference/wizard.md b/docs/reference/wizard.md index 19191252e..1bd83a0bc 100644 --- a/docs/reference/wizard.md +++ b/docs/reference/wizard.md @@ -243,6 +243,7 @@ Typical fields in `~/.openclaw/openclaw.json`: - `agents.defaults.workspace` - `agents.defaults.model` / `models.providers` (if Minimax chosen) - `gateway.*` (mode, bind, auth, tailscale) +- `session.dmScope` (behavior details: [CLI Onboarding Reference](/start/wizard-cli-reference#outputs-and-internals)) - `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*` - Channel allowlists (Slack/Discord/Matrix/Microsoft Teams) when you opt in during the prompts (names resolve to IDs when possible). - `skills.install.nodeManager` diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md index b0b31de8c..96fd1d87a 100644 --- a/docs/start/wizard-cli-reference.md +++ b/docs/start/wizard-cli-reference.md @@ -215,6 +215,7 @@ Typical fields in `~/.openclaw/openclaw.json`: - `agents.defaults.workspace` - `agents.defaults.model` / `models.providers` (if Minimax chosen) - `gateway.*` (mode, bind, auth, tailscale) +- `session.dmScope` (local onboarding defaults this to `per-channel-peer` when unset; existing explicit values are preserved) - `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*` - Channel allowlists (Slack, Discord, Matrix, Microsoft Teams) when you opt in during prompts (names resolve to IDs when possible) - `skills.install.nodeManager` diff --git a/docs/start/wizard.md b/docs/start/wizard.md index b869c8566..d653574f4 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -50,6 +50,7 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control). - Workspace default (or existing workspace) - Gateway port **18789** - Gateway auth **Token** (auto‑generated, even on loopback) + - DM isolation default: local onboarding writes `session.dmScope: "per-channel-peer"` when unset. Details: [CLI Onboarding Reference](/start/wizard-cli-reference#outputs-and-internals) - Tailscale exposure **Off** - Telegram + WhatsApp DMs default to **allowlist** (you'll be prompted for your phone number) diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index f977952c8..8d3452277 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -124,6 +124,10 @@ are treated as allowlisted on nodes (macOS node or headless node host). This use `tools.exec.safeBins` defines a small list of **stdin-only** binaries (for example `jq`) that can run in allowlist mode **without** explicit allowlist entries. Safe bins reject positional file args and path-like tokens, so they can only operate on the incoming stream. +Treat this as a narrow fast-path for stream filters, not a general trust list. +Do **not** add interpreter or runtime binaries (for example `python3`, `node`, `ruby`, `bash`, `sh`, `zsh`) to `safeBins`. +If a command can evaluate code, execute subcommands, or read files by design, prefer explicit allowlist entries and keep approval prompts enabled. +Custom safe bins must define an explicit profile in `tools.exec.safeBinProfiles.`. Validation is deterministic from argv shape only (no host filesystem existence checks), which prevents file-existence oracle behavior from allow/deny differences. File-oriented options are denied for default safe bins (for example `sort -o`, `sort --output`, @@ -155,6 +159,8 @@ double quotes; use single quotes if you need literal `$()` text. On macOS companion-app approvals, raw shell text containing shell control or expansion syntax (`&&`, `||`, `;`, `|`, `` ` ``, `$`, `<`, `>`, `(`, `)`) is treated as an allowlist miss unless the shell binary itself is allowlisted. +For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped env overrides are reduced to a +small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`). Default safe bins: `jq`, `cut`, `uniq`, `head`, `tail`, `tr`, `wc`. @@ -163,6 +169,44 @@ their non-stdin workflows. For `grep` in safe-bin mode, provide the pattern with `-e`/`--regexp`; positional pattern form is rejected so file operands cannot be smuggled as ambiguous positionals. +### Safe bins versus allowlist + +| Topic | `tools.exec.safeBins` | Allowlist (`exec-approvals.json`) | +| ---------------- | ------------------------------------------------------ | ------------------------------------------------------------ | +| Goal | Auto-allow narrow stdin filters | Explicitly trust specific executables | +| Match type | Executable name + safe-bin argv policy | Resolved executable path glob pattern | +| Argument scope | Restricted by safe-bin profile and literal-token rules | Path match only; arguments are otherwise your responsibility | +| Typical examples | `jq`, `head`, `tail`, `wc` | `python3`, `node`, `ffmpeg`, custom CLIs | +| Best use | Low-risk text transforms in pipelines | Any tool with broader behavior or side effects | + +Configuration location: + +- `safeBins` comes from config (`tools.exec.safeBins` or per-agent `agents.list[].tools.exec.safeBins`). +- `safeBinProfiles` comes from config (`tools.exec.safeBinProfiles` or per-agent `agents.list[].tools.exec.safeBinProfiles`). Per-agent profile keys override global keys. +- allowlist entries live in host-local `~/.openclaw/exec-approvals.json` under `agents..allowlist` (or via Control UI / `openclaw approvals allowlist ...`). +- `openclaw security audit` warns with `tools.exec.safe_bins_interpreter_unprofiled` when interpreter/runtime bins appear in `safeBins` without explicit profiles. +- `openclaw doctor --fix` can scaffold missing custom `safeBinProfiles.` entries as `{}` (review and tighten afterward). Interpreter/runtime bins are not auto-scaffolded. + +Custom profile example: + +```json5 +{ + tools: { + exec: { + safeBins: ["jq", "myfilter"], + safeBinProfiles: { + myfilter: { + minPositional: 0, + maxPositional: 0, + allowedValueFlags: ["-n", "--limit"], + deniedFlags: ["-f", "--file", "-c", "--command"], + }, + }, + }, + }, +} +``` + ## Control UI editing Use the **Control UI → Nodes → Exec approvals** card to edit defaults, per‑agent diff --git a/docs/tools/exec.md b/docs/tools/exec.md index 3712b5507..47842a7bb 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -55,6 +55,7 @@ Notes: - `tools.exec.node` (default: unset) - `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs (gateway + sandbox only). - `tools.exec.safeBins`: stdin-only safe binaries that can run without explicit allowlist entries. For behavior details, see [Safe bins](/tools/exec-approvals#safe-bins-stdin-only). +- `tools.exec.safeBinProfiles`: optional custom argv policy per safe bin (`minPositional`, `maxPositional`, `allowedValueFlags`, `deniedFlags`). Example: @@ -126,6 +127,17 @@ allowlisted or a safe bin. Chaining (`;`, `&&`, `||`) and redirections are rejec allowlist mode unless every top-level segment satisfies the allowlist (including safe bins). Redirections remain unsupported. +Use the two controls for different jobs: + +- `tools.exec.safeBins`: small, stdin-only stream filters. +- `tools.exec.safeBinProfiles`: explicit argv policy for custom safe bins. +- allowlist: explicit trust for executable paths. + +Do not treat `safeBins` as a generic allowlist, and do not add interpreter/runtime binaries (for example `python3`, `node`, `ruby`, `bash`). If you need those, use explicit allowlist entries and keep approval prompts enabled. +`openclaw security audit` warns when interpreter/runtime `safeBins` entries are missing explicit profiles, and `openclaw doctor --fix` can scaffold missing custom `safeBinProfiles` entries. + +For full policy details and examples, see [Exec approvals](/tools/exec-approvals#safe-bins-stdin-only) and [Safe bins versus allowlist](/tools/exec-approvals#safe-bins-versus-allowlist). + ## Examples Foreground: diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index 5c2549e44..7334da1ec 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -23,7 +23,9 @@ Use `/subagents` to inspect or control sub-agent runs for the **current session* - `/subagents steer ` - `/subagents spawn [--model ] [--thinking ]` -Discord thread binding controls: +Thread binding controls: + +These commands work on channels that support persistent thread bindings. See **Thread supporting channels** below. - `/focus ` - `/unfocus` @@ -85,14 +87,18 @@ Tool params: - `mode: "session"` requires `thread: true` - `cleanup?` (`delete|keep`, default `keep`) -## Discord thread-bound sessions +## Thread-bound sessions -When thread bindings are enabled, a sub-agent can stay bound to a Discord thread so follow-up user messages in that thread keep routing to the same sub-agent session. +When thread bindings are enabled for a channel, a sub-agent can stay bound to a thread so follow-up user messages in that thread keep routing to the same sub-agent session. + +### Thread supporting channels + +- Discord (currently the only supported channel): supports persistent thread-bound subagent sessions (`sessions_spawn` with `thread: true`), manual thread controls (`/focus`, `/unfocus`, `/agents`, `/session ttl`), and adapter keys `channels.discord.threadBindings.enabled`, `channels.discord.threadBindings.ttlHours`, and `channels.discord.threadBindings.spawnSubagentSessions`. Quick flow: 1. Spawn with `sessions_spawn` using `thread: true` (and optionally `mode: "session"`). -2. OpenClaw creates or binds a Discord thread to that session target. +2. OpenClaw creates or binds a thread to that session target in the active channel. 3. Replies and follow-up messages in that thread route to the bound session. 4. Use `/session ttl` to inspect/update auto-unfocus TTL. 5. Use `/unfocus` to detach manually. @@ -100,17 +106,16 @@ Quick flow: Manual controls: - `/focus ` binds the current thread (or creates one) to a sub-agent/session target. -- `/unfocus` removes the binding for the current Discord thread. +- `/unfocus` removes the binding for the current bound thread. - `/agents` lists active runs and binding state (`thread:` or `unbound`). -- `/session ttl` only works for focused Discord threads. +- `/session ttl` only works for focused bound threads. Config switches: - Global default: `session.threadBindings.enabled`, `session.threadBindings.ttlHours` -- Discord override: `channels.discord.threadBindings.enabled`, `channels.discord.threadBindings.ttlHours` -- Spawn auto-bind opt-in: `channels.discord.threadBindings.spawnSubagentSessions` +- Channel override and spawn auto-bind keys are adapter-specific. See **Thread supporting channels** above. -See [Discord](/channels/discord), [Configuration Reference](/gateway/configuration-reference), and [Slash commands](/tools/slash-commands). +See [Configuration Reference](/gateway/configuration-reference) and [Slash commands](/tools/slash-commands) for current adapter details. Allowlist: @@ -202,7 +207,7 @@ Sub-agents report back via an announce step: - The announce step runs inside the sub-agent session (not the requester session). - If the sub-agent replies exactly `ANNOUNCE_SKIP`, nothing is posted. - Otherwise the announce reply is posted to the requester chat channel via a follow-up `agent` call (`deliver=true`). -- Announce replies preserve thread/topic routing when available (Slack threads, Telegram topics, Matrix threads). +- Announce replies preserve thread/topic routing when available on channel adapters. - Announce messages are normalized to a stable template: - `Status:` derived from the run outcome (`success`, `error`, `timeout`, or `unknown`). - `Result:` the summary content from the announce step (or `(not available)` if missing). diff --git a/extensions/bluebubbles/src/actions.test.ts b/extensions/bluebubbles/src/actions.test.ts index efb4859fa..aabc5adf8 100644 --- a/extensions/bluebubbles/src/actions.test.ts +++ b/extensions/bluebubbles/src/actions.test.ts @@ -3,17 +3,10 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { bluebubblesMessageActions } from "./actions.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; -vi.mock("./accounts.js", () => ({ - resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => { - const config = cfg?.channels?.bluebubbles ?? {}; - return { - accountId: accountId ?? "default", - enabled: config.enabled !== false, - configured: Boolean(config.serverUrl && config.password), - config, - }; - }), -})); +vi.mock("./accounts.js", async () => { + const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js"); + return createBlueBubblesAccountsMockModule(); +}); vi.mock("./reactions.js", () => ({ sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined), diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts index 47f6e6d03..170602299 100644 --- a/extensions/bluebubbles/src/attachments.test.ts +++ b/extensions/bluebubbles/src/attachments.test.ts @@ -4,7 +4,12 @@ import "./test-mocks.js"; import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import { setBlueBubblesRuntime } from "./runtime.js"; -import { installBlueBubblesFetchTestHooks } from "./test-harness.js"; +import { + BLUE_BUBBLES_PRIVATE_API_STATUS, + installBlueBubblesFetchTestHooks, + mockBlueBubblesPrivateApiStatus, + mockBlueBubblesPrivateApiStatusOnce, +} from "./test-harness.js"; import type { BlueBubblesAttachment } from "./types.js"; const mockFetch = vi.fn(); @@ -278,7 +283,10 @@ describe("sendBlueBubblesAttachment", () => { fetchRemoteMediaMock.mockClear(); setBlueBubblesRuntime(runtimeStub); vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset(); - vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null); + mockBlueBubblesPrivateApiStatus( + vi.mocked(getCachedBlueBubblesPrivateApiStatus), + BLUE_BUBBLES_PRIVATE_API_STATUS.unknown, + ); }); afterEach(() => { @@ -381,7 +389,10 @@ describe("sendBlueBubblesAttachment", () => { }); it("downgrades attachment reply threading when private API is disabled", async () => { - vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false); + mockBlueBubblesPrivateApiStatusOnce( + vi.mocked(getCachedBlueBubblesPrivateApiStatus), + BLUE_BUBBLES_PRIVATE_API_STATUS.disabled, + ); mockFetch.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(JSON.stringify({ messageId: "msg-4" })), @@ -402,4 +413,32 @@ describe("sendBlueBubblesAttachment", () => { expect(bodyText).not.toContain('name="selectedMessageGuid"'); expect(bodyText).not.toContain('name="partIndex"'); }); + + it("warns and downgrades attachment reply threading when private API status is unknown", async () => { + const runtimeLog = vi.fn(); + setBlueBubblesRuntime({ + ...runtimeStub, + log: runtimeLog, + } as unknown as PluginRuntime); + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ messageId: "msg-5" })), + }); + + await sendBlueBubblesAttachment({ + to: "chat_guid:iMessage;-;+15551234567", + buffer: new Uint8Array([1, 2, 3]), + filename: "photo.jpg", + contentType: "image/jpeg", + replyToMessageGuid: "reply-guid-unknown", + opts: { serverUrl: "http://localhost:1234", password: "test" }, + }); + + expect(runtimeLog).toHaveBeenCalledTimes(1); + expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown"); + const body = mockFetch.mock.calls[0][1]?.body as Uint8Array; + const bodyText = decodeBody(body); + expect(bodyText).not.toContain('name="selectedMessageGuid"'); + expect(bodyText).not.toContain('name="partIndex"'); + }); }); diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts index 48331f215..3b8850f21 100644 --- a/extensions/bluebubbles/src/attachments.ts +++ b/extensions/bluebubbles/src/attachments.ts @@ -3,9 +3,12 @@ import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk"; import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; import { postMultipartFormData } from "./multipart.js"; -import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; +import { + getCachedBlueBubblesPrivateApiStatus, + isBlueBubblesPrivateApiStatusEnabled, +} from "./probe.js"; import { resolveRequestUrl } from "./request-url.js"; -import { getBlueBubblesRuntime } from "./runtime.js"; +import { getBlueBubblesRuntime, warnBlueBubbles } from "./runtime.js"; import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js"; import { resolveChatGuidForTarget } from "./send.js"; import { @@ -139,6 +142,7 @@ export async function sendBlueBubblesAttachment(params: { contentType = contentType?.trim() || undefined; const { baseUrl, password, accountId } = resolveAccount(opts); const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId); + const privateApiEnabled = isBlueBubblesPrivateApiStatusEnabled(privateApiStatus); // Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage). const isAudioMessage = wantsVoice; @@ -207,7 +211,7 @@ export async function sendBlueBubblesAttachment(params: { addField("chatGuid", chatGuid); addField("name", filename); addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`); - if (privateApiStatus !== false) { + if (privateApiEnabled) { addField("method", "private-api"); } @@ -217,9 +221,13 @@ export async function sendBlueBubblesAttachment(params: { } const trimmedReplyTo = replyToMessageGuid?.trim(); - if (trimmedReplyTo && privateApiStatus !== false) { + if (trimmedReplyTo && privateApiEnabled) { addField("selectedMessageGuid", trimmedReplyTo); addField("partIndex", typeof replyToPartIndex === "number" ? String(replyToPartIndex) : "0"); + } else if (trimmedReplyTo && privateApiStatus === null) { + warnBlueBubbles( + "Private API status unknown; sending attachment without reply threading metadata. Run a status probe to restore private-api reply features.", + ); } // Add optional caption diff --git a/extensions/bluebubbles/src/chat.test.ts b/extensions/bluebubbles/src/chat.test.ts index f372ca461..d22ded636 100644 --- a/extensions/bluebubbles/src/chat.test.ts +++ b/extensions/bluebubbles/src/chat.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it, vi } from "vitest"; import "./test-mocks.js"; -import { markBlueBubblesChatRead, sendBlueBubblesTyping, setGroupIconBlueBubbles } from "./chat.js"; +import { + addBlueBubblesParticipant, + editBlueBubblesMessage, + leaveBlueBubblesChat, + markBlueBubblesChatRead, + removeBlueBubblesParticipant, + renameBlueBubblesChat, + sendBlueBubblesTyping, + setGroupIconBlueBubbles, + unsendBlueBubblesMessage, +} from "./chat.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import { installBlueBubblesFetchTestHooks } from "./test-harness.js"; @@ -278,6 +288,188 @@ describe("chat", () => { }); }); + describe("editBlueBubblesMessage", () => { + it("throws when required args are missing", async () => { + await expect(editBlueBubblesMessage("", "updated", {})).rejects.toThrow("messageGuid"); + await expect(editBlueBubblesMessage("message-guid", " ", {})).rejects.toThrow("newText"); + }); + + it("sends edit request with default payload values", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await editBlueBubblesMessage(" message-guid ", " updated text ", { + serverUrl: "http://localhost:1234", + password: "test-password", + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/message/message-guid/edit"), + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + }), + ); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body).toEqual({ + editedMessage: "updated text", + backwardsCompatibilityMessage: "Edited to: updated text", + partIndex: 0, + }); + }); + + it("supports custom part index and backwards compatibility message", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await editBlueBubblesMessage("message-guid", "new text", { + serverUrl: "http://localhost:1234", + password: "test-password", + partIndex: 3, + backwardsCompatMessage: "custom-backwards-message", + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.partIndex).toBe(3); + expect(body.backwardsCompatibilityMessage).toBe("custom-backwards-message"); + }); + + it("throws on non-ok response", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 422, + text: () => Promise.resolve("Unprocessable"), + }); + + await expect( + editBlueBubblesMessage("message-guid", "new text", { + serverUrl: "http://localhost:1234", + password: "test-password", + }), + ).rejects.toThrow("edit failed (422): Unprocessable"); + }); + }); + + describe("unsendBlueBubblesMessage", () => { + it("throws when messageGuid is missing", async () => { + await expect(unsendBlueBubblesMessage("", {})).rejects.toThrow("messageGuid"); + }); + + it("sends unsend request with default part index", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await unsendBlueBubblesMessage(" msg-123 ", { + serverUrl: "http://localhost:1234", + password: "test-password", + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/message/msg-123/unsend"), + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + }), + ); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.partIndex).toBe(0); + }); + + it("uses custom part index", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await unsendBlueBubblesMessage("msg-123", { + serverUrl: "http://localhost:1234", + password: "test-password", + partIndex: 2, + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.partIndex).toBe(2); + }); + }); + + describe("group chat mutation actions", () => { + it("renames chat", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await renameBlueBubblesChat(" chat-guid ", "New Group Name", { + serverUrl: "http://localhost:1234", + password: "test-password", + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/chat/chat-guid"), + expect.objectContaining({ method: "PUT" }), + ); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.displayName).toBe("New Group Name"); + }); + + it("adds and removes participant using matching endpoint", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await addBlueBubblesParticipant("chat-guid", "+15551234567", { + serverUrl: "http://localhost:1234", + password: "test-password", + }); + await removeBlueBubblesParticipant("chat-guid", "+15551234567", { + serverUrl: "http://localhost:1234", + password: "test-password", + }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[0][0]).toContain("/api/v1/chat/chat-guid/participant"); + expect(mockFetch.mock.calls[0][1].method).toBe("POST"); + expect(mockFetch.mock.calls[1][0]).toContain("/api/v1/chat/chat-guid/participant"); + expect(mockFetch.mock.calls[1][1].method).toBe("DELETE"); + + const addBody = JSON.parse(mockFetch.mock.calls[0][1].body); + const removeBody = JSON.parse(mockFetch.mock.calls[1][1].body); + expect(addBody.address).toBe("+15551234567"); + expect(removeBody.address).toBe("+15551234567"); + }); + + it("leaves chat without JSON body", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await leaveBlueBubblesChat("chat-guid", { + serverUrl: "http://localhost:1234", + password: "test-password", + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/chat/chat-guid/leave"), + expect.objectContaining({ method: "POST" }), + ); + expect(mockFetch.mock.calls[0][1].body).toBeUndefined(); + expect(mockFetch.mock.calls[0][1].headers).toBeUndefined(); + }); + }); + describe("setGroupIconBlueBubbles", () => { it("throws when chatGuid is empty", async () => { await expect( diff --git a/extensions/bluebubbles/src/chat.ts b/extensions/bluebubbles/src/chat.ts index 354e70767..f5f83b1b6 100644 --- a/extensions/bluebubbles/src/chat.ts +++ b/extensions/bluebubbles/src/chat.ts @@ -26,6 +26,41 @@ function assertPrivateApiEnabled(accountId: string, feature: string): void { } } +function resolvePartIndex(partIndex: number | undefined): number { + return typeof partIndex === "number" ? partIndex : 0; +} + +async function sendPrivateApiJsonRequest(params: { + opts: BlueBubblesChatOpts; + feature: string; + action: string; + path: string; + method: "POST" | "PUT" | "DELETE"; + payload?: unknown; +}): Promise { + const { baseUrl, password, accountId } = resolveAccount(params.opts); + assertPrivateApiEnabled(accountId, params.feature); + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: params.path, + password, + }); + + const request: RequestInit = { method: params.method }; + if (params.payload !== undefined) { + request.headers = { "Content-Type": "application/json" }; + request.body = JSON.stringify(params.payload); + } + + const res = await blueBubblesFetchWithTimeout(url, request, params.opts.timeoutMs); + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error( + `BlueBubbles ${params.action} failed (${res.status}): ${errorText || "unknown"}`, + ); + } +} + export async function markBlueBubblesChatRead( chatGuid: string, opts: BlueBubblesChatOpts = {}, @@ -97,34 +132,18 @@ export async function editBlueBubblesMessage( throw new Error("BlueBubbles edit requires newText"); } - const { baseUrl, password, accountId } = resolveAccount(opts); - assertPrivateApiEnabled(accountId, "edit"); - const url = buildBlueBubblesApiUrl({ - baseUrl, + await sendPrivateApiJsonRequest({ + opts, + feature: "edit", + action: "edit", + method: "POST", path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/edit`, - password, - }); - - const payload = { - editedMessage: trimmedText, - backwardsCompatibilityMessage: opts.backwardsCompatMessage ?? `Edited to: ${trimmedText}`, - partIndex: typeof opts.partIndex === "number" ? opts.partIndex : 0, - }; - - const res = await blueBubblesFetchWithTimeout( - url, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), + payload: { + editedMessage: trimmedText, + backwardsCompatibilityMessage: opts.backwardsCompatMessage ?? `Edited to: ${trimmedText}`, + partIndex: resolvePartIndex(opts.partIndex), }, - opts.timeoutMs, - ); - - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error(`BlueBubbles edit failed (${res.status}): ${errorText || "unknown"}`); - } + }); } /** @@ -140,32 +159,14 @@ export async function unsendBlueBubblesMessage( throw new Error("BlueBubbles unsend requires messageGuid"); } - const { baseUrl, password, accountId } = resolveAccount(opts); - assertPrivateApiEnabled(accountId, "unsend"); - const url = buildBlueBubblesApiUrl({ - baseUrl, + await sendPrivateApiJsonRequest({ + opts, + feature: "unsend", + action: "unsend", + method: "POST", path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/unsend`, - password, + payload: { partIndex: resolvePartIndex(opts.partIndex) }, }); - - const payload = { - partIndex: typeof opts.partIndex === "number" ? opts.partIndex : 0, - }; - - const res = await blueBubblesFetchWithTimeout( - url, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }, - opts.timeoutMs, - ); - - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error(`BlueBubbles unsend failed (${res.status}): ${errorText || "unknown"}`); - } } /** @@ -181,28 +182,14 @@ export async function renameBlueBubblesChat( throw new Error("BlueBubbles rename requires chatGuid"); } - const { baseUrl, password, accountId } = resolveAccount(opts); - assertPrivateApiEnabled(accountId, "renameGroup"); - const url = buildBlueBubblesApiUrl({ - baseUrl, + await sendPrivateApiJsonRequest({ + opts, + feature: "renameGroup", + action: "rename", + method: "PUT", path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}`, - password, + payload: { displayName }, }); - - const res = await blueBubblesFetchWithTimeout( - url, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ displayName }), - }, - opts.timeoutMs, - ); - - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error(`BlueBubbles rename failed (${res.status}): ${errorText || "unknown"}`); - } } /** @@ -222,28 +209,14 @@ export async function addBlueBubblesParticipant( throw new Error("BlueBubbles addParticipant requires address"); } - const { baseUrl, password, accountId } = resolveAccount(opts); - assertPrivateApiEnabled(accountId, "addParticipant"); - const url = buildBlueBubblesApiUrl({ - baseUrl, + await sendPrivateApiJsonRequest({ + opts, + feature: "addParticipant", + action: "addParticipant", + method: "POST", path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`, - password, + payload: { address: trimmedAddress }, }); - - const res = await blueBubblesFetchWithTimeout( - url, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ address: trimmedAddress }), - }, - opts.timeoutMs, - ); - - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error(`BlueBubbles addParticipant failed (${res.status}): ${errorText || "unknown"}`); - } } /** @@ -263,30 +236,14 @@ export async function removeBlueBubblesParticipant( throw new Error("BlueBubbles removeParticipant requires address"); } - const { baseUrl, password, accountId } = resolveAccount(opts); - assertPrivateApiEnabled(accountId, "removeParticipant"); - const url = buildBlueBubblesApiUrl({ - baseUrl, + await sendPrivateApiJsonRequest({ + opts, + feature: "removeParticipant", + action: "removeParticipant", + method: "DELETE", path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`, - password, + payload: { address: trimmedAddress }, }); - - const res = await blueBubblesFetchWithTimeout( - url, - { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ address: trimmedAddress }), - }, - opts.timeoutMs, - ); - - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error( - `BlueBubbles removeParticipant failed (${res.status}): ${errorText || "unknown"}`, - ); - } } /** @@ -301,20 +258,13 @@ export async function leaveBlueBubblesChat( throw new Error("BlueBubbles leaveChat requires chatGuid"); } - const { baseUrl, password, accountId } = resolveAccount(opts); - assertPrivateApiEnabled(accountId, "leaveGroup"); - const url = buildBlueBubblesApiUrl({ - baseUrl, + await sendPrivateApiJsonRequest({ + opts, + feature: "leaveGroup", + action: "leaveChat", + method: "POST", path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/leave`, - password, }); - - const res = await blueBubblesFetchWithTimeout(url, { method: "POST" }, opts.timeoutMs); - - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error(`BlueBubbles leaveChat failed (${res.status}): ${errorText || "unknown"}`); - } } /** diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts index 4ae113d93..67fb50a78 100644 --- a/extensions/bluebubbles/src/monitor-processing.ts +++ b/extensions/bluebubbles/src/monitor-processing.ts @@ -39,7 +39,7 @@ import type { BlueBubblesRuntimeEnv, WebhookTarget, } from "./monitor-shared.js"; -import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; +import { isBlueBubblesPrivateApiEnabled } from "./probe.js"; import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js"; import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender } from "./targets.js"; @@ -420,7 +420,7 @@ export async function processMessage( target: WebhookTarget, ): Promise { const { account, config, runtime, core, statusSink } = target; - const privateApiEnabled = getCachedBlueBubblesPrivateApiStatus(account.accountId) !== false; + const privateApiEnabled = isBlueBubblesPrivateApiEnabled(account.accountId); const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid); const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup; diff --git a/extensions/bluebubbles/src/probe.ts b/extensions/bluebubbles/src/probe.ts index e60c47dc6..5ee95a268 100644 --- a/extensions/bluebubbles/src/probe.ts +++ b/extensions/bluebubbles/src/probe.ts @@ -96,6 +96,14 @@ export function getCachedBlueBubblesPrivateApiStatus(accountId?: string): boolea return info.private_api; } +export function isBlueBubblesPrivateApiStatusEnabled(status: boolean | null): boolean { + return status === true; +} + +export function isBlueBubblesPrivateApiEnabled(accountId?: string): boolean { + return isBlueBubblesPrivateApiStatusEnabled(getCachedBlueBubblesPrivateApiStatus(accountId)); +} + /** * Parse macOS version string (e.g., "15.0.1" or "26.0") into major version number. */ diff --git a/extensions/bluebubbles/src/reactions.test.ts b/extensions/bluebubbles/src/reactions.test.ts index 643a926b8..0ea99f911 100644 --- a/extensions/bluebubbles/src/reactions.test.ts +++ b/extensions/bluebubbles/src/reactions.test.ts @@ -1,17 +1,10 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { sendBlueBubblesReaction } from "./reactions.js"; -vi.mock("./accounts.js", () => ({ - resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => { - const config = cfg?.channels?.bluebubbles ?? {}; - return { - accountId: accountId ?? "default", - enabled: config.enabled !== false, - configured: Boolean(config.serverUrl && config.password), - config, - }; - }), -})); +vi.mock("./accounts.js", async () => { + const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js"); + return createBlueBubblesAccountsMockModule(); +}); const mockFetch = vi.fn(); diff --git a/extensions/bluebubbles/src/runtime.ts b/extensions/bluebubbles/src/runtime.ts index 2f183c74e..c9468234d 100644 --- a/extensions/bluebubbles/src/runtime.ts +++ b/extensions/bluebubbles/src/runtime.ts @@ -1,14 +1,34 @@ import type { PluginRuntime } from "openclaw/plugin-sdk"; let runtime: PluginRuntime | null = null; +type LegacyRuntimeLogShape = { log?: (message: string) => void }; export function setBlueBubblesRuntime(next: PluginRuntime): void { runtime = next; } +export function clearBlueBubblesRuntime(): void { + runtime = null; +} + +export function tryGetBlueBubblesRuntime(): PluginRuntime | null { + return runtime; +} + export function getBlueBubblesRuntime(): PluginRuntime { if (!runtime) { throw new Error("BlueBubbles runtime not initialized"); } return runtime; } + +export function warnBlueBubbles(message: string): void { + const formatted = `[bluebubbles] ${message}`; + // Backward-compatible with tests/legacy injections that pass { log }. + const log = (runtime as unknown as LegacyRuntimeLogShape | null)?.log; + if (typeof log === "function") { + log(formatted); + return; + } + console.warn(formatted); +} diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts index c1bcafe29..987237264 100644 --- a/extensions/bluebubbles/src/send.test.ts +++ b/extensions/bluebubbles/src/send.test.ts @@ -1,15 +1,22 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; import "./test-mocks.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; +import { clearBlueBubblesRuntime, setBlueBubblesRuntime } from "./runtime.js"; import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js"; -import { installBlueBubblesFetchTestHooks } from "./test-harness.js"; +import { + BLUE_BUBBLES_PRIVATE_API_STATUS, + installBlueBubblesFetchTestHooks, + mockBlueBubblesPrivateApiStatusOnce, +} from "./test-harness.js"; import type { BlueBubblesSendTarget } from "./types.js"; const mockFetch = vi.fn(); +const privateApiStatusMock = vi.mocked(getCachedBlueBubblesPrivateApiStatus); installBlueBubblesFetchTestHooks({ mockFetch, - privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus), + privateApiStatusMock, }); function mockResolvedHandleTarget( @@ -527,6 +534,10 @@ describe("send", () => { }); it("uses private-api when reply metadata is present", async () => { + mockBlueBubblesPrivateApiStatusOnce( + privateApiStatusMock, + BLUE_BUBBLES_PRIVATE_API_STATUS.enabled, + ); mockResolvedHandleTarget(); mockSendResponse({ data: { guid: "msg-uuid-124" } }); @@ -548,7 +559,10 @@ describe("send", () => { }); it("downgrades threaded reply to plain send when private API is disabled", async () => { - vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false); + mockBlueBubblesPrivateApiStatusOnce( + privateApiStatusMock, + BLUE_BUBBLES_PRIVATE_API_STATUS.disabled, + ); mockResolvedHandleTarget(); mockSendResponse({ data: { guid: "msg-uuid-plain" } }); @@ -568,6 +582,10 @@ describe("send", () => { }); it("normalizes effect names and uses private-api for effects", async () => { + mockBlueBubblesPrivateApiStatusOnce( + privateApiStatusMock, + BLUE_BUBBLES_PRIVATE_API_STATUS.enabled, + ); mockResolvedHandleTarget(); mockSendResponse({ data: { guid: "msg-uuid-125" } }); @@ -586,6 +604,38 @@ describe("send", () => { expect(body.effectId).toBe("com.apple.MobileSMS.expressivesend.invisibleink"); }); + it("warns and downgrades private-api features when status is unknown", async () => { + const runtimeLog = vi.fn(); + setBlueBubblesRuntime({ log: runtimeLog } as unknown as PluginRuntime); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + mockResolvedHandleTarget(); + mockSendResponse({ data: { guid: "msg-uuid-unknown" } }); + + try { + const result = await sendMessageBlueBubbles("+15551234567", "Reply fallback", { + serverUrl: "http://localhost:1234", + password: "test", + replyToMessageGuid: "reply-guid-123", + effectId: "invisible ink", + }); + + expect(result.messageId).toBe("msg-uuid-unknown"); + expect(runtimeLog).toHaveBeenCalledTimes(1); + expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown"); + expect(warnSpy).not.toHaveBeenCalled(); + + const sendCall = mockFetch.mock.calls[1]; + const body = JSON.parse(sendCall[1].body); + expect(body.method).toBeUndefined(); + expect(body.selectedMessageGuid).toBeUndefined(); + expect(body.partIndex).toBeUndefined(); + expect(body.effectId).toBeUndefined(); + } finally { + clearBlueBubblesRuntime(); + warnSpy.mockRestore(); + } + }); + it("sends message with chat_guid target directly", async () => { mockFetch.mockResolvedValueOnce({ ok: true, diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts index c5614062f..4719fb416 100644 --- a/extensions/bluebubbles/src/send.ts +++ b/extensions/bluebubbles/src/send.ts @@ -2,7 +2,11 @@ import crypto from "node:crypto"; import type { OpenClawConfig } from "openclaw/plugin-sdk"; import { stripMarkdown } from "openclaw/plugin-sdk"; import { resolveBlueBubblesAccount } from "./accounts.js"; -import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; +import { + getCachedBlueBubblesPrivateApiStatus, + isBlueBubblesPrivateApiStatusEnabled, +} from "./probe.js"; +import { warnBlueBubbles } from "./runtime.js"; import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js"; import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js"; import { @@ -71,6 +75,38 @@ function resolveEffectId(raw?: string): string | undefined { return raw; } +type PrivateApiDecision = { + canUsePrivateApi: boolean; + throwEffectDisabledError: boolean; + warningMessage?: string; +}; + +function resolvePrivateApiDecision(params: { + privateApiStatus: boolean | null; + wantsReplyThread: boolean; + wantsEffect: boolean; +}): PrivateApiDecision { + const { privateApiStatus, wantsReplyThread, wantsEffect } = params; + const needsPrivateApi = wantsReplyThread || wantsEffect; + const canUsePrivateApi = + needsPrivateApi && isBlueBubblesPrivateApiStatusEnabled(privateApiStatus); + const throwEffectDisabledError = wantsEffect && privateApiStatus === false; + if (!needsPrivateApi || privateApiStatus !== null) { + return { canUsePrivateApi, throwEffectDisabledError }; + } + const requested = [ + wantsReplyThread ? "reply threading" : null, + wantsEffect ? "message effects" : null, + ] + .filter(Boolean) + .join(" + "); + return { + canUsePrivateApi, + throwEffectDisabledError, + warningMessage: `Private API status unknown; sending without ${requested}. Run a status probe to restore private-api features.`, + }; +} + type BlueBubblesChatRecord = Record; function extractChatGuid(chat: BlueBubblesChatRecord): string | null { @@ -372,30 +408,36 @@ export async function sendMessageBlueBubbles( const effectId = resolveEffectId(opts.effectId); const wantsReplyThread = Boolean(opts.replyToMessageGuid?.trim()); const wantsEffect = Boolean(effectId); - const needsPrivateApi = wantsReplyThread || wantsEffect; - const canUsePrivateApi = needsPrivateApi && privateApiStatus !== false; - if (wantsEffect && privateApiStatus === false) { + const privateApiDecision = resolvePrivateApiDecision({ + privateApiStatus, + wantsReplyThread, + wantsEffect, + }); + if (privateApiDecision.throwEffectDisabledError) { throw new Error( "BlueBubbles send failed: reply/effect requires Private API, but it is disabled on the BlueBubbles server.", ); } + if (privateApiDecision.warningMessage) { + warnBlueBubbles(privateApiDecision.warningMessage); + } const payload: Record = { chatGuid, tempGuid: crypto.randomUUID(), message: strippedText, }; - if (canUsePrivateApi) { + if (privateApiDecision.canUsePrivateApi) { payload.method = "private-api"; } // Add reply threading support - if (wantsReplyThread && canUsePrivateApi) { + if (wantsReplyThread && privateApiDecision.canUsePrivateApi) { payload.selectedMessageGuid = opts.replyToMessageGuid; payload.partIndex = typeof opts.replyToPartIndex === "number" ? opts.replyToPartIndex : 0; } // Add message effects support - if (effectId) { + if (effectId && privateApiDecision.canUsePrivateApi) { payload.effectId = effectId; } diff --git a/extensions/bluebubbles/src/targets.ts b/extensions/bluebubbles/src/targets.ts index be9d0fa67..b136de309 100644 --- a/extensions/bluebubbles/src/targets.ts +++ b/extensions/bluebubbles/src/targets.ts @@ -78,6 +78,40 @@ function looksLikeRawChatIdentifier(value: string): boolean { return CHAT_IDENTIFIER_UUID_RE.test(trimmed) || CHAT_IDENTIFIER_HEX_RE.test(trimmed); } +function parseGroupTarget(params: { + trimmed: string; + lower: string; + requireValue: boolean; +}): { kind: "chat_id"; chatId: number } | { kind: "chat_guid"; chatGuid: string } | null { + if (!params.lower.startsWith("group:")) { + return null; + } + const value = stripPrefix(params.trimmed, "group:"); + const chatId = Number.parseInt(value, 10); + if (Number.isFinite(chatId)) { + return { kind: "chat_id", chatId }; + } + if (value) { + return { kind: "chat_guid", chatGuid: value }; + } + if (params.requireValue) { + throw new Error("group target is required"); + } + return null; +} + +function parseRawChatIdentifierTarget( + trimmed: string, +): { kind: "chat_identifier"; chatIdentifier: string } | null { + if (/^chat\d+$/i.test(trimmed)) { + return { kind: "chat_identifier", chatIdentifier: trimmed }; + } + if (looksLikeRawChatIdentifier(trimmed)) { + return { kind: "chat_identifier", chatIdentifier: trimmed }; + } + return null; +} + export function normalizeBlueBubblesHandle(raw: string): string { const trimmed = raw.trim(); if (!trimmed) { @@ -239,16 +273,9 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget { return chatTarget; } - if (lower.startsWith("group:")) { - const value = stripPrefix(trimmed, "group:"); - const chatId = Number.parseInt(value, 10); - if (Number.isFinite(chatId)) { - return { kind: "chat_id", chatId }; - } - if (!value) { - throw new Error("group target is required"); - } - return { kind: "chat_guid", chatGuid: value }; + const groupTarget = parseGroupTarget({ trimmed, lower, requireValue: true }); + if (groupTarget) { + return groupTarget; } const rawChatGuid = parseRawChatGuid(trimmed); @@ -256,15 +283,9 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget { return { kind: "chat_guid", chatGuid: rawChatGuid }; } - // Handle chat pattern (e.g., "chat660250192681427962") as chat_identifier - // These are BlueBubbles chat identifiers (the third part of a chat GUID), not numeric IDs - if (/^chat\d+$/i.test(trimmed)) { - return { kind: "chat_identifier", chatIdentifier: trimmed }; - } - - // Handle UUID/hex chat identifiers (e.g., "8b9c1a10536d4d86a336ea03ab7151cc") - if (looksLikeRawChatIdentifier(trimmed)) { - return { kind: "chat_identifier", chatIdentifier: trimmed }; + const rawChatIdentifierTarget = parseRawChatIdentifierTarget(trimmed); + if (rawChatIdentifierTarget) { + return rawChatIdentifierTarget; } return { kind: "handle", to: trimmed, service: "auto" }; @@ -298,26 +319,14 @@ export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget return chatTarget; } - if (lower.startsWith("group:")) { - const value = stripPrefix(trimmed, "group:"); - const chatId = Number.parseInt(value, 10); - if (Number.isFinite(chatId)) { - return { kind: "chat_id", chatId }; - } - if (value) { - return { kind: "chat_guid", chatGuid: value }; - } + const groupTarget = parseGroupTarget({ trimmed, lower, requireValue: false }); + if (groupTarget) { + return groupTarget; } - // Handle chat pattern (e.g., "chat660250192681427962") as chat_identifier - // These are BlueBubbles chat identifiers (the third part of a chat GUID), not numeric IDs - if (/^chat\d+$/i.test(trimmed)) { - return { kind: "chat_identifier", chatIdentifier: trimmed }; - } - - // Handle UUID/hex chat identifiers (e.g., "8b9c1a10536d4d86a336ea03ab7151cc") - if (looksLikeRawChatIdentifier(trimmed)) { - return { kind: "chat_identifier", chatIdentifier: trimmed }; + const rawChatIdentifierTarget = parseRawChatIdentifierTarget(trimmed); + if (rawChatIdentifierTarget) { + return rawChatIdentifierTarget; } return { kind: "handle", handle: normalizeBlueBubblesHandle(trimmed) }; diff --git a/extensions/bluebubbles/src/test-harness.ts b/extensions/bluebubbles/src/test-harness.ts index 627b04197..5f7351b2e 100644 --- a/extensions/bluebubbles/src/test-harness.ts +++ b/extensions/bluebubbles/src/test-harness.ts @@ -1,6 +1,31 @@ import type { Mock } from "vitest"; import { afterEach, beforeEach, vi } from "vitest"; +export const BLUE_BUBBLES_PRIVATE_API_STATUS = { + enabled: true, + disabled: false, + unknown: null, +} as const; + +type BlueBubblesPrivateApiStatusMock = { + mockReturnValue: (value: boolean | null) => unknown; + mockReturnValueOnce: (value: boolean | null) => unknown; +}; + +export function mockBlueBubblesPrivateApiStatus( + mock: Pick, + value: boolean | null, +) { + mock.mockReturnValue(value); +} + +export function mockBlueBubblesPrivateApiStatusOnce( + mock: Pick, + value: boolean | null, +) { + mock.mockReturnValueOnce(value); +} + export function resolveBlueBubblesAccountFromConfig(params: { cfg?: { channels?: { bluebubbles?: Record } }; accountId?: string; @@ -22,11 +47,15 @@ export function createBlueBubblesAccountsMockModule() { type BlueBubblesProbeMockModule = { getCachedBlueBubblesPrivateApiStatus: Mock<() => boolean | null>; + isBlueBubblesPrivateApiStatusEnabled: Mock<(status: boolean | null) => boolean>; }; export function createBlueBubblesProbeMockModule(): BlueBubblesProbeMockModule { return { - getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null), + getCachedBlueBubblesPrivateApiStatus: vi + .fn() + .mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown), + isBlueBubblesPrivateApiStatusEnabled: vi.fn((status: boolean | null) => status === true), }; } @@ -41,7 +70,7 @@ export function installBlueBubblesFetchTestHooks(params: { vi.stubGlobal("fetch", params.mockFetch); params.mockFetch.mockReset(); params.privateApiStatusMock.mockReset(); - params.privateApiStatusMock.mockReturnValue(null); + params.privateApiStatusMock.mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown); }); afterEach(() => { diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 7556f14e1..815dafbf6 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -22,6 +22,8 @@ import { resolveDefaultDiscordAccountId, resolveDiscordGroupRequireMention, resolveDiscordGroupToolPolicy, + resolveOpenProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, setAccountEnabledInConfigSection, type ChannelMessageActionAdapter, type ChannelPlugin, @@ -130,8 +132,12 @@ export const discordPlugin: ChannelPlugin = { }, collectWarnings: ({ account, cfg }) => { const warnings: string[] = []; - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.discord !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); const guildEntries = account.config.guilds ?? {}; const guildsConfigured = Object.keys(guildEntries).length > 0; const channelAllowlistConfigured = guildsConfigured; diff --git a/extensions/discord/src/subagent-hooks.test.ts b/extensions/discord/src/subagent-hooks.test.ts index 8e2514b3b..f8a139cd5 100644 --- a/extensions/discord/src/subagent-hooks.test.ts +++ b/extensions/discord/src/subagent-hooks.test.ts @@ -64,6 +64,95 @@ function registerHandlersForTest( return handlers; } +function getRequiredHandler( + handlers: Map unknown>, + hookName: string, +): (event: unknown, ctx: unknown) => unknown { + const handler = handlers.get(hookName); + if (!handler) { + throw new Error(`expected ${hookName} hook handler`); + } + return handler; +} + +function createSpawnEvent(overrides?: { + childSessionKey?: string; + agentId?: string; + label?: string; + mode?: string; + requester?: { + channel?: string; + accountId?: string; + to?: string; + threadId?: string; + }; + threadRequested?: boolean; +}): { + childSessionKey: string; + agentId: string; + label: string; + mode: string; + requester: { + channel: string; + accountId: string; + to: string; + threadId?: string; + }; + threadRequested: boolean; +} { + const base = { + childSessionKey: "agent:main:subagent:child", + agentId: "main", + label: "banana", + mode: "session", + requester: { + channel: "discord", + accountId: "work", + to: "channel:123", + threadId: "456", + }, + threadRequested: true, + }; + return { + ...base, + ...overrides, + requester: { + ...base.requester, + ...(overrides?.requester ?? {}), + }, + }; +} + +function createSpawnEventWithoutThread() { + return createSpawnEvent({ + label: "", + requester: { threadId: undefined }, + }); +} + +async function runSubagentSpawning( + config?: Record, + event = createSpawnEventWithoutThread(), +) { + const handlers = registerHandlersForTest(config); + const handler = getRequiredHandler(handlers, "subagent_spawning"); + return await handler(event, {}); +} + +async function expectSubagentSpawningError(params?: { + config?: Record; + errorContains?: string; + event?: ReturnType; +}) { + const result = await runSubagentSpawning(params?.config, params?.event); + expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled(); + expect(result).toMatchObject({ status: "error" }); + if (params?.errorContains) { + const errorText = (result as { error?: string }).error ?? ""; + expect(errorText).toContain(params.errorContains); + } +} + describe("discord subagent hook handlers", () => { beforeEach(() => { hookMocks.resolveDiscordAccount.mockClear(); @@ -90,27 +179,9 @@ describe("discord subagent hook handlers", () => { it("binds thread routing on subagent_spawning", async () => { const handlers = registerHandlersForTest(); - const handler = handlers.get("subagent_spawning"); - if (!handler) { - throw new Error("expected subagent_spawning hook handler"); - } + const handler = getRequiredHandler(handlers, "subagent_spawning"); - const result = await handler( - { - childSessionKey: "agent:main:subagent:child", - agentId: "main", - label: "banana", - mode: "session", - requester: { - channel: "discord", - accountId: "work", - to: "channel:123", - threadId: "456", - }, - threadRequested: true, - }, - {}, - ); + const result = await handler(createSpawnEvent(), {}); expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledTimes(1); expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledWith({ @@ -127,82 +198,42 @@ describe("discord subagent hook handlers", () => { }); it("returns error when thread-bound subagent spawn is disabled", async () => { - const handlers = registerHandlersForTest({ - channels: { - discord: { - threadBindings: { - spawnSubagentSessions: false, + await expectSubagentSpawningError({ + config: { + channels: { + discord: { + threadBindings: { + spawnSubagentSessions: false, + }, }, }, }, + errorContains: "spawnSubagentSessions=true", }); - const handler = handlers.get("subagent_spawning"); - if (!handler) { - throw new Error("expected subagent_spawning hook handler"); - } - - const result = await handler( - { - childSessionKey: "agent:main:subagent:child", - agentId: "main", - requester: { - channel: "discord", - accountId: "work", - to: "channel:123", - }, - threadRequested: true, - }, - {}, - ); - - expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled(); - expect(result).toMatchObject({ status: "error" }); - const errorText = (result as { error?: string }).error ?? ""; - expect(errorText).toContain("spawnSubagentSessions=true"); }); it("returns error when global thread bindings are disabled", async () => { - const handlers = registerHandlersForTest({ - session: { - threadBindings: { - enabled: false, - }, - }, - channels: { - discord: { + await expectSubagentSpawningError({ + config: { + session: { threadBindings: { - spawnSubagentSessions: true, + enabled: false, + }, + }, + channels: { + discord: { + threadBindings: { + spawnSubagentSessions: true, + }, }, }, }, + errorContains: "threadBindings.enabled=true", }); - const handler = handlers.get("subagent_spawning"); - if (!handler) { - throw new Error("expected subagent_spawning hook handler"); - } - - const result = await handler( - { - childSessionKey: "agent:main:subagent:child", - agentId: "main", - requester: { - channel: "discord", - accountId: "work", - to: "channel:123", - }, - threadRequested: true, - }, - {}, - ); - - expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled(); - expect(result).toMatchObject({ status: "error" }); - const errorText = (result as { error?: string }).error ?? ""; - expect(errorText).toContain("threadBindings.enabled=true"); }); it("allows account-level threadBindings.enabled to override global disable", async () => { - const handlers = registerHandlersForTest({ + const result = await runSubagentSpawning({ session: { threadBindings: { enabled: false, @@ -221,79 +252,34 @@ describe("discord subagent hook handlers", () => { }, }, }); - const handler = handlers.get("subagent_spawning"); - if (!handler) { - throw new Error("expected subagent_spawning hook handler"); - } - - const result = await handler( - { - childSessionKey: "agent:main:subagent:child", - agentId: "main", - requester: { - channel: "discord", - accountId: "work", - to: "channel:123", - }, - threadRequested: true, - }, - {}, - ); expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledTimes(1); expect(result).toMatchObject({ status: "ok", threadBindingReady: true }); }); it("defaults thread-bound subagent spawn to disabled when unset", async () => { - const handlers = registerHandlersForTest({ - channels: { - discord: { - threadBindings: {}, + await expectSubagentSpawningError({ + config: { + channels: { + discord: { + threadBindings: {}, + }, }, }, }); - const handler = handlers.get("subagent_spawning"); - if (!handler) { - throw new Error("expected subagent_spawning hook handler"); - } - - const result = await handler( - { - childSessionKey: "agent:main:subagent:child", - agentId: "main", - requester: { - channel: "discord", - accountId: "work", - to: "channel:123", - }, - threadRequested: true, - }, - {}, - ); - - expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled(); - expect(result).toMatchObject({ status: "error" }); }); it("no-ops when thread binding is requested on non-discord channel", async () => { - const handlers = registerHandlersForTest(); - const handler = handlers.get("subagent_spawning"); - if (!handler) { - throw new Error("expected subagent_spawning hook handler"); - } - - const result = await handler( - { - childSessionKey: "agent:main:subagent:child", - agentId: "main", - mode: "session", + const result = await runSubagentSpawning( + undefined, + createSpawnEvent({ requester: { channel: "signal", + accountId: "", to: "+123", + threadId: undefined, }, - threadRequested: true, - }, - {}, + }), ); expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled(); @@ -302,26 +288,7 @@ describe("discord subagent hook handlers", () => { it("returns error when thread bind fails", async () => { hookMocks.autoBindSpawnedDiscordSubagent.mockResolvedValueOnce(null); - const handlers = registerHandlersForTest(); - const handler = handlers.get("subagent_spawning"); - if (!handler) { - throw new Error("expected subagent_spawning hook handler"); - } - - const result = await handler( - { - childSessionKey: "agent:main:subagent:child", - agentId: "main", - mode: "session", - requester: { - channel: "discord", - accountId: "work", - to: "channel:123", - }, - threadRequested: true, - }, - {}, - ); + const result = await runSubagentSpawning(); expect(result).toMatchObject({ status: "error" }); const errorText = (result as { error?: string }).error ?? ""; @@ -330,10 +297,7 @@ describe("discord subagent hook handlers", () => { it("unbinds thread routing on subagent_ended", () => { const handlers = registerHandlersForTest(); - const handler = handlers.get("subagent_ended"); - if (!handler) { - throw new Error("expected subagent_ended hook handler"); - } + const handler = getRequiredHandler(handlers, "subagent_ended"); handler( { @@ -361,10 +325,7 @@ describe("discord subagent hook handlers", () => { { accountId: "work", threadId: "777" }, ]); const handlers = registerHandlersForTest(); - const handler = handlers.get("subagent_delivery_target"); - if (!handler) { - throw new Error("expected subagent_delivery_target hook handler"); - } + const handler = getRequiredHandler(handlers, "subagent_delivery_target"); const result = handler( { @@ -404,10 +365,7 @@ describe("discord subagent hook handlers", () => { { accountId: "work", threadId: "888" }, ]); const handlers = registerHandlersForTest(); - const handler = handlers.get("subagent_delivery_target"); - if (!handler) { - throw new Error("expected subagent_delivery_target hook handler"); - } + const handler = getRequiredHandler(handlers, "subagent_delivery_target"); const result = handler( { diff --git a/extensions/feishu/src/bot.checkBotMentioned.test.ts b/extensions/feishu/src/bot.checkBotMentioned.test.ts index a6233e053..c88b32925 100644 --- a/extensions/feishu/src/bot.checkBotMentioned.test.ts +++ b/extensions/feishu/src/bot.checkBotMentioned.test.ts @@ -22,6 +22,20 @@ function makeEvent( }; } +function makePostEvent(content: unknown) { + return { + sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } }, + message: { + message_id: "msg_1", + chat_id: "oc_chat1", + chat_type: "group", + message_type: "post", + content: JSON.stringify(content), + mentions: [], + }, + }; +} + describe("parseFeishuMessageEvent – mentionedBot", () => { const BOT_OPEN_ID = "ou_bot_123"; @@ -85,64 +99,31 @@ describe("parseFeishuMessageEvent – mentionedBot", () => { it("returns mentionedBot=true for post message with at (no top-level mentions)", () => { const BOT_OPEN_ID = "ou_bot_123"; - const postContent = JSON.stringify({ + const event = makePostEvent({ content: [ [{ tag: "at", user_id: BOT_OPEN_ID, user_name: "claw" }], [{ tag: "text", text: "What does this document say" }], ], }); - const event = { - sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } }, - message: { - message_id: "msg_1", - chat_id: "oc_chat1", - chat_type: "group", - message_type: "post", - content: postContent, - mentions: [], - }, - }; const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID); expect(ctx.mentionedBot).toBe(true); }); it("returns mentionedBot=false for post message with no at", () => { - const postContent = JSON.stringify({ + const event = makePostEvent({ content: [[{ tag: "text", text: "hello" }]], }); - const event = { - sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } }, - message: { - message_id: "msg_1", - chat_id: "oc_chat1", - chat_type: "group", - message_type: "post", - content: postContent, - mentions: [], - }, - }; const ctx = parseFeishuMessageEvent(event as any, "ou_bot_123"); expect(ctx.mentionedBot).toBe(false); }); it("returns mentionedBot=false for post message with at for another user", () => { - const postContent = JSON.stringify({ + const event = makePostEvent({ content: [ [{ tag: "at", user_id: "ou_other", user_name: "other" }], [{ tag: "text", text: "hello" }], ], }); - const event = { - sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } }, - message: { - message_id: "msg_1", - chat_id: "oc_chat1", - chat_type: "group", - message_type: "post", - content: postContent, - mentions: [], - }, - }; const ctx = parseFeishuMessageEvent(event as any, "ou_bot_123"); expect(ctx.mentionedBot).toBe(false); }); diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index b9cd691cb..0daebe19d 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -25,6 +25,24 @@ vi.mock("./send.js", () => ({ getMessageFeishu: mockGetMessageFeishu, })); +function createRuntimeEnv(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number): never => { + throw new Error(`exit ${code}`); + }), + } as RuntimeEnv; +} + +async function dispatchMessage(params: { cfg: ClawdbotConfig; event: FeishuMessageEvent }) { + await handleFeishuMessage({ + cfg: params.cfg, + event: params.event, + runtime: createRuntimeEnv(), + }); +} + describe("handleFeishuMessage command authorization", () => { const mockFinalizeInboundContext = vi.fn((ctx: unknown) => ctx); const mockDispatchReplyFromConfig = vi @@ -96,17 +114,7 @@ describe("handleFeishuMessage command authorization", () => { }, }; - await handleFeishuMessage({ - cfg, - event, - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - } as RuntimeEnv, - }); + await dispatchMessage({ cfg, event }); expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({ useAccessGroups: true, @@ -151,17 +159,7 @@ describe("handleFeishuMessage command authorization", () => { }, }; - await handleFeishuMessage({ - cfg, - event, - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - } as RuntimeEnv, - }); + await dispatchMessage({ cfg, event }); expect(mockReadAllowFromStore).toHaveBeenCalledWith("feishu"); expect(mockResolveCommandAuthorizedFromAuthorizers).not.toHaveBeenCalled(); @@ -198,17 +196,7 @@ describe("handleFeishuMessage command authorization", () => { }, }; - await handleFeishuMessage({ - cfg, - event, - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - } as RuntimeEnv, - }); + await dispatchMessage({ cfg, event }); expect(mockUpsertPairingRequest).toHaveBeenCalledWith({ channel: "feishu", @@ -262,17 +250,7 @@ describe("handleFeishuMessage command authorization", () => { }, }; - await handleFeishuMessage({ - cfg, - event, - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - } as RuntimeEnv, - }); + await dispatchMessage({ cfg, event }); expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({ useAccessGroups: true, diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 14d921919..2cf30c440 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -2,10 +2,13 @@ import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk"; import { buildAgentMediaPayload, buildPendingHistoryContextFromMap, - recordPendingHistoryEntryIfEnabled, clearHistoryEntriesIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry, + recordPendingHistoryEntryIfEnabled, + resolveOpenProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; @@ -563,7 +566,18 @@ export async function handleFeishuMessage(params: { const useAccessGroups = cfg.commands?.useAccessGroups !== false; if (isGroup) { - const groupPolicy = feishuCfg?.groupPolicy ?? "open"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.feishu !== undefined, + groupPolicy: feishuCfg?.groupPolicy, + defaultGroupPolicy, + }); + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "feishu", + accountId: account.accountId, + log, + }); const groupAllowFrom = feishuCfg?.groupAllowFrom ?? []; // DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`); diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 98a622cdf..f22292417 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -4,6 +4,8 @@ import { createDefaultChannelRuntimeState, DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, } from "openclaw/plugin-sdk"; import { resolveFeishuAccount, @@ -224,10 +226,12 @@ export const feishuPlugin: ChannelPlugin = { collectWarnings: ({ cfg, accountId }) => { const account = resolveFeishuAccount({ cfg, accountId }); const feishuCfg = account.config; - const defaultGroupPolicy = ( - cfg.channels as Record | undefined - )?.defaults?.groupPolicy; - const groupPolicy = feishuCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.feishu !== undefined, + groupPolicy: feishuCfg?.groupPolicy, + defaultGroupPolicy, + }); if (groupPolicy !== "open") return []; return [ `- Feishu[${account.accountId}] groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`, diff --git a/extensions/feishu/src/config-schema.test.ts b/extensions/feishu/src/config-schema.test.ts index 942d0c885..64a278c4a 100644 --- a/extensions/feishu/src/config-schema.test.ts +++ b/extensions/feishu/src/config-schema.test.ts @@ -2,6 +2,28 @@ import { describe, expect, it } from "vitest"; import { FeishuConfigSchema } from "./config-schema.js"; describe("FeishuConfigSchema webhook validation", () => { + it("applies top-level defaults", () => { + const result = FeishuConfigSchema.parse({}); + expect(result.domain).toBe("feishu"); + expect(result.connectionMode).toBe("websocket"); + expect(result.webhookPath).toBe("/feishu/events"); + expect(result.dmPolicy).toBe("pairing"); + expect(result.groupPolicy).toBe("allowlist"); + expect(result.requireMention).toBe(true); + }); + + it("does not force top-level policy defaults into account config", () => { + const result = FeishuConfigSchema.parse({ + accounts: { + main: {}, + }, + }); + + expect(result.accounts?.main?.dmPolicy).toBeUndefined(); + expect(result.accounts?.main?.groupPolicy).toBeUndefined(); + expect(result.accounts?.main?.requireMention).toBeUndefined(); + }); + it("rejects top-level webhook mode without verificationToken", () => { const result = FeishuConfigSchema.safeParse({ connectionMode: "webhook", diff --git a/extensions/feishu/src/config-schema.ts b/extensions/feishu/src/config-schema.ts index b1e9fa248..f5b08e13e 100644 --- a/extensions/feishu/src/config-schema.ts +++ b/extensions/feishu/src/config-schema.ts @@ -112,6 +112,31 @@ export const FeishuGroupSchema = z }) .strict(); +const FeishuSharedConfigShape = { + webhookHost: z.string().optional(), + webhookPort: z.number().int().positive().optional(), + capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, + configWrites: z.boolean().optional(), + dmPolicy: DmPolicySchema.optional(), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupPolicy: GroupPolicySchema.optional(), + groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), + requireMention: z.boolean().optional(), + groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(), + historyLimit: z.number().int().min(0).optional(), + dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema).optional(), + textChunkLimit: z.number().int().positive().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), + blockStreamingCoalesce: BlockStreamingCoalesceSchema, + mediaMaxMb: z.number().positive().optional(), + heartbeat: ChannelHeartbeatVisibilitySchema, + renderMode: RenderModeSchema, + streaming: StreamingModeSchema, + tools: FeishuToolsConfigSchema, +}; + /** * Per-account configuration. * All fields are optional - missing fields inherit from top-level config. @@ -127,28 +152,7 @@ export const FeishuAccountConfigSchema = z domain: FeishuDomainSchema.optional(), connectionMode: FeishuConnectionModeSchema.optional(), webhookPath: z.string().optional(), - webhookHost: z.string().optional(), - webhookPort: z.number().int().positive().optional(), - capabilities: z.array(z.string()).optional(), - markdown: MarkdownConfigSchema, - configWrites: z.boolean().optional(), - dmPolicy: DmPolicySchema.optional(), - allowFrom: z.array(z.union([z.string(), z.number()])).optional(), - groupPolicy: GroupPolicySchema.optional(), - groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), - requireMention: z.boolean().optional(), - groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(), - historyLimit: z.number().int().min(0).optional(), - dmHistoryLimit: z.number().int().min(0).optional(), - dms: z.record(z.string(), DmConfigSchema).optional(), - textChunkLimit: z.number().int().positive().optional(), - chunkMode: z.enum(["length", "newline"]).optional(), - blockStreamingCoalesce: BlockStreamingCoalesceSchema, - mediaMaxMb: z.number().positive().optional(), - heartbeat: ChannelHeartbeatVisibilitySchema, - renderMode: RenderModeSchema, - streaming: StreamingModeSchema, // Enable streaming card mode (default: true) - tools: FeishuToolsConfigSchema, + ...FeishuSharedConfigShape, }) .strict(); @@ -163,29 +167,11 @@ export const FeishuConfigSchema = z domain: FeishuDomainSchema.optional().default("feishu"), connectionMode: FeishuConnectionModeSchema.optional().default("websocket"), webhookPath: z.string().optional().default("/feishu/events"), - webhookHost: z.string().optional(), - webhookPort: z.number().int().positive().optional(), - capabilities: z.array(z.string()).optional(), - markdown: MarkdownConfigSchema, - configWrites: z.boolean().optional(), + ...FeishuSharedConfigShape, dmPolicy: DmPolicySchema.optional().default("pairing"), - allowFrom: z.array(z.union([z.string(), z.number()])).optional(), groupPolicy: GroupPolicySchema.optional().default("allowlist"), - groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), requireMention: z.boolean().optional().default(true), - groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(), topicSessionMode: TopicSessionModeSchema, - historyLimit: z.number().int().min(0).optional(), - dmHistoryLimit: z.number().int().min(0).optional(), - dms: z.record(z.string(), DmConfigSchema).optional(), - textChunkLimit: z.number().int().positive().optional(), - chunkMode: z.enum(["length", "newline"]).optional(), - blockStreamingCoalesce: BlockStreamingCoalesceSchema, - mediaMaxMb: z.number().positive().optional(), - heartbeat: ChannelHeartbeatVisibilitySchema, - renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown - streaming: StreamingModeSchema, // Enable streaming card mode (default: true) - tools: FeishuToolsConfigSchema, // Dynamic agent creation for DM users dynamicAgentCreation: DynamicAgentCreationSchema, // Multi-account configuration diff --git a/extensions/feishu/src/media.test.ts b/extensions/feishu/src/media.test.ts index b9e97703a..5851e8490 100644 --- a/extensions/feishu/src/media.test.ts +++ b/extensions/feishu/src/media.test.ts @@ -38,6 +38,16 @@ vi.mock("./runtime.js", () => ({ import { downloadImageFeishu, downloadMessageResourceFeishu, sendMediaFeishu } from "./media.js"; +function expectPathIsolatedToTmpRoot(pathValue: string, key: string): void { + expect(pathValue).not.toContain(key); + expect(pathValue).not.toContain(".."); + + const tmpRoot = path.resolve(os.tmpdir()); + const resolved = path.resolve(pathValue); + const rel = path.relative(tmpRoot, resolved); + expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false); +} + describe("sendMediaFeishu msg_type routing", () => { beforeEach(() => { vi.clearAllMocks(); @@ -217,13 +227,7 @@ describe("sendMediaFeishu msg_type routing", () => { expect(result.buffer).toEqual(Buffer.from("image-data")); expect(capturedPath).toBeDefined(); - expect(capturedPath).not.toContain(imageKey); - expect(capturedPath).not.toContain(".."); - - const tmpRoot = path.resolve(os.tmpdir()); - const resolved = path.resolve(capturedPath as string); - const rel = path.relative(tmpRoot, resolved); - expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false); + expectPathIsolatedToTmpRoot(capturedPath as string, imageKey); }); it("uses isolated temp paths for message resource downloads", async () => { @@ -246,13 +250,7 @@ describe("sendMediaFeishu msg_type routing", () => { expect(result.buffer).toEqual(Buffer.from("resource-data")); expect(capturedPath).toBeDefined(); - expect(capturedPath).not.toContain(fileKey); - expect(capturedPath).not.toContain(".."); - - const tmpRoot = path.resolve(os.tmpdir()); - const resolved = path.resolve(capturedPath as string); - const rel = path.relative(tmpRoot, resolved); - expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false); + expectPathIsolatedToTmpRoot(capturedPath as string, fileKey); }); it("rejects invalid image keys before calling feishu api", async () => { diff --git a/extensions/feishu/src/monitor.webhook-security.test.ts b/extensions/feishu/src/monitor.webhook-security.test.ts index b304ee6ed..97637e75e 100644 --- a/extensions/feishu/src/monitor.webhook-security.test.ts +++ b/extensions/feishu/src/monitor.webhook-security.test.ts @@ -78,6 +78,41 @@ function buildConfig(params: { } as ClawdbotConfig; } +async function withRunningWebhookMonitor( + params: { + accountId: string; + path: string; + verificationToken: string; + }, + run: (url: string) => Promise, +) { + const port = await getFreePort(); + const cfg = buildConfig({ + accountId: params.accountId, + path: params.path, + port, + verificationToken: params.verificationToken, + }); + + const abortController = new AbortController(); + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const monitorPromise = monitorFeishuProvider({ + config: cfg, + runtime, + abortSignal: abortController.signal, + }); + + const url = `http://127.0.0.1:${port}${params.path}`; + await waitUntilServerReady(url); + + try { + await run(url); + } finally { + abortController.abort(); + await monitorPromise; + } +} + afterEach(() => { stopFeishuMonitor(); }); @@ -99,76 +134,50 @@ describe("Feishu webhook security hardening", () => { it("returns 415 for POST requests without json content type", async () => { probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); - const port = await getFreePort(); - const path = "/hook-content-type"; - const cfg = buildConfig({ - accountId: "content-type", - path, - port, - verificationToken: "verify_token", - }); + await withRunningWebhookMonitor( + { + accountId: "content-type", + path: "/hook-content-type", + verificationToken: "verify_token", + }, + async (url) => { + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "text/plain" }, + body: "{}", + }); - const abortController = new AbortController(); - const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; - const monitorPromise = monitorFeishuProvider({ - config: cfg, - runtime, - abortSignal: abortController.signal, - }); - - await waitUntilServerReady(`http://127.0.0.1:${port}${path}`); - - const response = await fetch(`http://127.0.0.1:${port}${path}`, { - method: "POST", - headers: { "content-type": "text/plain" }, - body: "{}", - }); - - expect(response.status).toBe(415); - expect(await response.text()).toBe("Unsupported Media Type"); - - abortController.abort(); - await monitorPromise; + expect(response.status).toBe(415); + expect(await response.text()).toBe("Unsupported Media Type"); + }, + ); }); it("rate limits webhook burst traffic with 429", async () => { probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); - const port = await getFreePort(); - const path = "/hook-rate-limit"; - const cfg = buildConfig({ - accountId: "rate-limit", - path, - port, - verificationToken: "verify_token", - }); + await withRunningWebhookMonitor( + { + accountId: "rate-limit", + path: "/hook-rate-limit", + verificationToken: "verify_token", + }, + async (url) => { + let saw429 = false; + for (let i = 0; i < 130; i += 1) { + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "text/plain" }, + body: "{}", + }); + if (response.status === 429) { + saw429 = true; + expect(await response.text()).toBe("Too Many Requests"); + break; + } + } - const abortController = new AbortController(); - const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; - const monitorPromise = monitorFeishuProvider({ - config: cfg, - runtime, - abortSignal: abortController.signal, - }); - - await waitUntilServerReady(`http://127.0.0.1:${port}${path}`); - - let saw429 = false; - for (let i = 0; i < 130; i += 1) { - const response = await fetch(`http://127.0.0.1:${port}${path}`, { - method: "POST", - headers: { "content-type": "text/plain" }, - body: "{}", - }); - if (response.status === 429) { - saw429 = true; - expect(await response.text()).toBe("Too Many Requests"); - break; - } - } - - expect(saw429).toBe(true); - - abortController.abort(); - await monitorPromise; + expect(saw429).toBe(true); + }, + ); }); }); diff --git a/extensions/feishu/src/onboarding.ts b/extensions/feishu/src/onboarding.ts index a2cf02dd2..bb847ebab 100644 --- a/extensions/feishu/src/onboarding.ts +++ b/extensions/feishu/src/onboarding.ts @@ -104,6 +104,25 @@ async function noteFeishuCredentialHelp(prompter: WizardPrompter): Promise ); } +async function promptFeishuCredentials(prompter: WizardPrompter): Promise<{ + appId: string; + appSecret: string; +}> { + const appId = String( + await prompter.text({ + message: "Enter Feishu App ID", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + const appSecret = String( + await prompter.text({ + message: "Enter Feishu App Secret", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + return { appId, appSecret }; +} + function setFeishuGroupPolicy( cfg: ClawdbotConfig, groupPolicy: "open" | "allowlist" | "disabled", @@ -210,18 +229,9 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { }, }; } else { - appId = String( - await prompter.text({ - message: "Enter Feishu App ID", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - appSecret = String( - await prompter.text({ - message: "Enter Feishu App Secret", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); + const entered = await promptFeishuCredentials(prompter); + appId = entered.appId; + appSecret = entered.appSecret; } } else if (hasConfigCreds) { const keep = await prompter.confirm({ @@ -229,32 +239,14 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { initialValue: true, }); if (!keep) { - appId = String( - await prompter.text({ - message: "Enter Feishu App ID", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - appSecret = String( - await prompter.text({ - message: "Enter Feishu App Secret", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); + const entered = await promptFeishuCredentials(prompter); + appId = entered.appId; + appSecret = entered.appSecret; } } else { - appId = String( - await prompter.text({ - message: "Enter Feishu App ID", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - appSecret = String( - await prompter.text({ - message: "Enter Feishu App Secret", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); + const entered = await promptFeishuCredentials(prompter); + appId = entered.appId; + appSecret = entered.appSecret; } if (appId && appSecret) { diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 8022add55..52943f630 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -11,6 +11,8 @@ import { PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, resolveGoogleChatGroupRequireMention, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, setAccountEnabledInConfigSection, type ChannelDock, type ChannelMessageActionAdapter, @@ -198,8 +200,12 @@ export const googlechatPlugin: ChannelPlugin = { }, collectWarnings: ({ account, cfg }) => { const warnings: string[] = []; - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.googlechat !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); if (groupPolicy === "open") { warnings.push( `- Google Chat spaces: groupPolicy="open" allows any space to trigger (mention-gated). Set channels.googlechat.groupPolicy="allowlist" and configure channels.googlechat.groups.`, diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts index cee540058..689f10341 100644 --- a/extensions/googlechat/src/monitor.ts +++ b/extensions/googlechat/src/monitor.ts @@ -1,13 +1,17 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawConfig } from "openclaw/plugin-sdk"; import { + GROUP_POLICY_BLOCKED_LABEL, createReplyPrefixOptions, readJsonBodyWithLimit, registerWebhookTarget, rejectNonPostWebhookRequest, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, resolveSingleWebhookTargetAsync, resolveWebhookPath, resolveWebhookTargets, + warnMissingProviderGroupPolicyFallbackOnce, requestBodyErrorToText, resolveMentionGatingWithBypass, } from "openclaw/plugin-sdk"; @@ -426,8 +430,20 @@ async function processMessageWithPipeline(params: { return; } - const defaultGroupPolicy = config.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(config); + const { groupPolicy, providerMissingFallbackApplied } = + resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: config.channels?.googlechat !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "googlechat", + accountId: account.accountId, + blockedLabel: GROUP_POLICY_BLOCKED_LABEL.space, + log: (message) => logVerbose(core, runtime, message), + }); const groupConfigResolved = resolveGroupConfig({ groupId: spaceId, groupName: space.displayName ?? null, diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 00696414f..a2b7bbde6 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -18,6 +18,8 @@ import { resolveIMessageAccount, resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, setAccountEnabledInConfigSection, type ChannelPlugin, type ResolvedIMessageAccount, @@ -97,8 +99,12 @@ export const imessagePlugin: ChannelPlugin = { }; }, collectWarnings: ({ account, cfg }) => { - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.imessage !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index 024f379c3..59121e7ff 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -4,6 +4,8 @@ import { formatPairingApproveHint, getChatChannelMeta, PAIRING_APPROVED_MESSAGE, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, setAccountEnabledInConfigSection, deleteAccountFromConfigSection, type ChannelPlugin, @@ -134,8 +136,12 @@ export const ircPlugin: ChannelPlugin = { }, collectWarnings: ({ account, cfg }) => { const warnings: string[] = []; - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.irc !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); if (groupPolicy === "open") { warnings.push( '- IRC channels: groupPolicy="open" allows all channels and senders (mention-gated). Prefer channels.irc.groupPolicy="allowlist" with channels.irc.groups.', diff --git a/extensions/irc/src/inbound.ts b/extensions/irc/src/inbound.ts index abd523ed1..dd466f095 100644 --- a/extensions/irc/src/inbound.ts +++ b/extensions/irc/src/inbound.ts @@ -1,7 +1,11 @@ import { + GROUP_POLICY_BLOCKED_LABEL, createReplyPrefixOptions, logInboundDrop, resolveControlCommandGate, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, type OpenClawConfig, type RuntimeEnv, } from "openclaw/plugin-sdk"; @@ -84,8 +88,20 @@ export async function handleIrcInbound(params: { : message.senderNick; const dmPolicy = account.config.dmPolicy ?? "pairing"; - const defaultGroupPolicy = config.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(config); + const { groupPolicy, providerMissingFallbackApplied } = + resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: config.channels?.irc !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "irc", + accountId: account.accountId, + blockedLabel: GROUP_POLICY_BLOCKED_LABEL.channel, + log: (message) => runtime.log?.(message), + }); const configAllowFrom = normalizeIrcAllowlist(account.config.allowFrom); const configGroupAllowFrom = normalizeIrcAllowlist(account.config.groupAllowFrom); diff --git a/extensions/line/src/channel.logout.test.ts b/extensions/line/src/channel.logout.test.ts index dbceacee7..c2864ec70 100644 --- a/extensions/line/src/channel.logout.test.ts +++ b/extensions/line/src/channel.logout.test.ts @@ -47,15 +47,50 @@ function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } { return { runtime, mocks: { writeConfigFile, resolveLineAccount } }; } +function createRuntimeEnv(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number): never => { + throw new Error(`exit ${code}`); + }), + }; +} + +function resolveAccount( + resolveLineAccount: LineRuntimeMocks["resolveLineAccount"], + cfg: OpenClawConfig, + accountId: string, +): ResolvedLineAccount { + const resolver = resolveLineAccount as unknown as (params: { + cfg: OpenClawConfig; + accountId?: string; + }) => ResolvedLineAccount; + return resolver({ cfg, accountId }); +} + +async function runLogoutScenario(params: { cfg: OpenClawConfig; accountId: string }): Promise<{ + result: Awaited["logoutAccount"]>>>; + mocks: LineRuntimeMocks; +}> { + const { runtime, mocks } = createRuntime(); + setLineRuntime(runtime); + const account = resolveAccount(mocks.resolveLineAccount, params.cfg, params.accountId); + const result = await linePlugin.gateway!.logoutAccount!({ + accountId: params.accountId, + cfg: params.cfg, + account, + runtime: createRuntimeEnv(), + }); + return { result, mocks }; +} + describe("linePlugin gateway.logoutAccount", () => { beforeEach(() => { setLineRuntime(createRuntime().runtime); }); it("clears tokenFile/secretFile on default account logout", async () => { - const { runtime, mocks } = createRuntime(); - setLineRuntime(runtime); - const cfg: OpenClawConfig = { channels: { line: { @@ -64,38 +99,17 @@ describe("linePlugin gateway.logoutAccount", () => { }, }, }; - const runtimeEnv: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - }; - const resolveAccount = mocks.resolveLineAccount as unknown as (params: { - cfg: OpenClawConfig; - accountId?: string; - }) => ResolvedLineAccount; - const account = resolveAccount({ + const { result, mocks } = await runLogoutScenario({ cfg, accountId: DEFAULT_ACCOUNT_ID, }); - const result = await linePlugin.gateway!.logoutAccount!({ - accountId: DEFAULT_ACCOUNT_ID, - cfg, - account, - runtime: runtimeEnv, - }); - expect(result.cleared).toBe(true); expect(result.loggedOut).toBe(true); expect(mocks.writeConfigFile).toHaveBeenCalledWith({}); }); it("clears tokenFile/secretFile on account logout", async () => { - const { runtime, mocks } = createRuntime(); - setLineRuntime(runtime); - const cfg: OpenClawConfig = { channels: { line: { @@ -108,31 +122,35 @@ describe("linePlugin gateway.logoutAccount", () => { }, }, }; - const runtimeEnv: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - }; - const resolveAccount = mocks.resolveLineAccount as unknown as (params: { - cfg: OpenClawConfig; - accountId?: string; - }) => ResolvedLineAccount; - const account = resolveAccount({ + const { result, mocks } = await runLogoutScenario({ cfg, accountId: "primary", }); - const result = await linePlugin.gateway!.logoutAccount!({ - accountId: "primary", - cfg, - account, - runtime: runtimeEnv, - }); - expect(result.cleared).toBe(true); expect(result.loggedOut).toBe(true); expect(mocks.writeConfigFile).toHaveBeenCalledWith({}); }); + + it("does not write config when account has no token/secret fields", async () => { + const cfg: OpenClawConfig = { + channels: { + line: { + accounts: { + primary: { + name: "Primary", + }, + }, + }, + }, + }; + const { result, mocks } = await runLogoutScenario({ + cfg, + accountId: "primary", + }); + + expect(result.cleared).toBe(false); + expect(result.loggedOut).toBe(true); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index cc30264e1..ac49940d2 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -3,6 +3,8 @@ import { DEFAULT_ACCOUNT_ID, LineConfigSchema, processLineMessage, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, type ChannelPlugin, type ChannelStatusIssue, type OpenClawConfig, @@ -161,9 +163,12 @@ export const linePlugin: ChannelPlugin = { }; }, collectWarnings: ({ account, cfg }) => { - const defaultGroupPolicy = (cfg.channels?.defaults as { groupPolicy?: string } | undefined) - ?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.line !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 3cd699f25..20dde4dc6 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -6,6 +6,8 @@ import { formatPairingApproveHint, normalizeAccountId, PAIRING_APPROVED_MESSAGE, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, setAccountEnabledInConfigSection, type ChannelPlugin, } from "openclaw/plugin-sdk"; @@ -169,8 +171,12 @@ export const matrixPlugin: ChannelPlugin = { }; }, collectWarnings: ({ account, cfg }) => { - const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg as CoreConfig); + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: (cfg as CoreConfig).channels?.matrix !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index df6d87fad..0544dba9a 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -1,5 +1,13 @@ import { format } from "node:util"; -import { mergeAllowlist, summarizeMapping, type RuntimeEnv } from "openclaw/plugin-sdk"; +import { + GROUP_POLICY_BLOCKED_LABEL, + mergeAllowlist, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + summarizeMapping, + warnMissingProviderGroupPolicyFallbackOnce, + type RuntimeEnv, +} from "openclaw/plugin-sdk"; import { resolveMatrixTargets } from "../../resolve-targets.js"; import { getMatrixRuntime } from "../../runtime.js"; import type { CoreConfig, ReplyToMode } from "../../types.js"; @@ -242,8 +250,20 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi setActiveMatrixClient(client, opts.accountId); const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg); - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicyRaw = accountConfig.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy: groupPolicyRaw, providerMissingFallbackApplied } = + resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.matrix !== undefined, + groupPolicy: accountConfig.groupPolicy, + defaultGroupPolicy, + }); + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "matrix", + accountId: account.accountId, + blockedLabel: GROUP_POLICY_BLOCKED_LABEL.room, + log: (message) => logVerboseMessage(message), + }); const groupPolicy = allowlistOnly && groupPolicyRaw === "open" ? "allowlist" : groupPolicyRaw; const replyToMode = opts.replyToMode ?? accountConfig.replyToMode ?? "off"; const threadReplies = accountConfig.threadReplies ?? "inbound"; diff --git a/extensions/mattermost/src/channel.test.ts b/extensions/mattermost/src/channel.test.ts index cd60f4fe6..9cb5df2b8 100644 --- a/extensions/mattermost/src/channel.test.ts +++ b/extensions/mattermost/src/channel.test.ts @@ -54,6 +54,25 @@ describe("mattermostPlugin", () => { resetMattermostReactionBotUserCacheForTests(); }); + const runReactAction = async (params: Record, fetchMode: "add" | "remove") => { + const cfg = createMattermostTestConfig(); + const fetchImpl = createMattermostReactionFetchMock({ + mode: fetchMode, + postId: "POST1", + emojiName: "thumbsup", + }); + + return await withMockedGlobalFetch(fetchImpl as unknown as typeof fetch, async () => { + return await mattermostPlugin.actions?.handleAction?.({ + channel: "mattermost", + action: "react", + params, + cfg, + accountId: "default", + } as any); + }); + }; + it("exposes react when mattermost is configured", () => { const cfg: OpenClawConfig = { channels: { @@ -152,51 +171,32 @@ describe("mattermostPlugin", () => { }); it("handles react by calling Mattermost reactions API", async () => { - const cfg = createMattermostTestConfig(); - const fetchImpl = createMattermostReactionFetchMock({ - mode: "add", - postId: "POST1", - emojiName: "thumbsup", - }); - - const result = await withMockedGlobalFetch(fetchImpl as unknown as typeof fetch, async () => { - const result = await mattermostPlugin.actions?.handleAction?.({ - channel: "mattermost", - action: "react", - params: { messageId: "POST1", emoji: "thumbsup" }, - cfg, - accountId: "default", - } as any); - - return result; - }); + const result = await runReactAction({ messageId: "POST1", emoji: "thumbsup" }, "add"); expect(result?.content).toEqual([{ type: "text", text: "Reacted with :thumbsup: on POST1" }]); expect(result?.details).toEqual({}); }); it("only treats boolean remove flag as removal", async () => { - const cfg = createMattermostTestConfig(); - const fetchImpl = createMattermostReactionFetchMock({ - mode: "add", - postId: "POST1", - emojiName: "thumbsup", - }); - - const result = await withMockedGlobalFetch(fetchImpl as unknown as typeof fetch, async () => { - const result = await mattermostPlugin.actions?.handleAction?.({ - channel: "mattermost", - action: "react", - params: { messageId: "POST1", emoji: "thumbsup", remove: "true" }, - cfg, - accountId: "default", - } as any); - - return result; - }); + const result = await runReactAction( + { messageId: "POST1", emoji: "thumbsup", remove: "true" }, + "add", + ); expect(result?.content).toEqual([{ type: "text", text: "Reacted with :thumbsup: on POST1" }]); }); + + it("removes reaction when remove flag is boolean true", async () => { + const result = await runReactAction( + { messageId: "POST1", emoji: "thumbsup", remove: true }, + "remove", + ); + + expect(result?.content).toEqual([ + { type: "text", text: "Removed reaction :thumbsup: from POST1" }, + ]); + expect(result?.details).toEqual({}); + }); }); describe("config", () => { diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 3935d5f20..5053026f4 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -6,6 +6,8 @@ import { formatPairingApproveHint, migrateBaseNameToDefaultAccount, normalizeAccountId, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, setAccountEnabledInConfigSection, type ChannelMessageActionAdapter, type ChannelMessageActionName, @@ -228,8 +230,12 @@ export const mattermostPlugin: ChannelPlugin = { }; }, collectWarnings: ({ account, cfg }) => { - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.mattermost !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/mattermost/src/mattermost/client.ts b/extensions/mattermost/src/mattermost/client.ts index f0a0fd26a..826212c9e 100644 --- a/extensions/mattermost/src/mattermost/client.ts +++ b/extensions/mattermost/src/mattermost/client.ts @@ -58,7 +58,7 @@ function buildMattermostApiUrl(baseUrl: string, path: string): string { return `${normalized}/api/v4${suffix}`; } -async function readMattermostError(res: Response): Promise { +export async function readMattermostError(res: Response): Promise { const contentType = res.headers.get("content-type") ?? ""; if (contentType.includes("application/json")) { const data = (await res.json()) as { message?: string } | undefined; diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index b2c921b15..2ae8388b0 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -16,7 +16,10 @@ import { DEFAULT_GROUP_HISTORY_LIMIT, recordPendingHistoryEntryIfEnabled, resolveControlCommandGate, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, resolveChannelMediaMaxBytes, + warnMissingProviderGroupPolicyFallbackOnce, type HistoryEntry, } from "openclaw/plugin-sdk"; import { getMattermostRuntime } from "../runtime.js"; @@ -242,6 +245,19 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT, ); const channelHistories = new Map(); + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy, providerMissingFallbackApplied } = + resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.mattermost !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "mattermost", + accountId: account.accountId, + log: (message) => logVerboseMessage(message), + }); const fetchWithAuth: FetchLike = (input, init) => { const headers = new Headers(init?.headers); @@ -375,8 +391,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} senderId; const rawText = post.message?.trim() || ""; const dmPolicy = account.config.dmPolicy ?? "pairing"; - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []); const configGroupAllowFrom = normalizeAllowList(account.config.groupAllowFrom ?? []); const storeAllowFrom = normalizeAllowList( @@ -887,8 +901,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} } } } else if (kind) { - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; if (groupPolicy === "disabled") { logVerboseMessage(`mattermost: drop reaction (groupPolicy=disabled channel=${channelId})`); return; diff --git a/extensions/mattermost/src/mattermost/probe.test.ts b/extensions/mattermost/src/mattermost/probe.test.ts new file mode 100644 index 000000000..887ac576a --- /dev/null +++ b/extensions/mattermost/src/mattermost/probe.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { probeMattermost } from "./probe.js"; + +const mockFetch = vi.fn(); + +describe("probeMattermost", () => { + beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns baseUrl missing for empty base URL", async () => { + await expect(probeMattermost(" ", "token")).resolves.toEqual({ + ok: false, + error: "baseUrl missing", + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("normalizes base URL and returns bot info", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ id: "bot-1", username: "clawbot" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + const result = await probeMattermost("https://mm.example.com/api/v4/", "bot-token"); + + expect(mockFetch).toHaveBeenCalledWith( + "https://mm.example.com/api/v4/users/me", + expect.objectContaining({ + headers: { Authorization: "Bearer bot-token" }, + }), + ); + expect(result).toEqual( + expect.objectContaining({ + ok: true, + status: 200, + bot: { id: "bot-1", username: "clawbot" }, + }), + ); + expect(result.elapsedMs).toBeGreaterThanOrEqual(0); + }); + + it("returns API error details from JSON response", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ message: "invalid auth token" }), { + status: 401, + statusText: "Unauthorized", + headers: { "content-type": "application/json" }, + }), + ); + + await expect(probeMattermost("https://mm.example.com", "bad-token")).resolves.toEqual( + expect.objectContaining({ + ok: false, + status: 401, + error: "invalid auth token", + }), + ); + }); + + it("falls back to statusText when error body is empty", async () => { + mockFetch.mockResolvedValueOnce( + new Response("", { + status: 403, + statusText: "Forbidden", + headers: { "content-type": "text/plain" }, + }), + ); + + await expect(probeMattermost("https://mm.example.com", "token")).resolves.toEqual( + expect.objectContaining({ + ok: false, + status: 403, + error: "Forbidden", + }), + ); + }); + + it("returns fetch error when request throws", async () => { + mockFetch.mockRejectedValueOnce(new Error("network down")); + + await expect(probeMattermost("https://mm.example.com", "token")).resolves.toEqual( + expect.objectContaining({ + ok: false, + status: null, + error: "network down", + }), + ); + }); +}); diff --git a/extensions/mattermost/src/mattermost/probe.ts b/extensions/mattermost/src/mattermost/probe.ts index cb468ec14..eda98b21c 100644 --- a/extensions/mattermost/src/mattermost/probe.ts +++ b/extensions/mattermost/src/mattermost/probe.ts @@ -1,5 +1,5 @@ import type { BaseProbeResult } from "openclaw/plugin-sdk"; -import { normalizeMattermostBaseUrl, type MattermostUser } from "./client.js"; +import { normalizeMattermostBaseUrl, readMattermostError, type MattermostUser } from "./client.js"; export type MattermostProbe = BaseProbeResult & { status?: number | null; @@ -7,18 +7,6 @@ export type MattermostProbe = BaseProbeResult & { bot?: MattermostUser; }; -async function readMattermostError(res: Response): Promise { - const contentType = res.headers.get("content-type") ?? ""; - if (contentType.includes("application/json")) { - const data = (await res.json()) as { message?: string } | undefined; - if (data?.message) { - return data.message; - } - return JSON.stringify(data); - } - return await res.text(); -} - export async function probeMattermost( baseUrl: string, botToken: string, diff --git a/extensions/mattermost/src/onboarding.ts b/extensions/mattermost/src/onboarding.ts index 9f90f1f2a..358d3f43f 100644 --- a/extensions/mattermost/src/onboarding.ts +++ b/extensions/mattermost/src/onboarding.ts @@ -22,6 +22,25 @@ async function noteMattermostSetup(prompter: WizardPrompter): Promise { ); } +async function promptMattermostCredentials(prompter: WizardPrompter): Promise<{ + botToken: string; + baseUrl: string; +}> { + const botToken = String( + await prompter.text({ + message: "Enter Mattermost bot token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + const baseUrl = String( + await prompter.text({ + message: "Enter Mattermost base URL", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + return { botToken, baseUrl }; +} + export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = { channel, getStatus: async ({ cfg }) => { @@ -90,18 +109,9 @@ export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = { }, }; } else { - botToken = String( - await prompter.text({ - message: "Enter Mattermost bot token", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - baseUrl = String( - await prompter.text({ - message: "Enter Mattermost base URL", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); + const entered = await promptMattermostCredentials(prompter); + botToken = entered.botToken; + baseUrl = entered.baseUrl; } } else if (accountConfigured) { const keep = await prompter.confirm({ @@ -109,32 +119,14 @@ export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = { initialValue: true, }); if (!keep) { - botToken = String( - await prompter.text({ - message: "Enter Mattermost bot token", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - baseUrl = String( - await prompter.text({ - message: "Enter Mattermost base URL", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); + const entered = await promptMattermostCredentials(prompter); + botToken = entered.botToken; + baseUrl = entered.baseUrl; } } else { - botToken = String( - await prompter.text({ - message: "Enter Mattermost bot token", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - baseUrl = String( - await prompter.text({ - message: "Enter Mattermost base URL", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); + const entered = await promptMattermostCredentials(prompter); + botToken = entered.botToken; + baseUrl = entered.baseUrl; } if (botToken || baseUrl) { diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index d7e9b3088..16c7ad0fb 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -6,6 +6,8 @@ import { DEFAULT_ACCOUNT_ID, MSTeamsConfigSchema, PAIRING_APPROVED_MESSAGE, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, } from "openclaw/plugin-sdk"; import { listMSTeamsDirectoryGroupsLive, listMSTeamsDirectoryPeersLive } from "./directory-live.js"; import { msteamsOnboardingAdapter } from "./onboarding.js"; @@ -127,8 +129,12 @@ export const msteamsPlugin: ChannelPlugin = { }, security: { collectWarnings: ({ cfg }) => { - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = cfg.channels?.msteams?.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.msteams !== undefined, + groupPolicy: cfg.channels?.msteams?.groupPolicy, + defaultGroupPolicy, + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/msteams/src/directory-live.ts b/extensions/msteams/src/directory-live.ts index 8163cab49..06b2485eb 100644 --- a/extensions/msteams/src/directory-live.ts +++ b/extensions/msteams/src/directory-live.ts @@ -1,11 +1,8 @@ import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk"; +import { searchGraphUsers } from "./graph-users.js"; import { - escapeOData, - fetchGraphJson, type GraphChannel, type GraphGroup, - type GraphResponse, - type GraphUser, listChannelsForTeam, listTeamsByName, normalizeQuery, @@ -24,22 +21,7 @@ export async function listMSTeamsDirectoryPeersLive(params: { const token = await resolveGraphToken(params.cfg); const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20; - let users: GraphUser[] = []; - if (query.includes("@")) { - const escaped = escapeOData(query); - const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`; - const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`; - const res = await fetchGraphJson>({ token, path }); - users = res.value ?? []; - } else { - const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=${limit}`; - const res = await fetchGraphJson>({ - token, - path, - headers: { ConsistencyLevel: "eventual" }, - }); - users = res.value ?? []; - } + const users = await searchGraphUsers({ token, query, top: limit }); return users .map((user) => { diff --git a/extensions/msteams/src/graph-users.test.ts b/extensions/msteams/src/graph-users.test.ts new file mode 100644 index 000000000..8b5f2b52d --- /dev/null +++ b/extensions/msteams/src/graph-users.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { searchGraphUsers } from "./graph-users.js"; +import { fetchGraphJson } from "./graph.js"; + +vi.mock("./graph.js", () => ({ + escapeOData: vi.fn((value: string) => value.replace(/'/g, "''")), + fetchGraphJson: vi.fn(), +})); + +describe("searchGraphUsers", () => { + beforeEach(() => { + vi.mocked(fetchGraphJson).mockReset(); + }); + + it("returns empty array for blank queries", async () => { + await expect(searchGraphUsers({ token: "token-1", query: " " })).resolves.toEqual([]); + expect(fetchGraphJson).not.toHaveBeenCalled(); + }); + + it("uses exact mail/upn filter lookup for email-like queries", async () => { + vi.mocked(fetchGraphJson).mockResolvedValueOnce({ + value: [{ id: "user-1", displayName: "User One" }], + } as never); + + const result = await searchGraphUsers({ + token: "token-2", + query: "alice.o'hara@example.com", + }); + + expect(fetchGraphJson).toHaveBeenCalledWith({ + token: "token-2", + path: "/users?$filter=(mail%20eq%20'alice.o''hara%40example.com'%20or%20userPrincipalName%20eq%20'alice.o''hara%40example.com')&$select=id,displayName,mail,userPrincipalName", + }); + expect(result).toEqual([{ id: "user-1", displayName: "User One" }]); + }); + + it("uses displayName search with eventual consistency and custom top", async () => { + vi.mocked(fetchGraphJson).mockResolvedValueOnce({ + value: [{ id: "user-2", displayName: "Bob" }], + } as never); + + const result = await searchGraphUsers({ + token: "token-3", + query: "bob", + top: 25, + }); + + expect(fetchGraphJson).toHaveBeenCalledWith({ + token: "token-3", + path: "/users?$search=%22displayName%3Abob%22&$select=id,displayName,mail,userPrincipalName&$top=25", + headers: { ConsistencyLevel: "eventual" }, + }); + expect(result).toEqual([{ id: "user-2", displayName: "Bob" }]); + }); + + it("falls back to default top and empty value handling", async () => { + vi.mocked(fetchGraphJson).mockResolvedValueOnce({} as never); + + await expect(searchGraphUsers({ token: "token-4", query: "carol" })).resolves.toEqual([]); + expect(fetchGraphJson).toHaveBeenCalledWith({ + token: "token-4", + path: "/users?$search=%22displayName%3Acarol%22&$select=id,displayName,mail,userPrincipalName&$top=10", + headers: { ConsistencyLevel: "eventual" }, + }); + }); +}); diff --git a/extensions/msteams/src/graph-users.ts b/extensions/msteams/src/graph-users.ts new file mode 100644 index 000000000..965e83296 --- /dev/null +++ b/extensions/msteams/src/graph-users.ts @@ -0,0 +1,29 @@ +import { escapeOData, fetchGraphJson, type GraphResponse, type GraphUser } from "./graph.js"; + +export async function searchGraphUsers(params: { + token: string; + query: string; + top?: number; +}): Promise { + const query = params.query.trim(); + if (!query) { + return []; + } + + if (query.includes("@")) { + const escaped = escapeOData(query); + const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`; + const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`; + const res = await fetchGraphJson>({ token: params.token, path }); + return res.value ?? []; + } + + const top = typeof params.top === "number" && params.top > 0 ? params.top : 10; + const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=${top}`; + const res = await fetchGraphJson>({ + token: params.token, + path, + headers: { ConsistencyLevel: "eventual" }, + }); + return res.value ?? []; +} diff --git a/extensions/msteams/src/graph.ts b/extensions/msteams/src/graph.ts index 943e32ef4..d2c210153 100644 --- a/extensions/msteams/src/graph.ts +++ b/extensions/msteams/src/graph.ts @@ -1,6 +1,7 @@ import type { MSTeamsConfig } from "openclaw/plugin-sdk"; import { GRAPH_ROOT } from "./attachments/shared.js"; import { loadMSTeamsSdkWithAuth } from "./sdk.js"; +import { readAccessToken } from "./token-response.js"; import { resolveMSTeamsCredentials } from "./token.js"; export type GraphUser = { @@ -22,18 +23,6 @@ export type GraphChannel = { export type GraphResponse = { value?: T[] }; -function readAccessToken(value: unknown): string | null { - if (typeof value === "string") { - return value; - } - if (value && typeof value === "object") { - const token = - (value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token; - return typeof token === "string" ? token : null; - } - return null; -} - export function normalizeQuery(value?: string | null): string { return value?.trim() ?? ""; } diff --git a/extensions/msteams/src/messenger.ts b/extensions/msteams/src/messenger.ts index 1ee0cae68..d4de764ea 100644 --- a/extensions/msteams/src/messenger.ts +++ b/extensions/msteams/src/messenger.ts @@ -441,11 +441,7 @@ export async function sendMSTeamsMessages(params: { } }; - if (params.replyStyle === "thread") { - const ctx = params.context; - if (!ctx) { - throw new Error("Missing context for replyStyle=thread"); - } + const sendMessagesInContext = async (ctx: SendContext): Promise => { const messageIds: string[] = []; for (const [idx, message] of messages.entries()) { const response = await sendWithRetry( @@ -464,6 +460,14 @@ export async function sendMSTeamsMessages(params: { messageIds.push(extractMessageId(response) ?? "unknown"); } return messageIds; + }; + + if (params.replyStyle === "thread") { + const ctx = params.context; + if (!ctx) { + throw new Error("Missing context for replyStyle=thread"); + } + return await sendMessagesInContext(ctx); } const baseRef = buildConversationReference(params.conversationRef); @@ -474,22 +478,7 @@ export async function sendMSTeamsMessages(params: { const messageIds: string[] = []; await params.adapter.continueConversation(params.appId, proactiveRef, async (ctx) => { - for (const [idx, message] of messages.entries()) { - const response = await sendWithRetry( - async () => - await ctx.sendActivity( - await buildActivity( - message, - params.conversationRef, - params.tokenProvider, - params.sharePointSiteId, - params.mediaMaxBytes, - ), - ), - { messageIndex: idx, messageCount: messages.length }, - ); - messageIds.push(extractMessageId(response) ?? "unknown"); - } + messageIds.push(...(await sendMessagesInContext(ctx))); }); return messageIds; } diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index ae1f203a0..56f9848dd 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -5,6 +5,7 @@ import { logInboundDrop, recordPendingHistoryEntryIfEnabled, resolveControlCommandGate, + resolveDefaultGroupPolicy, resolveMentionGating, formatAllowlistMatchMeta, type HistoryEntry, @@ -174,7 +175,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { } } - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); const groupPolicy = !isDirectMessage && msteamsCfg ? (msteamsCfg.groupPolicy ?? defaultGroupPolicy ?? "allowlist") diff --git a/extensions/msteams/src/probe.ts b/extensions/msteams/src/probe.ts index b6732c658..8434fa504 100644 --- a/extensions/msteams/src/probe.ts +++ b/extensions/msteams/src/probe.ts @@ -1,6 +1,7 @@ import type { BaseProbeResult, MSTeamsConfig } from "openclaw/plugin-sdk"; import { formatUnknownError } from "./errors.js"; import { loadMSTeamsSdkWithAuth } from "./sdk.js"; +import { readAccessToken } from "./token-response.js"; import { resolveMSTeamsCredentials } from "./token.js"; export type ProbeMSTeamsResult = BaseProbeResult & { @@ -13,18 +14,6 @@ export type ProbeMSTeamsResult = BaseProbeResult & { }; }; -function readAccessToken(value: unknown): string | null { - if (typeof value === "string") { - return value; - } - if (value && typeof value === "object") { - const token = - (value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token; - return typeof token === "string" ? token : null; - } - return null; -} - function decodeJwtPayload(token: string): Record | null { const parts = token.split("."); if (parts.length < 2) { diff --git a/extensions/msteams/src/resolve-allowlist.ts b/extensions/msteams/src/resolve-allowlist.ts index d87bea302..1e66c4972 100644 --- a/extensions/msteams/src/resolve-allowlist.ts +++ b/extensions/msteams/src/resolve-allowlist.ts @@ -1,8 +1,5 @@ +import { searchGraphUsers } from "./graph-users.js"; import { - escapeOData, - fetchGraphJson, - type GraphResponse, - type GraphUser, listChannelsForTeam, listTeamsByName, normalizeQuery, @@ -182,22 +179,7 @@ export async function resolveMSTeamsUserAllowlist(params: { results.push({ input, resolved: true, id: query }); continue; } - let users: GraphUser[] = []; - if (query.includes("@")) { - const escaped = escapeOData(query); - const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`; - const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`; - const res = await fetchGraphJson>({ token, path }); - users = res.value ?? []; - } else { - const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=10`; - const res = await fetchGraphJson>({ - token, - path, - headers: { ConsistencyLevel: "eventual" }, - }); - users = res.value ?? []; - } + const users = await searchGraphUsers({ token, query, top: 10 }); const match = users[0]; if (!match?.id) { results.push({ input, resolved: false }); diff --git a/extensions/msteams/src/token-response.test.ts b/extensions/msteams/src/token-response.test.ts new file mode 100644 index 000000000..2deddfbc7 --- /dev/null +++ b/extensions/msteams/src/token-response.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { readAccessToken } from "./token-response.js"; + +describe("readAccessToken", () => { + it("returns raw string token values", () => { + expect(readAccessToken("abc")).toBe("abc"); + }); + + it("returns accessToken from object value", () => { + expect(readAccessToken({ accessToken: "access-token" })).toBe("access-token"); + }); + + it("returns token fallback from object value", () => { + expect(readAccessToken({ token: "fallback-token" })).toBe("fallback-token"); + }); + + it("returns null for unsupported values", () => { + expect(readAccessToken({ accessToken: 123 })).toBeNull(); + expect(readAccessToken({ token: false })).toBeNull(); + expect(readAccessToken(null)).toBeNull(); + expect(readAccessToken(undefined)).toBeNull(); + }); +}); diff --git a/extensions/msteams/src/token-response.ts b/extensions/msteams/src/token-response.ts new file mode 100644 index 000000000..b08804b1c --- /dev/null +++ b/extensions/msteams/src/token-response.ts @@ -0,0 +1,11 @@ +export function readAccessToken(value: unknown): string | null { + if (typeof value === "string") { + return value; + } + if (value && typeof value === "object") { + const token = + (value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token; + return typeof token === "string" ? token : null; + } + return null; +} diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index 7471d70da..c0cfa8e44 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -5,6 +5,8 @@ import { deleteAccountFromConfigSection, formatPairingApproveHint, normalizeAccountId, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, setAccountEnabledInConfigSection, type ChannelPlugin, type OpenClawConfig, @@ -128,8 +130,13 @@ export const nextcloudTalkPlugin: ChannelPlugin = }; }, collectWarnings: ({ account, cfg }) => { - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: + (cfg.channels as Record | undefined)?.["nextcloud-talk"] !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts index 642e010b0..5ad02979b 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -1,7 +1,11 @@ import { + GROUP_POLICY_BLOCKED_LABEL, createReplyPrefixOptions, logInboundDrop, resolveControlCommandGate, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, type OpenClawConfig, type RuntimeEnv, } from "openclaw/plugin-sdk"; @@ -84,12 +88,22 @@ export async function handleNextcloudTalkInbound(params: { statusSink?.({ lastInboundAt: message.timestamp }); const dmPolicy = account.config.dmPolicy ?? "pairing"; - const defaultGroupPolicy = (config.channels as Record | undefined)?.defaults as - | { groupPolicy?: string } - | undefined; - const groupPolicy = (account.config.groupPolicy ?? - defaultGroupPolicy?.groupPolicy ?? - "allowlist") as GroupPolicy; + const defaultGroupPolicy = resolveDefaultGroupPolicy(config as OpenClawConfig); + const { groupPolicy, providerMissingFallbackApplied } = + resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: + ((config.channels as Record | undefined)?.["nextcloud-talk"] ?? + undefined) !== undefined, + groupPolicy: account.config.groupPolicy as GroupPolicy | undefined, + defaultGroupPolicy, + }); + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "nextcloud-talk", + accountId: account.accountId, + blockedLabel: GROUP_POLICY_BLOCKED_LABEL.room, + log: (message) => runtime.log?.(message), + }); const configAllowFrom = normalizeNextcloudTalkAllowlist(account.config.allowFrom); const configGroupAllowFrom = normalizeNextcloudTalkAllowlist(account.config.groupAllowFrom); diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 2d627eeb9..2feb30dfe 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -17,6 +17,8 @@ import { PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, resolveDefaultSignalAccountId, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, resolveSignalAccount, setAccountEnabledInConfigSection, signalOnboardingAdapter, @@ -123,8 +125,12 @@ export const signalPlugin: ChannelPlugin = { }; }, collectWarnings: ({ account, cfg }) => { - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.signal !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 891dd6a59..f431f71b3 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -19,6 +19,8 @@ import { resolveDefaultSlackAccountId, resolveSlackAccount, resolveSlackReplyToMode, + resolveOpenProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, buildSlackThreadingToolContext, @@ -150,8 +152,12 @@ export const slackPlugin: ChannelPlugin = { }, collectWarnings: ({ account, cfg }) => { const warnings: string[] = []; - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.slack !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); const channelAllowlistConfigured = Boolean(account.config.channels) && Object.keys(account.config.channels ?? {}).length > 0; diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index a26dd956a..91ccba95e 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -17,6 +17,8 @@ import { parseTelegramReplyToMessageId, parseTelegramThreadId, resolveDefaultTelegramAccountId, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, resolveTelegramAccount, resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, @@ -195,8 +197,12 @@ export const telegramPlugin: ChannelPlugin { - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.telegram !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/voice-call/src/manager.test.ts b/extensions/voice-call/src/manager.test.ts index 3d02cb323..d92dbc11f 100644 --- a/extensions/voice-call/src/manager.test.ts +++ b/extensions/voice-call/src/manager.test.ts @@ -46,17 +46,44 @@ class FakeProvider implements VoiceCallProvider { } } +let storeSeq = 0; + +function createTestStorePath(): string { + storeSeq += 1; + return path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}-${storeSeq}`); +} + +function createManagerHarness( + configOverrides: Record = {}, + provider = new FakeProvider(), +): { + manager: CallManager; + provider: FakeProvider; +} { + const config = VoiceCallConfigSchema.parse({ + enabled: true, + provider: "plivo", + fromNumber: "+15550000000", + ...configOverrides, + }); + const manager = new CallManager(config, createTestStorePath()); + manager.initialize(provider, "https://example.com/voice/webhook"); + return { manager, provider }; +} + +function markCallAnswered(manager: CallManager, callId: string, eventId: string): void { + manager.processEvent({ + id: eventId, + type: "call.answered", + callId, + providerCallId: "request-uuid", + timestamp: Date.now(), + }); +} + describe("CallManager", () => { it("upgrades providerCallId mapping when provider ID changes", async () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", - }); - - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const manager = new CallManager(config, storePath); - manager.initialize(new FakeProvider(), "https://example.com/voice/webhook"); + const { manager } = createManagerHarness(); const { callId, success, error } = await manager.initiateCall("+15550000001"); expect(success).toBe(true); @@ -81,16 +108,7 @@ describe("CallManager", () => { }); it("speaks initial message on answered for notify mode (non-Twilio)", async () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", - }); - - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); + const { manager, provider } = createManagerHarness(); const { callId, success } = await manager.initiateCall("+15550000002", undefined, { message: "Hello there", @@ -113,19 +131,11 @@ describe("CallManager", () => { }); it("rejects inbound calls with missing caller ID when allowlist enabled", () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ inboundPolicy: "allowlist", allowFrom: ["+15550001234"], }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - manager.processEvent({ id: "evt-allowlist-missing", type: "call.initiated", @@ -142,19 +152,11 @@ describe("CallManager", () => { }); it("rejects inbound calls with anonymous caller ID when allowlist enabled", () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ inboundPolicy: "allowlist", allowFrom: ["+15550001234"], }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - manager.processEvent({ id: "evt-allowlist-anon", type: "call.initiated", @@ -172,19 +174,11 @@ describe("CallManager", () => { }); it("rejects inbound calls that only match allowlist suffixes", () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ inboundPolicy: "allowlist", allowFrom: ["+15550001234"], }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - manager.processEvent({ id: "evt-allowlist-suffix", type: "call.initiated", @@ -202,18 +196,10 @@ describe("CallManager", () => { }); it("rejects duplicate inbound events with a single hangup call", () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ inboundPolicy: "disabled", }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - manager.processEvent({ id: "evt-reject-init", type: "call.initiated", @@ -242,18 +228,11 @@ describe("CallManager", () => { }); it("accepts inbound calls that exactly match the allowlist", () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager } = createManagerHarness({ inboundPolicy: "allowlist", allowFrom: ["+15550001234"], }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const manager = new CallManager(config, storePath); - manager.initialize(new FakeProvider(), "https://example.com/voice/webhook"); - manager.processEvent({ id: "evt-allowlist-exact", type: "call.initiated", @@ -269,28 +248,14 @@ describe("CallManager", () => { }); it("completes a closed-loop turn without live audio", async () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ transcriptTimeoutMs: 5000, }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - const started = await manager.initiateCall("+15550000003"); expect(started.success).toBe(true); - manager.processEvent({ - id: "evt-closed-loop-answered", - type: "call.answered", - callId: started.callId, - providerCallId: "request-uuid", - timestamp: Date.now(), - }); + markCallAnswered(manager, started.callId, "evt-closed-loop-answered"); const turnPromise = manager.continueCall(started.callId, "How can I help?"); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -323,28 +288,14 @@ describe("CallManager", () => { }); it("rejects overlapping continueCall requests for the same call", async () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ transcriptTimeoutMs: 5000, }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - const started = await manager.initiateCall("+15550000004"); expect(started.success).toBe(true); - manager.processEvent({ - id: "evt-overlap-answered", - type: "call.answered", - callId: started.callId, - providerCallId: "request-uuid", - timestamp: Date.now(), - }); + markCallAnswered(manager, started.callId, "evt-overlap-answered"); const first = manager.continueCall(started.callId, "First prompt"); const second = await manager.continueCall(started.callId, "Second prompt"); @@ -369,28 +320,14 @@ describe("CallManager", () => { }); it("tracks latency metadata across multiple closed-loop turns", async () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ transcriptTimeoutMs: 5000, }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - const started = await manager.initiateCall("+15550000005"); expect(started.success).toBe(true); - manager.processEvent({ - id: "evt-multi-answered", - type: "call.answered", - callId: started.callId, - providerCallId: "request-uuid", - timestamp: Date.now(), - }); + markCallAnswered(manager, started.callId, "evt-multi-answered"); const firstTurn = manager.continueCall(started.callId, "First question"); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -436,28 +373,14 @@ describe("CallManager", () => { }); it("handles repeated closed-loop turns without waiter churn", async () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ transcriptTimeoutMs: 5000, }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - const started = await manager.initiateCall("+15550000006"); expect(started.success).toBe(true); - manager.processEvent({ - id: "evt-loop-answered", - type: "call.answered", - callId: started.callId, - providerCallId: "request-uuid", - timestamp: Date.now(), - }); + markCallAnswered(manager, started.callId, "evt-loop-answered"); for (let i = 1; i <= 5; i++) { const turnPromise = manager.continueCall(started.callId, `Prompt ${i}`); diff --git a/extensions/voice-call/src/manager/events.test.ts b/extensions/voice-call/src/manager/events.test.ts index 74d1f10e4..f1d5b5d6f 100644 --- a/extensions/voice-call/src/manager/events.test.ts +++ b/extensions/voice-call/src/manager/events.test.ts @@ -45,6 +45,32 @@ function createProvider(overrides: Partial = {}): VoiceCallPr }; } +function createInboundDisabledConfig() { + return VoiceCallConfigSchema.parse({ + enabled: true, + provider: "plivo", + fromNumber: "+15550000000", + inboundPolicy: "disabled", + }); +} + +function createInboundInitiatedEvent(params: { + id: string; + providerCallId: string; + from: string; +}): NormalizedEvent { + return { + id: params.id, + type: "call.initiated", + callId: params.providerCallId, + providerCallId: params.providerCallId, + timestamp: Date.now(), + direction: "inbound", + from: params.from, + to: "+15550000000", + }; +} + describe("processEvent (functional)", () => { it("calls provider hangup when rejecting inbound call", () => { const hangupCalls: HangupCallInput[] = []; @@ -55,24 +81,14 @@ describe("processEvent (functional)", () => { }); const ctx = createContext({ - config: VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", - inboundPolicy: "disabled", - }), + config: createInboundDisabledConfig(), provider, }); - const event: NormalizedEvent = { + const event = createInboundInitiatedEvent({ id: "evt-1", - type: "call.initiated", - callId: "prov-1", providerCallId: "prov-1", - timestamp: Date.now(), - direction: "inbound", from: "+15559999999", - to: "+15550000000", - }; + }); processEvent(ctx, event); @@ -87,24 +103,14 @@ describe("processEvent (functional)", () => { it("does not call hangup when provider is null", () => { const ctx = createContext({ - config: VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", - inboundPolicy: "disabled", - }), + config: createInboundDisabledConfig(), provider: null, }); - const event: NormalizedEvent = { + const event = createInboundInitiatedEvent({ id: "evt-2", - type: "call.initiated", - callId: "prov-2", providerCallId: "prov-2", - timestamp: Date.now(), - direction: "inbound", from: "+15551111111", - to: "+15550000000", - }; + }); processEvent(ctx, event); @@ -119,24 +125,14 @@ describe("processEvent (functional)", () => { }, }); const ctx = createContext({ - config: VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", - inboundPolicy: "disabled", - }), + config: createInboundDisabledConfig(), provider, }); - const event1: NormalizedEvent = { + const event1 = createInboundInitiatedEvent({ id: "evt-init", - type: "call.initiated", - callId: "prov-dup", providerCallId: "prov-dup", - timestamp: Date.now(), - direction: "inbound", from: "+15552222222", - to: "+15550000000", - }; + }); const event2: NormalizedEvent = { id: "evt-ring", type: "call.ringing", @@ -228,24 +224,14 @@ describe("processEvent (functional)", () => { }, }); const ctx = createContext({ - config: VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", - inboundPolicy: "disabled", - }), + config: createInboundDisabledConfig(), provider, }); - const event: NormalizedEvent = { + const event = createInboundInitiatedEvent({ id: "evt-fail", - type: "call.initiated", - callId: "prov-fail", providerCallId: "prov-fail", - timestamp: Date.now(), - direction: "inbound", from: "+15553333333", - to: "+15550000000", - }; + }); expect(() => processEvent(ctx, event)).not.toThrow(); expect(ctx.activeCalls.size).toBe(0); diff --git a/extensions/voice-call/src/manager/outbound.ts b/extensions/voice-call/src/manager/outbound.ts index d94c9da99..38978b679 100644 --- a/extensions/voice-call/src/manager/outbound.ts +++ b/extensions/voice-call/src/manager/outbound.ts @@ -51,6 +51,32 @@ type EndCallContext = Pick< | "maxDurationTimers" >; +type ConnectedCallContext = Pick; + +type ConnectedCallLookup = + | { kind: "error"; error: string } + | { kind: "ended"; call: CallRecord } + | { + kind: "ok"; + call: CallRecord; + providerCallId: string; + provider: NonNullable; + }; + +function lookupConnectedCall(ctx: ConnectedCallContext, callId: CallId): ConnectedCallLookup { + const call = ctx.activeCalls.get(callId); + if (!call) { + return { kind: "error", error: "Call not found" }; + } + if (!ctx.provider || !call.providerCallId) { + return { kind: "error", error: "Call not connected" }; + } + if (TerminalStates.has(call.state)) { + return { kind: "ended", call }; + } + return { kind: "ok", call, providerCallId: call.providerCallId, provider: ctx.provider }; +} + export async function initiateCall( ctx: InitiateContext, to: string, @@ -149,26 +175,25 @@ export async function speak( callId: CallId, text: string, ): Promise<{ success: boolean; error?: string }> { - const call = ctx.activeCalls.get(callId); - if (!call) { - return { success: false, error: "Call not found" }; + const lookup = lookupConnectedCall(ctx, callId); + if (lookup.kind === "error") { + return { success: false, error: lookup.error }; } - if (!ctx.provider || !call.providerCallId) { - return { success: false, error: "Call not connected" }; - } - if (TerminalStates.has(call.state)) { + if (lookup.kind === "ended") { return { success: false, error: "Call has ended" }; } + const { call, providerCallId, provider } = lookup; + try { transitionState(call, "speaking"); persistCallRecord(ctx.storePath, call); addTranscriptEntry(call, "bot", text); - const voice = ctx.provider?.name === "twilio" ? ctx.config.tts?.openai?.voice : undefined; - await ctx.provider.playTts({ + const voice = provider.name === "twilio" ? ctx.config.tts?.openai?.voice : undefined; + await provider.playTts({ callId, - providerCallId: call.providerCallId, + providerCallId, text, voice, }); @@ -232,16 +257,15 @@ export async function continueCall( callId: CallId, prompt: string, ): Promise<{ success: boolean; transcript?: string; error?: string }> { - const call = ctx.activeCalls.get(callId); - if (!call) { - return { success: false, error: "Call not found" }; + const lookup = lookupConnectedCall(ctx, callId); + if (lookup.kind === "error") { + return { success: false, error: lookup.error }; } - if (!ctx.provider || !call.providerCallId) { - return { success: false, error: "Call not connected" }; - } - if (TerminalStates.has(call.state)) { + if (lookup.kind === "ended") { return { success: false, error: "Call has ended" }; } + const { call, providerCallId, provider } = lookup; + if (ctx.activeTurnCalls.has(callId) || ctx.transcriptWaiters.has(callId)) { return { success: false, error: "Already waiting for transcript" }; } @@ -256,13 +280,13 @@ export async function continueCall( persistCallRecord(ctx.storePath, call); const listenStartedAt = Date.now(); - await ctx.provider.startListening({ callId, providerCallId: call.providerCallId }); + await provider.startListening({ callId, providerCallId }); const transcript = await waitForFinalTranscript(ctx, callId); const transcriptReceivedAt = Date.now(); // Best-effort: stop listening after final transcript. - await ctx.provider.stopListening({ callId, providerCallId: call.providerCallId }); + await provider.stopListening({ callId, providerCallId }); const lastTurnLatencyMs = transcriptReceivedAt - turnStartedAt; const lastTurnListenWaitMs = transcriptReceivedAt - listenStartedAt; @@ -302,21 +326,19 @@ export async function endCall( ctx: EndCallContext, callId: CallId, ): Promise<{ success: boolean; error?: string }> { - const call = ctx.activeCalls.get(callId); - if (!call) { - return { success: false, error: "Call not found" }; + const lookup = lookupConnectedCall(ctx, callId); + if (lookup.kind === "error") { + return { success: false, error: lookup.error }; } - if (!ctx.provider || !call.providerCallId) { - return { success: false, error: "Call not connected" }; - } - if (TerminalStates.has(call.state)) { + if (lookup.kind === "ended") { return { success: true }; } + const { call, providerCallId, provider } = lookup; try { - await ctx.provider.hangupCall({ + await provider.hangupCall({ callId, - providerCallId: call.providerCallId, + providerCallId, reason: "hangup-bot", }); @@ -329,9 +351,7 @@ export async function endCall( rejectTranscriptWaiter(ctx, callId, "Call ended: hangup-bot"); ctx.activeCalls.delete(callId); - if (call.providerCallId) { - ctx.providerCallIdMap.delete(call.providerCallId); - } + ctx.providerCallIdMap.delete(providerCallId); return { success: true }; } catch (err) { diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index d19359630..b122577e2 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -19,6 +19,8 @@ import { readStringParam, resolveDefaultWhatsAppAccountId, resolveWhatsAppOutboundTarget, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, resolveWhatsAppAccount, resolveWhatsAppGroupRequireMention, resolveWhatsAppGroupToolPolicy, @@ -142,8 +144,12 @@ export const whatsappPlugin: ChannelPlugin = { }; }, collectWarnings: ({ account, cfg }) => { - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.whatsapp !== undefined, + groupPolicy: account.groupPolicy, + defaultGroupPolicy, + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/zalo/src/monitor.webhook.test.ts b/extensions/zalo/src/monitor.webhook.test.ts index 97162544b..af998bee6 100644 --- a/extensions/zalo/src/monitor.webhook.test.ts +++ b/extensions/zalo/src/monitor.webhook.test.ts @@ -21,113 +21,84 @@ async function withServer(handler: RequestListener, fn: (baseUrl: string) => Pro } } +const DEFAULT_ACCOUNT: ResolvedZaloAccount = { + accountId: "default", + enabled: true, + token: "tok", + tokenSource: "config", + config: {}, +}; + +const webhookRequestHandler: RequestListener = async (req, res) => { + const handled = await handleZaloWebhookRequest(req, res); + if (!handled) { + res.statusCode = 404; + res.end("not found"); + } +}; + +function registerTarget(params: { + path: string; + secret?: string; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; +}): () => void { + return registerZaloWebhookTarget({ + token: "tok", + account: DEFAULT_ACCOUNT, + config: {} as OpenClawConfig, + runtime: {}, + core: {} as PluginRuntime, + secret: params.secret ?? "secret", + path: params.path, + mediaMaxMb: 5, + statusSink: params.statusSink, + }); +} + describe("handleZaloWebhookRequest", () => { it("returns 400 for non-object payloads", async () => { - const core = {} as PluginRuntime; - const account: ResolvedZaloAccount = { - accountId: "default", - enabled: true, - token: "tok", - tokenSource: "config", - config: {}, - }; - const unregister = registerZaloWebhookTarget({ - token: "tok", - account, - config: {} as OpenClawConfig, - runtime: {}, - core, - secret: "secret", - path: "/hook", - mediaMaxMb: 5, - }); + const unregister = registerTarget({ path: "/hook" }); try { - await withServer( - async (req, res) => { - const handled = await handleZaloWebhookRequest(req, res); - if (!handled) { - res.statusCode = 404; - res.end("not found"); - } - }, - async (baseUrl) => { - const response = await fetch(`${baseUrl}/hook`, { - method: "POST", - headers: { - "x-bot-api-secret-token": "secret", - "content-type": "application/json", - }, - body: "null", - }); + await withServer(webhookRequestHandler, async (baseUrl) => { + const response = await fetch(`${baseUrl}/hook`, { + method: "POST", + headers: { + "x-bot-api-secret-token": "secret", + "content-type": "application/json", + }, + body: "null", + }); - expect(response.status).toBe(400); - expect(await response.text()).toBe("Bad Request"); - }, - ); + expect(response.status).toBe(400); + expect(await response.text()).toBe("Bad Request"); + }); } finally { unregister(); } }); it("rejects ambiguous routing when multiple targets match the same secret", async () => { - const core = {} as PluginRuntime; - const account: ResolvedZaloAccount = { - accountId: "default", - enabled: true, - token: "tok", - tokenSource: "config", - config: {}, - }; const sinkA = vi.fn(); const sinkB = vi.fn(); - const unregisterA = registerZaloWebhookTarget({ - token: "tok", - account, - config: {} as OpenClawConfig, - runtime: {}, - core, - secret: "secret", - path: "/hook", - mediaMaxMb: 5, - statusSink: sinkA, - }); - const unregisterB = registerZaloWebhookTarget({ - token: "tok", - account, - config: {} as OpenClawConfig, - runtime: {}, - core, - secret: "secret", - path: "/hook", - mediaMaxMb: 5, - statusSink: sinkB, - }); + const unregisterA = registerTarget({ path: "/hook", statusSink: sinkA }); + const unregisterB = registerTarget({ path: "/hook", statusSink: sinkB }); try { - await withServer( - async (req, res) => { - const handled = await handleZaloWebhookRequest(req, res); - if (!handled) { - res.statusCode = 404; - res.end("not found"); - } - }, - async (baseUrl) => { - const response = await fetch(`${baseUrl}/hook`, { - method: "POST", - headers: { - "x-bot-api-secret-token": "secret", - "content-type": "application/json", - }, - body: "{}", - }); + await withServer(webhookRequestHandler, async (baseUrl) => { + const response = await fetch(`${baseUrl}/hook`, { + method: "POST", + headers: { + "x-bot-api-secret-token": "secret", + "content-type": "application/json", + }, + body: "{}", + }); - expect(response.status).toBe(401); - expect(sinkA).not.toHaveBeenCalled(); - expect(sinkB).not.toHaveBeenCalled(); - }, - ); + expect(response.status).toBe(401); + expect(sinkA).not.toHaveBeenCalled(); + expect(sinkB).not.toHaveBeenCalled(); + }); } finally { unregisterA(); unregisterB(); @@ -135,73 +106,29 @@ describe("handleZaloWebhookRequest", () => { }); it("returns 415 for non-json content-type", async () => { - const core = {} as PluginRuntime; - const account: ResolvedZaloAccount = { - accountId: "default", - enabled: true, - token: "tok", - tokenSource: "config", - config: {}, - }; - const unregister = registerZaloWebhookTarget({ - token: "tok", - account, - config: {} as OpenClawConfig, - runtime: {}, - core, - secret: "secret", - path: "/hook-content-type", - mediaMaxMb: 5, - }); + const unregister = registerTarget({ path: "/hook-content-type" }); try { - await withServer( - async (req, res) => { - const handled = await handleZaloWebhookRequest(req, res); - if (!handled) { - res.statusCode = 404; - res.end("not found"); - } - }, - async (baseUrl) => { - const response = await fetch(`${baseUrl}/hook-content-type`, { - method: "POST", - headers: { - "x-bot-api-secret-token": "secret", - "content-type": "text/plain", - }, - body: "{}", - }); + await withServer(webhookRequestHandler, async (baseUrl) => { + const response = await fetch(`${baseUrl}/hook-content-type`, { + method: "POST", + headers: { + "x-bot-api-secret-token": "secret", + "content-type": "text/plain", + }, + body: "{}", + }); - expect(response.status).toBe(415); - }, - ); + expect(response.status).toBe(415); + }); } finally { unregister(); } }); it("deduplicates webhook replay by event_name + message_id", async () => { - const core = {} as PluginRuntime; - const account: ResolvedZaloAccount = { - accountId: "default", - enabled: true, - token: "tok", - tokenSource: "config", - config: {}, - }; const sink = vi.fn(); - const unregister = registerZaloWebhookTarget({ - token: "tok", - account, - config: {} as OpenClawConfig, - runtime: {}, - core, - secret: "secret", - path: "/hook-replay", - mediaMaxMb: 5, - statusSink: sink, - }); + const unregister = registerTarget({ path: "/hook-replay", statusSink: sink }); const payload = { event_name: "message.text.received", @@ -215,91 +142,56 @@ describe("handleZaloWebhookRequest", () => { }; try { - await withServer( - async (req, res) => { - const handled = await handleZaloWebhookRequest(req, res); - if (!handled) { - res.statusCode = 404; - res.end("not found"); - } - }, - async (baseUrl) => { - const first = await fetch(`${baseUrl}/hook-replay`, { - method: "POST", - headers: { - "x-bot-api-secret-token": "secret", - "content-type": "application/json", - }, - body: JSON.stringify(payload), - }); - const second = await fetch(`${baseUrl}/hook-replay`, { - method: "POST", - headers: { - "x-bot-api-secret-token": "secret", - "content-type": "application/json", - }, - body: JSON.stringify(payload), - }); + await withServer(webhookRequestHandler, async (baseUrl) => { + const first = await fetch(`${baseUrl}/hook-replay`, { + method: "POST", + headers: { + "x-bot-api-secret-token": "secret", + "content-type": "application/json", + }, + body: JSON.stringify(payload), + }); + const second = await fetch(`${baseUrl}/hook-replay`, { + method: "POST", + headers: { + "x-bot-api-secret-token": "secret", + "content-type": "application/json", + }, + body: JSON.stringify(payload), + }); - expect(first.status).toBe(200); - expect(second.status).toBe(200); - expect(sink).toHaveBeenCalledTimes(1); - }, - ); + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(sink).toHaveBeenCalledTimes(1); + }); } finally { unregister(); } }); it("returns 429 when per-path request rate exceeds threshold", async () => { - const core = {} as PluginRuntime; - const account: ResolvedZaloAccount = { - accountId: "default", - enabled: true, - token: "tok", - tokenSource: "config", - config: {}, - }; - const unregister = registerZaloWebhookTarget({ - token: "tok", - account, - config: {} as OpenClawConfig, - runtime: {}, - core, - secret: "secret", - path: "/hook-rate", - mediaMaxMb: 5, - }); + const unregister = registerTarget({ path: "/hook-rate" }); try { - await withServer( - async (req, res) => { - const handled = await handleZaloWebhookRequest(req, res); - if (!handled) { - res.statusCode = 404; - res.end("not found"); - } - }, - async (baseUrl) => { - let saw429 = false; - for (let i = 0; i < 130; i += 1) { - const response = await fetch(`${baseUrl}/hook-rate`, { - method: "POST", - headers: { - "x-bot-api-secret-token": "secret", - "content-type": "application/json", - }, - body: "{}", - }); - if (response.status === 429) { - saw429 = true; - break; - } + await withServer(webhookRequestHandler, async (baseUrl) => { + let saw429 = false; + for (let i = 0; i < 130; i += 1) { + const response = await fetch(`${baseUrl}/hook-rate`, { + method: "POST", + headers: { + "x-bot-api-secret-token": "secret", + "content-type": "application/json", + }, + body: "{}", + }); + if (response.status === 429) { + saw429 = true; + break; } + } - expect(saw429).toBe(true); - }, - ); + expect(saw429).toBe(true); + }); } finally { unregister(); } diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index c55a76a14..17575c401 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -3,8 +3,11 @@ import type { OpenClawConfig, MarkdownTableMode, RuntimeEnv } from "openclaw/plu import { createReplyPrefixOptions, mergeAllowlist, + resolveOpenProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, resolveSenderCommandAuthorization, summarizeMapping, + warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk"; import { getZalouserRuntime } from "./runtime.js"; import { sendMessageZalouser } from "./send.js"; @@ -177,8 +180,18 @@ async function processMessage( const groupName = metadata?.threadName ?? ""; const chatId = threadId; - const defaultGroupPolicy = config.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(config); + const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent: config.channels?.zalouser !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "zalouser", + accountId: account.accountId, + log: (message) => logVerbose(core, runtime, message), + }); const groups = account.config.groups ?? {}; if (isGroup) { if (groupPolicy === "disabled") { diff --git a/extensions/zalouser/src/onboarding.ts b/extensions/zalouser/src/onboarding.ts index 03750e110..c623349e7 100644 --- a/extensions/zalouser/src/onboarding.ts +++ b/extensions/zalouser/src/onboarding.ts @@ -23,6 +23,45 @@ import { runZca, runZcaInteractive, checkZcaInstalled, parseJsonOutput } from ". const channel = "zalouser" as const; +function setZalouserAccountScopedConfig( + cfg: OpenClawConfig, + accountId: string, + 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; +} + function setZalouserDmPolicy( cfg: OpenClawConfig, dmPolicy: "pairing" | "allowlist" | "open" | "disabled", @@ -123,40 +162,10 @@ async function promptZalouserAllowFrom(params: { continue; } const unique = mergeAllowFromEntries(existingAllowFrom, results.filter(Boolean) as string[]); - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - channels: { - ...cfg.channels, - zalouser: { - ...cfg.channels?.zalouser, - enabled: true, - dmPolicy: "allowlist", - allowFrom: unique, - }, - }, - } 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, - dmPolicy: "allowlist", - allowFrom: unique, - }, - }, - }, - }, - } as OpenClawConfig; + return setZalouserAccountScopedConfig(cfg, accountId, { + dmPolicy: "allowlist", + allowFrom: unique, + }); } } @@ -165,37 +174,9 @@ function setZalouserGroupPolicy( accountId: string, groupPolicy: "open" | "allowlist" | "disabled", ): OpenClawConfig { - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - channels: { - ...cfg.channels, - zalouser: { - ...cfg.channels?.zalouser, - enabled: true, - groupPolicy, - }, - }, - } 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, - groupPolicy, - }, - }, - }, - }, - } as OpenClawConfig; + return setZalouserAccountScopedConfig(cfg, accountId, { + groupPolicy, + }); } function setZalouserGroupAllowlist( @@ -204,37 +185,9 @@ function setZalouserGroupAllowlist( groupKeys: string[], ): OpenClawConfig { const groups = Object.fromEntries(groupKeys.map((key) => [key, { allow: true }])); - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - channels: { - ...cfg.channels, - zalouser: { - ...cfg.channels?.zalouser, - enabled: true, - groups, - }, - }, - } 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, - groups, - }, - }, - }, - }, - } as OpenClawConfig; + return setZalouserAccountScopedConfig(cfg, accountId, { + groups, + }); } async function resolveZalouserGroups(params: { @@ -403,38 +356,12 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = { } // Enable the channel - if (accountId === DEFAULT_ACCOUNT_ID) { - next = { - ...next, - channels: { - ...next.channels, - zalouser: { - ...next.channels?.zalouser, - enabled: true, - profile: account.profile !== "default" ? account.profile : undefined, - }, - }, - } as OpenClawConfig; - } else { - next = { - ...next, - channels: { - ...next.channels, - zalouser: { - ...next.channels?.zalouser, - enabled: true, - accounts: { - ...next.channels?.zalouser?.accounts, - [accountId]: { - ...next.channels?.zalouser?.accounts?.[accountId], - enabled: true, - profile: account.profile, - }, - }, - }, - }, - } as OpenClawConfig; - } + next = setZalouserAccountScopedConfig( + next, + accountId, + { profile: account.profile !== "default" ? account.profile : undefined }, + { profile: account.profile, enabled: true }, + ); if (forceAllowFrom) { next = await promptZalouserAllowFrom({ @@ -447,7 +374,7 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = { const accessConfig = await promptChannelAccessConfig({ prompter, label: "Zalo groups", - currentPolicy: account.config.groupPolicy ?? "open", + currentPolicy: account.config.groupPolicy ?? "allowlist", currentEntries: Object.keys(account.config.groups ?? {}), placeholder: "Family, Work, 123456789", updatePrompt: Boolean(account.config.groups), diff --git a/extensions/zalouser/src/send.test.ts b/extensions/zalouser/src/send.test.ts new file mode 100644 index 000000000..abca9fd50 --- /dev/null +++ b/extensions/zalouser/src/send.test.ts @@ -0,0 +1,156 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + sendImageZalouser, + sendLinkZalouser, + sendMessageZalouser, + type ZalouserSendResult, +} from "./send.js"; +import { runZca } from "./zca.js"; + +vi.mock("./zca.js", () => ({ + runZca: vi.fn(), +})); + +const mockRunZca = vi.mocked(runZca); +const originalZcaProfile = process.env.ZCA_PROFILE; + +function okResult(stdout = "message_id: msg-1") { + return { + ok: true, + stdout, + stderr: "", + exitCode: 0, + }; +} + +function failResult(stderr = "") { + return { + ok: false, + stdout: "", + stderr, + exitCode: 1, + }; +} + +describe("zalouser send helpers", () => { + beforeEach(() => { + mockRunZca.mockReset(); + delete process.env.ZCA_PROFILE; + }); + + afterEach(() => { + if (originalZcaProfile) { + process.env.ZCA_PROFILE = originalZcaProfile; + return; + } + delete process.env.ZCA_PROFILE; + }); + + it("returns validation error when thread id is missing", async () => { + const result = await sendMessageZalouser("", "hello"); + expect(result).toEqual({ + ok: false, + error: "No threadId provided", + } satisfies ZalouserSendResult); + expect(mockRunZca).not.toHaveBeenCalled(); + }); + + it("builds text send command with truncation and group flag", async () => { + mockRunZca.mockResolvedValueOnce(okResult("message id: mid-123")); + + const result = await sendMessageZalouser(" thread-1 ", "x".repeat(2200), { + profile: "profile-a", + isGroup: true, + }); + + expect(mockRunZca).toHaveBeenCalledWith(["msg", "send", "thread-1", "x".repeat(2000), "-g"], { + profile: "profile-a", + }); + expect(result).toEqual({ ok: true, messageId: "mid-123" }); + }); + + it("routes media sends from sendMessage and keeps text as caption", async () => { + mockRunZca.mockResolvedValueOnce(okResult()); + + await sendMessageZalouser("thread-2", "media caption", { + profile: "profile-b", + mediaUrl: "https://cdn.example.com/video.mp4", + isGroup: true, + }); + + expect(mockRunZca).toHaveBeenCalledWith( + [ + "msg", + "video", + "thread-2", + "-u", + "https://cdn.example.com/video.mp4", + "-m", + "media caption", + "-g", + ], + { profile: "profile-b" }, + ); + }); + + it("maps audio media to voice command", async () => { + mockRunZca.mockResolvedValueOnce(okResult()); + + await sendMessageZalouser("thread-3", "", { + profile: "profile-c", + mediaUrl: "https://cdn.example.com/clip.mp3", + }); + + expect(mockRunZca).toHaveBeenCalledWith( + ["msg", "voice", "thread-3", "-u", "https://cdn.example.com/clip.mp3"], + { profile: "profile-c" }, + ); + }); + + it("builds image command with caption and returns fallback error", async () => { + mockRunZca.mockResolvedValueOnce(failResult("")); + + const result = await sendImageZalouser("thread-4", " https://cdn.example.com/img.png ", { + profile: "profile-d", + caption: "caption text", + isGroup: true, + }); + + expect(mockRunZca).toHaveBeenCalledWith( + [ + "msg", + "image", + "thread-4", + "-u", + "https://cdn.example.com/img.png", + "-m", + "caption text", + "-g", + ], + { profile: "profile-d" }, + ); + expect(result).toEqual({ ok: false, error: "Failed to send image" }); + }); + + it("uses env profile fallback and builds link command", async () => { + process.env.ZCA_PROFILE = "env-profile"; + mockRunZca.mockResolvedValueOnce(okResult("abc123")); + + const result = await sendLinkZalouser("thread-5", " https://openclaw.ai ", { isGroup: true }); + + expect(mockRunZca).toHaveBeenCalledWith( + ["msg", "link", "thread-5", "https://openclaw.ai", "-g"], + { profile: "env-profile" }, + ); + expect(result).toEqual({ ok: true, messageId: "abc123" }); + }); + + it("returns caught command errors", async () => { + mockRunZca.mockRejectedValueOnce(new Error("zca unavailable")); + + await expect(sendLinkZalouser("thread-6", "https://openclaw.ai")).resolves.toEqual({ + ok: false, + error: "zca unavailable", + }); + }); +}); diff --git a/extensions/zalouser/src/send.ts b/extensions/zalouser/src/send.ts index 0674b88e2..1a3c3d3ea 100644 --- a/extensions/zalouser/src/send.ts +++ b/extensions/zalouser/src/send.ts @@ -13,12 +13,41 @@ export type ZalouserSendResult = { error?: string; }; +function resolveProfile(options: ZalouserSendOptions): string { + return options.profile || process.env.ZCA_PROFILE || "default"; +} + +function appendCaptionAndGroupFlags(args: string[], options: ZalouserSendOptions): void { + if (options.caption) { + args.push("-m", options.caption.slice(0, 2000)); + } + if (options.isGroup) { + args.push("-g"); + } +} + +async function runSendCommand( + args: string[], + profile: string, + fallbackError: string, +): Promise { + try { + const result = await runZca(args, { profile }); + if (result.ok) { + return { ok: true, messageId: extractMessageId(result.stdout) }; + } + return { ok: false, error: result.stderr || fallbackError }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + export async function sendMessageZalouser( threadId: string, text: string, options: ZalouserSendOptions = {}, ): Promise { - const profile = options.profile || process.env.ZCA_PROFILE || "default"; + const profile = resolveProfile(options); if (!threadId?.trim()) { return { ok: false, error: "No threadId provided" }; @@ -38,17 +67,7 @@ export async function sendMessageZalouser( args.push("-g"); } - try { - const result = await runZca(args, { profile }); - - if (result.ok) { - return { ok: true, messageId: extractMessageId(result.stdout) }; - } - - return { ok: false, error: result.stderr || "Failed to send message" }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; - } + return runSendCommand(args, profile, "Failed to send message"); } async function sendMediaZalouser( @@ -56,7 +75,7 @@ async function sendMediaZalouser( mediaUrl: string, options: ZalouserSendOptions = {}, ): Promise { - const profile = options.profile || process.env.ZCA_PROFILE || "default"; + const profile = resolveProfile(options); if (!threadId?.trim()) { return { ok: false, error: "No threadId provided" }; @@ -78,24 +97,8 @@ async function sendMediaZalouser( } const args = ["msg", command, threadId.trim(), "-u", mediaUrl.trim()]; - if (options.caption) { - args.push("-m", options.caption.slice(0, 2000)); - } - if (options.isGroup) { - args.push("-g"); - } - - try { - const result = await runZca(args, { profile }); - - if (result.ok) { - return { ok: true, messageId: extractMessageId(result.stdout) }; - } - - return { ok: false, error: result.stderr || `Failed to send ${command}` }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; - } + appendCaptionAndGroupFlags(args, options); + return runSendCommand(args, profile, `Failed to send ${command}`); } export async function sendImageZalouser( @@ -103,24 +106,10 @@ export async function sendImageZalouser( imageUrl: string, options: ZalouserSendOptions = {}, ): Promise { - const profile = options.profile || process.env.ZCA_PROFILE || "default"; + const profile = resolveProfile(options); const args = ["msg", "image", threadId.trim(), "-u", imageUrl.trim()]; - if (options.caption) { - args.push("-m", options.caption.slice(0, 2000)); - } - if (options.isGroup) { - args.push("-g"); - } - - try { - const result = await runZca(args, { profile }); - if (result.ok) { - return { ok: true, messageId: extractMessageId(result.stdout) }; - } - return { ok: false, error: result.stderr || "Failed to send image" }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; - } + appendCaptionAndGroupFlags(args, options); + return runSendCommand(args, profile, "Failed to send image"); } export async function sendLinkZalouser( @@ -128,21 +117,13 @@ export async function sendLinkZalouser( url: string, options: ZalouserSendOptions = {}, ): Promise { - const profile = options.profile || process.env.ZCA_PROFILE || "default"; + const profile = resolveProfile(options); const args = ["msg", "link", threadId.trim(), url.trim()]; if (options.isGroup) { args.push("-g"); } - try { - const result = await runZca(args, { profile }); - if (result.ok) { - return { ok: true, messageId: extractMessageId(result.stdout) }; - } - return { ok: false, error: result.stderr || "Failed to send link" }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; - } + return runSendCommand(args, profile, "Failed to send link"); } function extractMessageId(stdout: string): string | undefined { diff --git a/extensions/zalouser/src/types.ts b/extensions/zalouser/src/types.ts index e6557cb0e..8be1649ba 100644 --- a/extensions/zalouser/src/types.ts +++ b/extensions/zalouser/src/types.ts @@ -68,35 +68,30 @@ export type ListenOptions = CommonOptions & { prefix?: string; }; -export type ZalouserAccountConfig = { +type ZalouserToolConfig = { allow?: string[]; deny?: string[] }; + +type ZalouserGroupConfig = { + allow?: boolean; + enabled?: boolean; + tools?: ZalouserToolConfig; +}; + +type ZalouserSharedConfig = { enabled?: boolean; name?: string; profile?: string; dmPolicy?: "pairing" | "allowlist" | "open" | "disabled"; allowFrom?: Array; groupPolicy?: "open" | "allowlist" | "disabled"; - groups?: Record< - string, - { allow?: boolean; enabled?: boolean; tools?: { allow?: string[]; deny?: string[] } } - >; + groups?: Record; messagePrefix?: string; responsePrefix?: string; }; -export type ZalouserConfig = { - enabled?: boolean; - name?: string; - profile?: string; +export type ZalouserAccountConfig = ZalouserSharedConfig; + +export type ZalouserConfig = ZalouserSharedConfig & { defaultAccount?: string; - dmPolicy?: "pairing" | "allowlist" | "open" | "disabled"; - allowFrom?: Array; - groupPolicy?: "open" | "allowlist" | "disabled"; - groups?: Record< - string, - { allow?: boolean; enabled?: boolean; tools?: { allow?: string[]; deny?: string[] } } - >; - messagePrefix?: string; - responsePrefix?: string; accounts?: Record; }; diff --git a/src/agents/auth-profiles.markauthprofilefailure.test.ts b/src/agents/auth-profiles.markauthprofilefailure.test.ts index 63f0271a5..c2720a7ed 100644 --- a/src/agents/auth-profiles.markauthprofilefailure.test.ts +++ b/src/agents/auth-profiles.markauthprofilefailure.test.ts @@ -83,6 +83,32 @@ describe("markAuthProfileFailure", () => { expectCooldownInRange(remainingMs, 0.8 * 60 * 60 * 1000, 1.2 * 60 * 60 * 1000); }); }); + it("keeps persisted cooldownUntil unchanged across mid-window retries", async () => { + await withAuthProfileStore(async ({ agentDir, store }) => { + await markAuthProfileFailure({ + store, + profileId: "anthropic:default", + reason: "rate_limit", + agentDir, + }); + + const firstCooldownUntil = store.usageStats?.["anthropic:default"]?.cooldownUntil; + expect(typeof firstCooldownUntil).toBe("number"); + + await markAuthProfileFailure({ + store, + profileId: "anthropic:default", + reason: "rate_limit", + agentDir, + }); + + const secondCooldownUntil = store.usageStats?.["anthropic:default"]?.cooldownUntil; + expect(secondCooldownUntil).toBe(firstCooldownUntil); + + const reloaded = ensureAuthProfileStore(agentDir); + expect(reloaded.usageStats?.["anthropic:default"]?.cooldownUntil).toBe(firstCooldownUntil); + }); + }); it("resets backoff counters outside the failure window", async () => { const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-")); try { diff --git a/src/agents/auth-profiles/usage.test.ts b/src/agents/auth-profiles/usage.test.ts index b5c92f646..6baef101f 100644 --- a/src/agents/auth-profiles/usage.test.ts +++ b/src/agents/auth-profiles/usage.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it, vi } from "vitest"; -import type { AuthProfileStore } from "./types.js"; +import type { AuthProfileStore, ProfileUsageStats } from "./types.js"; import { clearAuthProfileCooldown, clearExpiredCooldowns, isProfileInCooldown, + markAuthProfileFailure, resolveProfileUnusableUntil, } from "./usage.js"; @@ -347,3 +348,116 @@ describe("clearAuthProfileCooldown", () => { expect(store.usageStats).toBeUndefined(); }); }); + +describe("markAuthProfileFailure — active windows do not extend on retry", () => { + // Regression for https://github.com/openclaw/openclaw/issues/23516 + // When all providers are at saturation backoff (60 min) and retries fire every 30 min, + // each retry was resetting cooldownUntil to now+60m, preventing recovery. + type WindowStats = ProfileUsageStats; + + async function markFailureAt(params: { + store: ReturnType; + now: number; + reason: "rate_limit" | "billing"; + }): Promise { + vi.useFakeTimers(); + vi.setSystemTime(params.now); + try { + await markAuthProfileFailure({ + store: params.store, + profileId: "anthropic:default", + reason: params.reason, + }); + } finally { + vi.useRealTimers(); + } + } + + const activeWindowCases = [ + { + label: "cooldownUntil", + reason: "rate_limit" as const, + buildUsageStats: (now: number): WindowStats => ({ + cooldownUntil: now + 50 * 60 * 1000, + errorCount: 3, + lastFailureAt: now - 10 * 60 * 1000, + }), + readUntil: (stats: WindowStats | undefined) => stats?.cooldownUntil, + }, + { + label: "disabledUntil", + reason: "billing" as const, + buildUsageStats: (now: number): WindowStats => ({ + disabledUntil: now + 20 * 60 * 60 * 1000, + disabledReason: "billing", + errorCount: 5, + failureCounts: { billing: 5 }, + lastFailureAt: now - 60_000, + }), + readUntil: (stats: WindowStats | undefined) => stats?.disabledUntil, + }, + ]; + + for (const testCase of activeWindowCases) { + it(`keeps active ${testCase.label} unchanged on retry`, async () => { + const now = 1_000_000; + const existingStats = testCase.buildUsageStats(now); + const existingUntil = testCase.readUntil(existingStats); + const store = makeStore({ "anthropic:default": existingStats }); + + await markFailureAt({ + store, + now, + reason: testCase.reason, + }); + + const stats = store.usageStats?.["anthropic:default"]; + expect(testCase.readUntil(stats)).toBe(existingUntil); + }); + } + + const expiredWindowCases = [ + { + label: "cooldownUntil", + reason: "rate_limit" as const, + buildUsageStats: (now: number): WindowStats => ({ + cooldownUntil: now - 60_000, + errorCount: 3, + lastFailureAt: now - 60_000, + }), + expectedUntil: (now: number) => now + 60 * 60 * 1000, + readUntil: (stats: WindowStats | undefined) => stats?.cooldownUntil, + }, + { + label: "disabledUntil", + reason: "billing" as const, + buildUsageStats: (now: number): WindowStats => ({ + disabledUntil: now - 60_000, + disabledReason: "billing", + errorCount: 5, + failureCounts: { billing: 2 }, + lastFailureAt: now - 60_000, + }), + expectedUntil: (now: number) => now + 20 * 60 * 60 * 1000, + readUntil: (stats: WindowStats | undefined) => stats?.disabledUntil, + }, + ]; + + for (const testCase of expiredWindowCases) { + it(`recomputes ${testCase.label} after the previous window expires`, async () => { + const now = 1_000_000; + const store = makeStore({ + "anthropic:default": testCase.buildUsageStats(now), + }); + + await markFailureAt({ + store, + now, + reason: testCase.reason, + }); + + const stats = store.usageStats?.["anthropic:default"]; + expect(testCase.readUntil(stats)).toBe(testCase.expectedUntil(now)); + }); + } +}); diff --git a/src/agents/auth-profiles/usage.ts b/src/agents/auth-profiles/usage.ts index 1bfda2268..65816b529 100644 --- a/src/agents/auth-profiles/usage.ts +++ b/src/agents/auth-profiles/usage.ts @@ -256,6 +256,17 @@ export function resolveProfileUnusableUntilForDisplay( return resolveProfileUnusableUntil(stats); } +function keepActiveWindowOrRecompute(params: { + existingUntil: number | undefined; + now: number; + recomputedUntil: number; +}): number { + const { existingUntil, now, recomputedUntil } = params; + const hasActiveWindow = + typeof existingUntil === "number" && Number.isFinite(existingUntil) && existingUntil > now; + return hasActiveWindow ? existingUntil : recomputedUntil; +} + function computeNextProfileUsageStats(params: { existing: ProfileUsageStats; now: number; @@ -287,11 +298,23 @@ function computeNextProfileUsageStats(params: { baseMs: params.cfgResolved.billingBackoffMs, maxMs: params.cfgResolved.billingMaxMs, }); - updatedStats.disabledUntil = params.now + backoffMs; + // Keep active disable windows immutable so retries within the window cannot + // extend recovery time indefinitely. + updatedStats.disabledUntil = keepActiveWindowOrRecompute({ + existingUntil: params.existing.disabledUntil, + now: params.now, + recomputedUntil: params.now + backoffMs, + }); updatedStats.disabledReason = "billing"; } else { const backoffMs = calculateAuthProfileCooldownMs(nextErrorCount); - updatedStats.cooldownUntil = params.now + backoffMs; + // Keep active cooldown windows immutable so retries within the window + // cannot push recovery further out. + updatedStats.cooldownUntil = keepActiveWindowOrRecompute({ + existingUntil: params.existing.cooldownUntil, + now: params.now, + recomputedUntil: params.now + backoffMs, + }); } return updatedStats; diff --git a/src/agents/bash-tools.exec-host-gateway.ts b/src/agents/bash-tools.exec-host-gateway.ts index 7e0698169..f742ee386 100644 --- a/src/agents/bash-tools.exec-host-gateway.ts +++ b/src/agents/bash-tools.exec-host-gateway.ts @@ -14,6 +14,7 @@ import { resolveAllowAlwaysPatterns, resolveExecApprovals, } from "../infra/exec-approvals.js"; +import type { SafeBinProfile } from "../infra/exec-safe-bin-policy.js"; import { markBackgrounded, tail } from "./bash-process-registry.js"; import { requestExecApprovalDecision } from "./bash-tools.exec-approval-request.js"; import { @@ -36,6 +37,7 @@ export type ProcessGatewayAllowlistParams = { security: ExecSecurity; ask: ExecAsk; safeBins: Set; + safeBinProfiles: Readonly>; agentId?: string; sessionKey?: string; scopeKey?: string; @@ -69,6 +71,7 @@ export async function processGatewayAllowlist( command: params.command, allowlist: approvals.allowlist, safeBins: params.safeBins, + safeBinProfiles: params.safeBinProfiles, cwd: params.workdir, env: params.env, platform: process.platform, diff --git a/src/agents/bash-tools.exec-types.ts b/src/agents/bash-tools.exec-types.ts index 9a94f4554..b6947de79 100644 --- a/src/agents/bash-tools.exec-types.ts +++ b/src/agents/bash-tools.exec-types.ts @@ -1,4 +1,5 @@ import type { ExecAsk, ExecHost, ExecSecurity } from "../infra/exec-approvals.js"; +import type { SafeBinProfileFixture } from "../infra/exec-safe-bin-policy.js"; import type { BashSandboxConfig } from "./bash-tools.shared.js"; export type ExecToolDefaults = { @@ -8,6 +9,7 @@ export type ExecToolDefaults = { node?: string; pathPrepend?: string[]; safeBins?: string[]; + safeBinProfiles?: Record; agentId?: string; backgroundMs?: number; timeoutSec?: number; diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 8ee8aa946..3776cce96 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -1,8 +1,8 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; -import { type ExecHost, maxAsk, minSecurity, resolveSafeBins } from "../infra/exec-approvals.js"; -import { getTrustedSafeBinDirs } from "../infra/exec-safe-bin-trust.js"; +import { type ExecHost, maxAsk, minSecurity } from "../infra/exec-approvals.js"; +import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js"; import { getShellPathFromLoginShell, resolveShellEnvFallbackTimeoutMs, @@ -163,8 +163,28 @@ export function createExecTool( ? defaults.timeoutSec : 1800; const defaultPathPrepend = normalizePathPrepend(defaults?.pathPrepend); - const safeBins = resolveSafeBins(defaults?.safeBins); - const trustedSafeBinDirs = getTrustedSafeBinDirs(); + const { + safeBins, + safeBinProfiles, + trustedSafeBinDirs, + unprofiledSafeBins, + unprofiledInterpreterSafeBins, + } = resolveExecSafeBinRuntimePolicy({ + local: { + safeBins: defaults?.safeBins, + safeBinProfiles: defaults?.safeBinProfiles, + }, + }); + if (unprofiledSafeBins.length > 0) { + logInfo( + `exec: ignoring unprofiled safeBins entries (${unprofiledSafeBins.toSorted().join(", ")}); use allowlist or define tools.exec.safeBinProfiles.`, + ); + } + if (unprofiledInterpreterSafeBins.length > 0) { + logInfo( + `exec: interpreter/runtime binaries in safeBins (${unprofiledInterpreterSafeBins.join(", ")}) are unsafe without explicit hardened profiles; prefer allowlist entries`, + ); + } const notifyOnExit = defaults?.notifyOnExit !== false; const notifyOnExitEmptySuccess = defaults?.notifyOnExitEmptySuccess === true; const notifySessionKey = defaults?.sessionKey?.trim() || undefined; @@ -404,6 +424,7 @@ export function createExecTool( security, ask, safeBins, + safeBinProfiles, agentId, sessionKey: defaults?.sessionKey, scopeKey: defaults?.scopeKey, diff --git a/src/agents/bash-tools.process.ts b/src/agents/bash-tools.process.ts index dbdb6f997..25248bf22 100644 --- a/src/agents/bash-tools.process.ts +++ b/src/agents/bash-tools.process.ts @@ -278,6 +278,18 @@ export function createProcessTool( }); }; + const runningSessionResult = ( + session: ProcessSession, + text: string, + ): AgentToolResult => ({ + content: [{ type: "text", text }], + details: { + status: "running", + sessionId: params.sessionId, + name: deriveSessionName(session.command), + }, + }); + switch (params.action) { case "poll": { if (!scopedSession) { @@ -452,21 +464,12 @@ export function createProcessTool( if (params.eof) { resolved.stdin.end(); } - return { - content: [ - { - type: "text", - text: `Wrote ${(params.data ?? "").length} bytes to session ${params.sessionId}${ - params.eof ? " (stdin closed)" : "" - }.`, - }, - ], - details: { - status: "running", - sessionId: params.sessionId, - name: deriveSessionName(resolved.session.command), - }, - }; + return runningSessionResult( + resolved.session, + `Wrote ${(params.data ?? "").length} bytes to session ${params.sessionId}${ + params.eof ? " (stdin closed)" : "" + }.`, + ); } case "send-keys": { @@ -491,21 +494,11 @@ export function createProcessTool( }; } await writeToStdin(resolved.stdin, data); - return { - content: [ - { - type: "text", - text: - `Sent ${data.length} bytes to session ${params.sessionId}.` + - (warnings.length ? `\nWarnings:\n- ${warnings.join("\n- ")}` : ""), - }, - ], - details: { - status: "running", - sessionId: params.sessionId, - name: deriveSessionName(resolved.session.command), - }, - }; + return runningSessionResult( + resolved.session, + `Sent ${data.length} bytes to session ${params.sessionId}.` + + (warnings.length ? `\nWarnings:\n- ${warnings.join("\n- ")}` : ""), + ); } case "submit": { @@ -514,19 +507,10 @@ export function createProcessTool( return resolved.result; } await writeToStdin(resolved.stdin, "\r"); - return { - content: [ - { - type: "text", - text: `Submitted session ${params.sessionId} (sent CR).`, - }, - ], - details: { - status: "running", - sessionId: params.sessionId, - name: deriveSessionName(resolved.session.command), - }, - }; + return runningSessionResult( + resolved.session, + `Submitted session ${params.sessionId} (sent CR).`, + ); } case "paste": { @@ -547,19 +531,10 @@ export function createProcessTool( }; } await writeToStdin(resolved.stdin, payload); - return { - content: [ - { - type: "text", - text: `Pasted ${params.text?.length ?? 0} chars to session ${params.sessionId}.`, - }, - ], - details: { - status: "running", - sessionId: params.sessionId, - name: deriveSessionName(resolved.session.command), - }, - }; + return runningSessionResult( + resolved.session, + `Pasted ${params.text?.length ?? 0} chars to session ${params.sessionId}.`, + ); } case "kill": { diff --git a/src/agents/bootstrap-files.test.ts b/src/agents/bootstrap-files.test.ts index 676030ad5..c5b869a72 100644 --- a/src/agents/bootstrap-files.test.ts +++ b/src/agents/bootstrap-files.test.ts @@ -24,6 +24,33 @@ function registerExtraBootstrapFileHook() { }); } +function registerMalformedBootstrapFileHook() { + registerInternalHook("agent:bootstrap", (event) => { + const context = event.context as AgentBootstrapHookContext; + context.bootstrapFiles = [ + ...context.bootstrapFiles, + { + name: "EXTRA.md", + filePath: path.join(context.workspaceDir, "BROKEN.md"), + content: "broken", + missing: false, + } as unknown as WorkspaceBootstrapFile, + { + name: "EXTRA.md", + path: 123, + content: "broken", + missing: false, + } as unknown as WorkspaceBootstrapFile, + { + name: "EXTRA.md", + path: " ", + content: "broken", + missing: false, + } as unknown as WorkspaceBootstrapFile, + ]; + }); +} + describe("resolveBootstrapFilesForRun", () => { beforeEach(() => clearInternalHooks()); afterEach(() => clearInternalHooks()); @@ -36,6 +63,23 @@ describe("resolveBootstrapFilesForRun", () => { expect(files.some((file) => file.path === path.join(workspaceDir, "EXTRA.md"))).toBe(true); }); + + it("drops malformed hook files with missing/invalid paths", async () => { + registerMalformedBootstrapFileHook(); + + const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-"); + const warnings: string[] = []; + const files = await resolveBootstrapFilesForRun({ + workspaceDir, + warn: (message) => warnings.push(message), + }); + + expect( + files.every((file) => typeof file.path === "string" && file.path.trim().length > 0), + ).toBe(true); + expect(warnings).toHaveLength(3); + expect(warnings[0]).toContain('missing or invalid "path" field'); + }); }); describe("resolveBootstrapContextForRun", () => { diff --git a/src/agents/bootstrap-files.ts b/src/agents/bootstrap-files.ts index 6abad5fcf..511610daa 100644 --- a/src/agents/bootstrap-files.ts +++ b/src/agents/bootstrap-files.ts @@ -22,12 +22,31 @@ export function makeBootstrapWarn(params: { return (message: string) => params.warn?.(`${message} (sessionKey=${params.sessionLabel})`); } +function sanitizeBootstrapFiles( + files: WorkspaceBootstrapFile[], + warn?: (message: string) => void, +): WorkspaceBootstrapFile[] { + const sanitized: WorkspaceBootstrapFile[] = []; + for (const file of files) { + const pathValue = typeof file.path === "string" ? file.path.trim() : ""; + if (!pathValue) { + warn?.( + `skipping bootstrap file "${file.name}" — missing or invalid "path" field (hook may have used "filePath" instead)`, + ); + continue; + } + sanitized.push({ ...file, path: pathValue }); + } + return sanitized; +} + export async function resolveBootstrapFilesForRun(params: { workspaceDir: string; config?: OpenClawConfig; sessionKey?: string; sessionId?: string; agentId?: string; + warn?: (message: string) => void; }): Promise { const sessionKey = params.sessionKey ?? params.sessionId; const bootstrapFiles = filterBootstrapFilesForSession( @@ -35,7 +54,7 @@ export async function resolveBootstrapFilesForRun(params: { sessionKey, ); - return applyBootstrapHookOverrides({ + const updated = await applyBootstrapHookOverrides({ files: bootstrapFiles, workspaceDir: params.workspaceDir, config: params.config, @@ -43,6 +62,7 @@ export async function resolveBootstrapFilesForRun(params: { sessionId: params.sessionId, agentId: params.agentId, }); + return sanitizeBootstrapFiles(updated, params.warn); } export async function resolveBootstrapContextForRun(params: { diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts index 1e522c043..d12303b61 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts @@ -3,7 +3,9 @@ import { emitAgentEvent } from "../infra/agent-events.js"; import "./test-helpers/fast-core-tools.js"; import { getCallGatewayMock, + getSessionsSpawnTool, resetSessionsSpawnConfigOverride, + setupSessionsSpawnGatewayMock, setSessionsSpawnConfigOverride, } from "./openclaw-tools.subagents.sessions-spawn.test-harness.js"; import { resetSubagentRegistryForTests } from "./subagent-registry.js"; @@ -18,22 +20,6 @@ vi.mock("./pi-embedded.js", () => ({ const callGatewayMock = getCallGatewayMock(); const RUN_TIMEOUT_SECONDS = 1; -type CreateOpenClawTools = (typeof import("./openclaw-tools.js"))["createOpenClawTools"]; -type CreateOpenClawToolsOpts = Parameters[0]; - -async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) { - // Dynamic import: ensure harness mocks are installed before tool modules load. - const { createOpenClawTools } = await import("./openclaw-tools.js"); - const tool = createOpenClawTools(opts).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } - return tool; -} - -type GatewayRequest = { method?: string; params?: unknown }; -type AgentWaitCall = { runId?: string; timeoutMs?: number }; - function buildDiscordCleanupHooks(onDelete: (key: string | undefined) => void) { return { onAgentSubagentSpawn: (params: unknown) => { @@ -48,98 +34,6 @@ function buildDiscordCleanupHooks(onDelete: (key: string | undefined) => void) { }; } -function setupSessionsSpawnGatewayMock(opts: { - includeSessionsList?: boolean; - includeChatHistory?: boolean; - onAgentSubagentSpawn?: (params: unknown) => void; - onSessionsPatch?: (params: unknown) => void; - onSessionsDelete?: (params: unknown) => void; - agentWaitResult?: { status: "ok" | "timeout"; startedAt: number; endedAt: number }; -}): { - calls: Array; - waitCalls: Array; - getChild: () => { runId?: string; sessionKey?: string }; -} { - const calls: Array = []; - const waitCalls: Array = []; - let agentCallCount = 0; - let childRunId: string | undefined; - let childSessionKey: string | undefined; - - callGatewayMock.mockImplementation(async (optsUnknown: unknown) => { - const request = optsUnknown as GatewayRequest; - calls.push(request); - - if (request.method === "sessions.list" && opts.includeSessionsList) { - return { - sessions: [ - { - key: "main", - lastChannel: "whatsapp", - lastTo: "+123", - }, - ], - }; - } - - if (request.method === "agent") { - agentCallCount += 1; - const runId = `run-${agentCallCount}`; - const params = request.params as { lane?: string; sessionKey?: string } | undefined; - // Only capture the first agent call (subagent spawn, not main agent trigger) - if (params?.lane === "subagent") { - childRunId = runId; - childSessionKey = params?.sessionKey ?? ""; - opts.onAgentSubagentSpawn?.(params); - } - return { - runId, - status: "accepted", - acceptedAt: 1000 + agentCallCount, - }; - } - - if (request.method === "agent.wait") { - const params = request.params as AgentWaitCall | undefined; - waitCalls.push(params ?? {}); - const res = opts.agentWaitResult ?? { status: "ok", startedAt: 1000, endedAt: 2000 }; - return { - runId: params?.runId ?? "run-1", - ...res, - }; - } - - if (request.method === "sessions.patch") { - opts.onSessionsPatch?.(request.params); - return { ok: true }; - } - - if (request.method === "sessions.delete") { - opts.onSessionsDelete?.(request.params); - return { ok: true }; - } - - if (request.method === "chat.history" && opts.includeChatHistory) { - return { - messages: [ - { - role: "assistant", - content: [{ type: "text", text: "done" }], - }, - ], - }; - } - - return {}; - }); - - return { - calls, - waitCalls, - getChild: () => ({ runId: childRunId, sessionKey: childSessionKey }), - }; -} - const waitFor = async (predicate: () => boolean, timeoutMs = 1_500) => { await vi.waitFor( () => { @@ -395,40 +289,10 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { }); it("sessions_spawn reports timed out when agent.wait returns timeout", async () => { - const calls: Array<{ method?: string; params?: unknown }> = []; - let agentCallCount = 0; - - callGatewayMock.mockImplementation(async (opts: unknown) => { - const request = opts as { method?: string; params?: unknown }; - calls.push(request); - if (request.method === "agent") { - agentCallCount += 1; - return { - runId: `run-${agentCallCount}`, - status: "accepted", - acceptedAt: 5000 + agentCallCount, - }; - } - if (request.method === "agent.wait") { - const params = request.params as { runId?: string } | undefined; - return { - runId: params?.runId ?? "run-1", - status: "timeout", - startedAt: 6000, - endedAt: 7000, - }; - } - if (request.method === "chat.history") { - return { - messages: [ - { - role: "assistant", - content: [{ type: "text", text: "still working" }], - }, - ], - }; - } - return {}; + const ctx = setupSessionsSpawnGatewayMock({ + includeChatHistory: true, + chatHistoryText: "still working", + agentWaitResult: { status: "timeout", startedAt: 6000, endedAt: 7000 }, }); const tool = await getSessionsSpawnTool({ @@ -446,9 +310,9 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { runId: "run-1", }); - await waitFor(() => calls.filter((call) => call.method === "agent").length >= 2); + await waitFor(() => ctx.calls.filter((call) => call.method === "agent").length >= 2); - const mainAgentCall = calls + const mainAgentCall = ctx.calls .filter((call) => call.method === "agent") .find((call) => { const params = call.params as { lane?: string } | undefined; @@ -461,40 +325,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { }); it("sessions_spawn announces with requester accountId", async () => { - const calls: Array<{ method?: string; params?: unknown }> = []; - let agentCallCount = 0; - let childRunId: string | undefined; - - callGatewayMock.mockImplementation(async (opts: unknown) => { - const request = opts as { method?: string; params?: unknown }; - calls.push(request); - if (request.method === "agent") { - agentCallCount += 1; - const runId = `run-${agentCallCount}`; - const params = request.params as { lane?: string; sessionKey?: string } | undefined; - if (params?.lane === "subagent") { - childRunId = runId; - } - return { - runId, - status: "accepted", - acceptedAt: 4000 + agentCallCount, - }; - } - if (request.method === "agent.wait") { - const params = request.params as { runId?: string; timeoutMs?: number } | undefined; - return { - runId: params?.runId ?? "run-1", - status: "ok", - startedAt: 1000, - endedAt: 2000, - }; - } - if (request.method === "sessions.delete" || request.method === "sessions.patch") { - return { ok: true }; - } - return {}; - }); + const ctx = setupSessionsSpawnGatewayMock({}); const tool = await getSessionsSpawnTool({ agentSessionKey: "main", @@ -512,13 +343,14 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { runId: "run-1", }); - if (!childRunId) { + const child = ctx.getChild(); + if (!child.runId) { throw new Error("missing child runId"); } vi.useFakeTimers(); try { emitAgentEvent({ - runId: childRunId, + runId: child.runId, stream: "lifecycle", data: { phase: "end", @@ -532,7 +364,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { vi.useRealTimers(); } - const agentCalls = calls.filter((call) => call.method === "agent"); + const agentCalls = ctx.calls.filter((call) => call.method === "agent"); expect(agentCalls).toHaveLength(2); const announceParams = agentCalls[1]?.params as | { accountId?: string; channel?: string; deliver?: boolean } diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts index d13bf231f..129e15b9f 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts @@ -3,6 +3,17 @@ import { vi } from "vitest"; type SessionsSpawnTestConfig = ReturnType<(typeof import("../config/config.js"))["loadConfig"]>; type CreateOpenClawTools = (typeof import("./openclaw-tools.js"))["createOpenClawTools"]; export type CreateOpenClawToolsOpts = Parameters[0]; +export type GatewayRequest = { method?: string; params?: unknown }; +export type AgentWaitCall = { runId?: string; timeoutMs?: number }; +type SessionsSpawnGatewayMockOptions = { + includeSessionsList?: boolean; + includeChatHistory?: boolean; + chatHistoryText?: string; + onAgentSubagentSpawn?: (params: unknown) => void; + onSessionsPatch?: (params: unknown) => void; + onSessionsDelete?: (params: unknown) => void; + agentWaitResult?: { status: "ok" | "timeout"; startedAt: number; endedAt: number }; +}; // Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit). // oxlint-disable-next-line typescript/no-explicit-any @@ -24,6 +35,18 @@ export function getCallGatewayMock(): AnyMock { return hoisted.callGatewayMock; } +export function getGatewayRequests(): Array { + return getCallGatewayMock().mock.calls.map((call: [unknown]) => call[0] as GatewayRequest); +} + +export function getGatewayMethods(): Array { + return getGatewayRequests().map((request) => request.method); +} + +export function findGatewayRequest(method: string): GatewayRequest | undefined { + return getGatewayRequests().find((request) => request.method === method); +} + export function resetSessionsSpawnConfigOverride(): void { hoisted.state.configOverride = hoisted.defaultConfigOverride; } @@ -42,6 +65,95 @@ export async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) { return tool; } +export function setupSessionsSpawnGatewayMock(setupOpts: SessionsSpawnGatewayMockOptions): { + calls: Array; + waitCalls: Array; + getChild: () => { runId?: string; sessionKey?: string }; +} { + const calls: Array = []; + const waitCalls: Array = []; + let agentCallCount = 0; + let childRunId: string | undefined; + let childSessionKey: string | undefined; + + getCallGatewayMock().mockImplementation(async (optsUnknown: unknown) => { + const request = optsUnknown as GatewayRequest; + calls.push(request); + + if (request.method === "sessions.list" && setupOpts.includeSessionsList) { + return { + sessions: [ + { + key: "main", + lastChannel: "whatsapp", + lastTo: "+123", + }, + ], + }; + } + + if (request.method === "agent") { + agentCallCount += 1; + const runId = `run-${agentCallCount}`; + const params = request.params as { lane?: string; sessionKey?: string } | undefined; + // Capture only the subagent run metadata. + if (params?.lane === "subagent") { + childRunId = runId; + childSessionKey = params.sessionKey ?? ""; + setupOpts.onAgentSubagentSpawn?.(params); + } + return { + runId, + status: "accepted", + acceptedAt: 1000 + agentCallCount, + }; + } + + if (request.method === "agent.wait") { + const params = request.params as AgentWaitCall | undefined; + waitCalls.push(params ?? {}); + const waitResult = setupOpts.agentWaitResult ?? { + status: "ok", + startedAt: 1000, + endedAt: 2000, + }; + return { + runId: params?.runId ?? "run-1", + ...waitResult, + }; + } + + if (request.method === "sessions.patch") { + setupOpts.onSessionsPatch?.(request.params); + return { ok: true }; + } + + if (request.method === "sessions.delete") { + setupOpts.onSessionsDelete?.(request.params); + return { ok: true }; + } + + if (request.method === "chat.history" && setupOpts.includeChatHistory) { + return { + messages: [ + { + role: "assistant", + content: [{ type: "text", text: setupOpts.chatHistoryText ?? "done" }], + }, + ], + }; + } + + return {}; + }); + + return { + calls, + waitCalls, + getChild: () => ({ runId: childRunId, sessionKey: childSessionKey }), + }; +} + vi.mock("../gateway/call.js", () => ({ callGateway: (opts: unknown) => hoisted.callGatewayMock(opts), })); diff --git a/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.test.ts b/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.test.ts index b9a290871..f353da5e7 100644 --- a/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.test.ts +++ b/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.test.ts @@ -116,6 +116,40 @@ describe("buildBootstrapContextFiles", () => { expect(result[0]?.content.length).toBeLessThanOrEqual(20); expect(result[0]?.content.startsWith("[MISSING]")).toBe(true); }); + + it("skips files with missing or invalid paths and emits warnings", () => { + const malformedMissingPath = { + name: "SKILL-SECURITY.md", + missing: false, + content: "secret", + } as unknown as WorkspaceBootstrapFile; + const malformedNonStringPath = { + name: "SKILL-SECURITY.md", + path: 123, + missing: false, + content: "secret", + } as unknown as WorkspaceBootstrapFile; + const malformedWhitespacePath = { + name: "SKILL-SECURITY.md", + path: " ", + missing: false, + content: "secret", + } as unknown as WorkspaceBootstrapFile; + const good = makeFile({ content: "hello" }); + const warnings: string[] = []; + const result = buildBootstrapContextFiles( + [malformedMissingPath, malformedNonStringPath, malformedWhitespacePath, good], + { + warn: (msg) => warnings.push(msg), + }, + ); + expect(result).toHaveLength(1); + expect(result[0]?.path).toBe("/tmp/AGENTS.md"); + expect(warnings).toHaveLength(3); + expect(warnings.every((warning) => warning.includes('missing or invalid "path" field'))).toBe( + true, + ); + }); }); type BootstrapLimitResolverCase = { diff --git a/src/agents/pi-embedded-helpers/bootstrap.ts b/src/agents/pi-embedded-helpers/bootstrap.ts index 87f5d59c9..6853bfbe9 100644 --- a/src/agents/pi-embedded-helpers/bootstrap.ts +++ b/src/agents/pi-embedded-helpers/bootstrap.ts @@ -199,15 +199,22 @@ export function buildBootstrapContextFiles( if (remainingTotalChars <= 0) { break; } + const pathValue = typeof file.path === "string" ? file.path.trim() : ""; + if (!pathValue) { + opts?.warn?.( + `skipping bootstrap file "${file.name}" — missing or invalid "path" field (hook may have used "filePath" instead)`, + ); + continue; + } if (file.missing) { - const missingText = `[MISSING] Expected at: ${file.path}`; + const missingText = `[MISSING] Expected at: ${pathValue}`; const cappedMissingText = clampToBudget(missingText, remainingTotalChars); if (!cappedMissingText) { break; } remainingTotalChars = Math.max(0, remainingTotalChars - cappedMissingText.length); result.push({ - path: file.path, + path: pathValue, content: cappedMissingText, }); continue; @@ -231,7 +238,7 @@ export function buildBootstrapContextFiles( } remainingTotalChars = Math.max(0, remainingTotalChars - contentWithinBudget.length); result.push({ - path: file.path, + path: pathValue, content: contentWithinBudget, }); } diff --git a/src/agents/pi-embedded-runner.google-sanitize-thinking.test.ts b/src/agents/pi-embedded-runner.google-sanitize-thinking.test.ts index f716ff32a..93266a023 100644 --- a/src/agents/pi-embedded-runner.google-sanitize-thinking.test.ts +++ b/src/agents/pi-embedded-runner.google-sanitize-thinking.test.ts @@ -231,6 +231,72 @@ describe("sanitizeSessionHistory (google thinking)", () => { ]); }); + it("strips non-base64 thought signatures for native Google Gemini", async () => { + const sessionManager = SessionManager.inMemory(); + const input = [ + { + role: "user", + content: "hi", + }, + { + role: "assistant", + content: [ + { type: "text", text: "hello", thought_signature: "msg_abc123" }, + { type: "thinking", thinking: "ok", thought_signature: "c2ln" }, + { + type: "toolCall", + id: "call_1", + name: "read", + arguments: { path: "/tmp/foo" }, + thoughtSignature: '{"id":1}', + }, + { + type: "toolCall", + id: "call_2", + name: "read", + arguments: { path: "/tmp/bar" }, + thoughtSignature: "c2ln", + }, + ], + }, + ] as unknown as AgentMessage[]; + + const out = await sanitizeSessionHistory({ + messages: input, + modelApi: "google-generative-ai", + provider: "google", + modelId: "gemini-2.0-flash", + sessionManager, + sessionId: "session:google-gemini", + }); + + const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as { + content?: Array<{ + type?: string; + thought_signature?: string; + thoughtSignature?: string; + thinking?: string; + }>; + }; + expect(assistant.content).toEqual([ + { type: "text", text: "hello" }, + { type: "thinking", thinking: "ok", thought_signature: "c2ln" }, + { + type: "toolCall", + id: "call1", + name: "read", + arguments: { path: "/tmp/foo" }, + }, + { + type: "toolCall", + id: "call2", + name: "read", + arguments: { path: "/tmp/bar" }, + thoughtSignature: "c2ln", + }, + ]); + }); + it("keeps mixed signed/unsigned thinking blocks for Google models", async () => { const sessionManager = SessionManager.inMemory(); const input = [ diff --git a/src/agents/pi-embedded-runner.test.ts b/src/agents/pi-embedded-runner.test.ts index cbe892131..1b0ccc1d4 100644 --- a/src/agents/pi-embedded-runner.test.ts +++ b/src/agents/pi-embedded-runner.test.ts @@ -130,7 +130,7 @@ beforeAll(async () => { workspaceDir = path.join(tempRoot, "workspace"); await fs.mkdir(agentDir, { recursive: true }); await fs.mkdir(workspaceDir, { recursive: true }); -}, 60_000); +}, 180_000); afterAll(async () => { if (!tempRoot) { diff --git a/src/agents/pi-tools.safe-bins.test.ts b/src/agents/pi-tools.safe-bins.test.ts index 551d18e13..7f0b99555 100644 --- a/src/agents/pi-tools.safe-bins.test.ts +++ b/src/agents/pi-tools.safe-bins.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { ExecApprovalsResolved } from "../infra/exec-approvals.js"; +import type { SafeBinProfileFixture } from "../infra/exec-safe-bin-policy.js"; import { captureEnv } from "../test-utils/env.js"; const bundledPluginsDirSnapshot = captureEnv(["OPENCLAW_BUNDLED_PLUGINS_DIR"]); @@ -86,6 +87,7 @@ type ExecTool = { async function createSafeBinsExecTool(params: { tmpPrefix: string; safeBins: string[]; + safeBinProfiles?: Record; files?: Array<{ name: string; contents: string }>; }): Promise<{ tmpDir: string; execTool: ExecTool }> { const { createOpenClawCodingTools } = await import("./pi-tools.js"); @@ -101,6 +103,7 @@ async function createSafeBinsExecTool(params: { security: "allowlist", ask: "off", safeBins: params.safeBins, + safeBinProfiles: params.safeBinProfiles, }, }, }; @@ -139,6 +142,9 @@ describe("createOpenClawCodingTools safeBins", () => { { tmpPrefix: "openclaw-safe-bins-", safeBins: ["echo"], + safeBinProfiles: { + echo: { maxPositional: 1 }, + }, }, async ({ tmpDir, execTool }) => { const marker = `safe-bins-${Date.now()}`; @@ -155,6 +161,23 @@ describe("createOpenClawCodingTools safeBins", () => { ); }); + it("rejects unprofiled custom safe-bin entries", async () => { + await withSafeBinsExecTool( + { + tmpPrefix: "openclaw-safe-bins-unprofiled-", + safeBins: ["echo"], + }, + async ({ tmpDir, execTool }) => { + await expect( + execTool.execute("call1", { + command: "echo hello", + workdir: tmpDir, + }), + ).rejects.toThrow("exec denied: allowlist miss"); + }, + ); + }); + it("does not allow env var expansion to smuggle file args via safeBins", async () => { await withSafeBinsExecTool( { diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 187e4ffc5..9c53c3b0d 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -7,6 +7,7 @@ import { } from "@mariozechner/pi-coding-agent"; import type { OpenClawConfig } from "../config/config.js"; import type { ToolLoopDetectionConfig } from "../config/types.tools.js"; +import { resolveMergedSafeBinProfileFixtures } from "../infra/exec-safe-bin-runtime-policy.js"; import { logWarn } from "../logger.js"; import { getPluginToolMeta } from "../plugins/tools.js"; import { isSubagentSessionKey } from "../routing/session-key.js"; @@ -104,6 +105,10 @@ function resolveExecConfig(params: { cfg?: OpenClawConfig; agentId?: string }) { node: agentExec?.node ?? globalExec?.node, pathPrepend: agentExec?.pathPrepend ?? globalExec?.pathPrepend, safeBins: agentExec?.safeBins ?? globalExec?.safeBins, + safeBinProfiles: resolveMergedSafeBinProfileFixtures({ + global: globalExec, + local: agentExec, + }), backgroundMs: agentExec?.backgroundMs ?? globalExec?.backgroundMs, timeoutSec: agentExec?.timeoutSec ?? globalExec?.timeoutSec, approvalRunningNoticeMs: @@ -361,6 +366,7 @@ export function createOpenClawCodingTools(options?: { node: options?.exec?.node ?? execConfig.node, pathPrepend: options?.exec?.pathPrepend ?? execConfig.pathPrepend, safeBins: options?.exec?.safeBins ?? execConfig.safeBins, + safeBinProfiles: options?.exec?.safeBinProfiles ?? execConfig.safeBinProfiles, agentId, cwd: workspaceRoot, allowBackground, diff --git a/src/agents/session-transcript-repair.test.ts b/src/agents/session-transcript-repair.test.ts index 68797cfee..e1422f7ea 100644 --- a/src/agents/session-transcript-repair.test.ts +++ b/src/agents/session-transcript-repair.test.ts @@ -6,6 +6,19 @@ import { repairToolUseResultPairing, } from "./session-transcript-repair.js"; +const TOOL_CALL_BLOCK_TYPES = new Set(["toolCall", "toolUse", "functionCall"]); + +function getAssistantToolCallBlocks(messages: AgentMessage[]) { + const assistant = messages[0] as Extract | undefined; + if (!assistant || !Array.isArray(assistant.content)) { + return [] as Array<{ type?: unknown; id?: unknown; name?: unknown }>; + } + return assistant.content.filter((block) => { + const type = (block as { type?: unknown }).type; + return typeof type === "string" && TOOL_CALL_BLOCK_TYPES.has(type); + }) as Array<{ type?: unknown; id?: unknown; name?: unknown }>; +} + describe("sanitizeToolUseResultPairing", () => { const buildDuplicateToolResultInput = (opts?: { middleMessage?: unknown; @@ -229,13 +242,7 @@ describe("sanitizeToolCallInputs", () => { ] as unknown as AgentMessage[]; const out = sanitizeToolCallInputs(input); - const assistant = out[0] as Extract; - const toolCalls = Array.isArray(assistant.content) - ? assistant.content.filter((block) => { - const type = (block as { type?: unknown }).type; - return typeof type === "string" && ["toolCall", "toolUse", "functionCall"].includes(type); - }) - : []; + const toolCalls = getAssistantToolCallBlocks(out); expect(toolCalls).toHaveLength(1); expect((toolCalls[0] as { id?: unknown }).id).toBe("call_ok"); @@ -264,13 +271,7 @@ describe("sanitizeToolCallInputs", () => { ] as unknown as AgentMessage[]; const out = sanitizeToolCallInputs(input); - const assistant = out[0] as Extract; - const toolCalls = Array.isArray(assistant.content) - ? assistant.content.filter((block) => { - const type = (block as { type?: unknown }).type; - return typeof type === "string" && ["toolCall", "toolUse", "functionCall"].includes(type); - }) - : []; + const toolCalls = getAssistantToolCallBlocks(out); expect(toolCalls).toHaveLength(1); expect((toolCalls[0] as { name?: unknown }).name).toBe("read"); @@ -288,13 +289,7 @@ describe("sanitizeToolCallInputs", () => { ] as unknown as AgentMessage[]; const out = sanitizeToolCallInputs(input, { allowedToolNames: ["read"] }); - const assistant = out[0] as Extract; - const toolCalls = Array.isArray(assistant.content) - ? assistant.content.filter((block) => { - const type = (block as { type?: unknown }).type; - return typeof type === "string" && ["toolCall", "toolUse", "functionCall"].includes(type); - }) - : []; + const toolCalls = getAssistantToolCallBlocks(out); expect(toolCalls).toHaveLength(1); expect((toolCalls[0] as { name?: unknown }).name).toBe("read"); diff --git a/src/agents/sessions-spawn-hooks.test.ts b/src/agents/sessions-spawn-hooks.test.ts index e38416af7..0a8c82ca6 100644 --- a/src/agents/sessions-spawn-hooks.test.ts +++ b/src/agents/sessions-spawn-hooks.test.ts @@ -1,10 +1,13 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import "./test-helpers/fast-core-tools.js"; import { + findGatewayRequest, getCallGatewayMock, + getGatewayMethods, getSessionsSpawnTool, setSessionsSpawnConfigOverride, } from "./openclaw-tools.subagents.sessions-spawn.test-harness.js"; +import { resetSubagentRegistryForTests } from "./subagent-registry.js"; const hookRunnerMocks = vi.hoisted(() => ({ hasSubagentEndedHook: true, @@ -45,21 +48,6 @@ vi.mock("../plugins/hook-runner-global.js", () => ({ })), })); -type GatewayRequest = { method?: string; params?: Record }; - -function getGatewayRequests(): GatewayRequest[] { - const callGatewayMock = getCallGatewayMock(); - return callGatewayMock.mock.calls.map((call: [unknown]) => call[0] as GatewayRequest); -} - -function getGatewayMethods(): Array { - return getGatewayRequests().map((request) => request.method); -} - -function findGatewayRequest(method: string): GatewayRequest | undefined { - return getGatewayRequests().find((request) => request.method === method); -} - function expectSessionsDeleteWithoutAgentStart() { const methods = getGatewayMethods(); expect(methods).toContain("sessions.delete"); @@ -79,6 +67,7 @@ function mockAgentStartFailure() { describe("sessions_spawn subagent lifecycle hooks", () => { beforeEach(() => { + resetSubagentRegistryForTests(); hookRunnerMocks.hasSubagentEndedHook = true; hookRunnerMocks.runSubagentSpawning.mockClear(); hookRunnerMocks.runSubagentSpawned.mockClear(); @@ -103,6 +92,10 @@ describe("sessions_spawn subagent lifecycle hooks", () => { }); }); + afterEach(() => { + resetSubagentRegistryForTests(); + }); + it("runs subagent_spawning and emits subagent_spawned with requester metadata", async () => { const tool = await getSessionsSpawnTool({ agentSessionKey: "main", diff --git a/src/agents/skills.test.ts b/src/agents/skills.test.ts index f8dfdd083..8020c3380 100644 --- a/src/agents/skills.test.ts +++ b/src/agents/skills.test.ts @@ -380,24 +380,26 @@ describe("applySkillEnvOverrides", () => { metadata: '{"openclaw":{"requires":{"env":["OPENAI_API_KEY"]}}}', }); + const config = { + skills: { + entries: { + "snapshot-env-skill": { + env: { + OPENAI_API_KEY: "snap-secret", + }, + }, + }, + }, + }; const snapshot = buildWorkspaceSkillSnapshot(workspaceDir, { managedSkillsDir: path.join(workspaceDir, ".managed"), + config, }); withClearedEnv(["OPENAI_API_KEY"], () => { const restore = applySkillEnvOverridesFromSnapshot({ snapshot, - config: { - skills: { - entries: { - "snapshot-env-skill": { - env: { - OPENAI_API_KEY: "snap-secret", - }, - }, - }, - }, - }, + config, }); try { diff --git a/src/agents/subagent-announce.format.test.ts b/src/agents/subagent-announce.format.test.ts index e93c97389..a612e9fca 100644 --- a/src/agents/subagent-announce.format.test.ts +++ b/src/agents/subagent-announce.format.test.ts @@ -1430,6 +1430,7 @@ describe("subagent announce formatting", () => { requesterSessionKey: "agent:main:subagent:orchestrator", requesterDisplayKey: "agent:main:subagent:orchestrator", ...defaultOutcomeAnnounce, + timeoutMs: 100, }); expect(didAnnounce).toBe(true); diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index 36573250e..81804eea6 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -502,7 +502,7 @@ function resolveRequesterStoreKey( cfg: ReturnType, requesterSessionKey: string, ): string { - const raw = requesterSessionKey.trim(); + const raw = (requesterSessionKey ?? "").trim(); if (!raw) { return raw; } diff --git a/src/agents/system-prompt-report.test.ts b/src/agents/system-prompt-report.test.ts index a3eb95e07..dc6c6c3eb 100644 --- a/src/agents/system-prompt-report.test.ts +++ b/src/agents/system-prompt-report.test.ts @@ -93,4 +93,23 @@ describe("buildSystemPromptReport", () => { expect(report.injectedWorkspaceFiles[0]?.injectedChars).toBe(0); expect(report.injectedWorkspaceFiles[0]?.truncated).toBe(true); }); + + it("ignores malformed injected file paths and still matches valid entries", () => { + const file = makeBootstrapFile({ path: "/tmp/workspace/policies/AGENTS.md" }); + const report = buildSystemPromptReport({ + source: "run", + generatedAt: 0, + bootstrapMaxChars: 20_000, + systemPrompt: "system", + bootstrapFiles: [file], + injectedFiles: [ + { path: 123 as unknown as string, content: "bad" }, + { path: "/tmp/workspace/policies/AGENTS.md", content: "trimmed" }, + ], + skillsPrompt: "", + tools: [], + }); + + expect(report.injectedWorkspaceFiles[0]?.injectedChars).toBe("trimmed".length); + }); }); diff --git a/src/agents/system-prompt-report.ts b/src/agents/system-prompt-report.ts index 71d77f471..6461e34af 100644 --- a/src/agents/system-prompt-report.ts +++ b/src/agents/system-prompt-report.ts @@ -40,26 +40,34 @@ function buildInjectedWorkspaceFiles(params: { bootstrapFiles: WorkspaceBootstrapFile[]; injectedFiles: EmbeddedContextFile[]; }): SessionSystemPromptReport["injectedWorkspaceFiles"] { - const injectedByPath = new Map(params.injectedFiles.map((f) => [f.path, f.content])); + const injectedByPath = new Map(); const injectedByBaseName = new Map(); for (const file of params.injectedFiles) { - const normalizedPath = file.path.replace(/\\/g, "/"); + const pathValue = typeof file.path === "string" ? file.path.trim() : ""; + if (!pathValue) { + continue; + } + if (!injectedByPath.has(pathValue)) { + injectedByPath.set(pathValue, file.content); + } + const normalizedPath = pathValue.replace(/\\/g, "/"); const baseName = path.posix.basename(normalizedPath); if (!injectedByBaseName.has(baseName)) { injectedByBaseName.set(baseName, file.content); } } return params.bootstrapFiles.map((file) => { + const pathValue = typeof file.path === "string" ? file.path.trim() : ""; const rawChars = file.missing ? 0 : (file.content ?? "").trimEnd().length; const injected = - injectedByPath.get(file.path) ?? + (pathValue ? injectedByPath.get(pathValue) : undefined) ?? injectedByPath.get(file.name) ?? injectedByBaseName.get(file.name); const injectedChars = injected ? injected.length : 0; const truncated = !file.missing && injectedChars < rawChars; return { name: file.name, - path: file.path, + path: pathValue || file.name, missing: file.missing, rawChars, injectedChars, diff --git a/src/agents/transcript-policy.test.ts b/src/agents/transcript-policy.test.ts index 56c1230b6..1da438561 100644 --- a/src/agents/transcript-policy.test.ts +++ b/src/agents/transcript-policy.test.ts @@ -19,6 +19,10 @@ describe("resolveTranscriptPolicy", () => { modelApi: "google-generative-ai", }); expect(policy.sanitizeToolCallIds).toBe(true); + expect(policy.sanitizeThoughtSignatures).toEqual({ + allowBase64Only: true, + includeCamelCase: true, + }); }); it("enables sanitizeToolCallIds for Mistral provider", () => { diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index 20c58a1f8..0458c3d1a 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -110,9 +110,8 @@ export function resolveTranscriptPolicy(params: { ? "strict" : undefined; const repairToolUseResultPairing = isGoogle || isAnthropic; - const sanitizeThoughtSignatures = isOpenRouterGemini - ? { allowBase64Only: true, includeCamelCase: true } - : undefined; + const sanitizeThoughtSignatures = + isOpenRouterGemini || isGoogle ? { allowBase64Only: true, includeCamelCase: true } : undefined; const sanitizeThinkingSignatures = isAntigravityClaudeModel; return { diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 4fe94914f..b00dcd969 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -8,6 +8,7 @@ import { hasNonzeroUsage } from "../../agents/usage.js"; import { resolveAgentIdFromSessionKey, resolveSessionFilePath, + resolveSessionFilePathOptions, resolveSessionTranscriptPath, type SessionEntry, updateSessionStore, @@ -324,7 +325,11 @@ export async function runReplyAgent(params: { defaultRuntime.error(buildLogMessage(nextSessionId)); if (cleanupTranscripts && prevSessionId) { const transcriptCandidates = new Set(); - const resolved = resolveSessionFilePath(prevSessionId, prevEntry, { agentId }); + const resolved = resolveSessionFilePath( + prevSessionId, + prevEntry, + resolveSessionFilePathOptions({ agentId, storePath }), + ); if (resolved) { transcriptCandidates.add(resolved); } diff --git a/src/auto-reply/reply/commands-export-session.ts b/src/auto-reply/reply/commands-export-session.ts index 10d039741..5b560e4f2 100644 --- a/src/auto-reply/reply/commands-export-session.ts +++ b/src/auto-reply/reply/commands-export-session.ts @@ -6,6 +6,7 @@ import { SessionManager } from "@mariozechner/pi-coding-agent"; import { resolveDefaultSessionStorePath, resolveSessionFilePath, + resolveSessionFilePathOptions, } from "../../config/sessions/paths.js"; import { loadSessionStore } from "../../config/sessions/store.js"; import type { SessionEntry } from "../../config/sessions/types.js"; @@ -126,10 +127,11 @@ export async function buildExportSessionReply(params: HandleCommandsParams): Pro let sessionFile: string; try { - sessionFile = resolveSessionFilePath(entry.sessionId, entry, { - agentId: params.agentId, - sessionsDir: path.dirname(storePath), - }); + sessionFile = resolveSessionFilePath( + entry.sessionId, + entry, + resolveSessionFilePathOptions({ agentId: params.agentId, storePath }), + ); } catch (err) { return { text: `❌ Failed to resolve session file: ${err instanceof Error ? err.message : String(err)}`, diff --git a/src/auto-reply/reply/get-reply-directives-apply.ts b/src/auto-reply/reply/get-reply-directives-apply.ts index fe42a2ca9..4232171a8 100644 --- a/src/auto-reply/reply/get-reply-directives-apply.ts +++ b/src/auto-reply/reply/get-reply-directives-apply.ts @@ -102,6 +102,31 @@ export async function applyInlineDirectiveOverrides(params: { let { directives } = params; let { provider, model } = params; let { contextTokens } = params; + const directiveModelState = { + allowedModelKeys: modelState.allowedModelKeys, + allowedModelCatalog: modelState.allowedModelCatalog, + resetModelOverride: modelState.resetModelOverride, + }; + const createDirectiveHandlingBase = () => ({ + cfg, + directives, + sessionEntry, + sessionStore, + sessionKey, + storePath, + elevatedEnabled, + elevatedAllowed, + elevatedFailures, + messageProviderKey, + defaultProvider, + defaultModel, + aliasIndex, + ...directiveModelState, + provider, + model, + initialModelLabel, + formatModelSwitchEvent, + }); let directiveAck: ReplyPayload | undefined; @@ -135,26 +160,7 @@ export async function applyInlineDirectiveOverrides(params: { }); const currentThinkLevel = resolvedDefaultThinkLevel; const directiveReply = await handleDirectiveOnly({ - cfg, - directives, - sessionEntry, - sessionStore, - sessionKey, - storePath, - elevatedEnabled, - elevatedAllowed, - elevatedFailures, - messageProviderKey, - defaultProvider, - defaultModel, - aliasIndex, - allowedModelKeys: modelState.allowedModelKeys, - allowedModelCatalog: modelState.allowedModelCatalog, - resetModelOverride: modelState.resetModelOverride, - provider, - model, - initialModelLabel, - formatModelSwitchEvent, + ...createDirectiveHandlingBase(), currentThinkLevel, currentVerboseLevel, currentReasoningLevel, @@ -222,9 +228,7 @@ export async function applyInlineDirectiveOverrides(params: { defaultProvider, defaultModel, aliasIndex, - allowedModelKeys: modelState.allowedModelKeys, - allowedModelCatalog: modelState.allowedModelCatalog, - resetModelOverride: modelState.resetModelOverride, + ...directiveModelState, provider, model, initialModelLabel, @@ -232,9 +236,7 @@ export async function applyInlineDirectiveOverrides(params: { agentCfg, modelState: { resolveDefaultThinkingLevel: modelState.resolveDefaultThinkingLevel, - allowedModelKeys: modelState.allowedModelKeys, - allowedModelCatalog: modelState.allowedModelCatalog, - resetModelOverride: modelState.resetModelOverride, + ...directiveModelState, }, }); directiveAck = fastLane.directiveAck; diff --git a/src/channels/plugins/onboarding/channel-access.test.ts b/src/channels/plugins/onboarding/channel-access.test.ts new file mode 100644 index 000000000..0e5b2ba66 --- /dev/null +++ b/src/channels/plugins/onboarding/channel-access.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from "vitest"; +import { + formatAllowlistEntries, + parseAllowlistEntries, + promptChannelAccessConfig, + promptChannelAllowlist, + promptChannelAccessPolicy, +} from "./channel-access.js"; + +function createPrompter(params?: { + confirm?: (options: { message: string; initialValue: boolean }) => Promise; + select?: (options: { + message: string; + options: Array<{ value: string; label: string }>; + initialValue?: string; + }) => Promise; + text?: (options: { + message: string; + placeholder?: string; + initialValue?: string; + }) => Promise; +}) { + return { + confirm: vi.fn(params?.confirm ?? (async () => true)), + select: vi.fn(params?.select ?? (async () => "allowlist")), + text: vi.fn(params?.text ?? (async () => "")), + }; +} + +describe("parseAllowlistEntries", () => { + it("splits comma/newline/semicolon-separated entries", () => { + expect(parseAllowlistEntries("alpha, beta\n gamma;delta")).toEqual([ + "alpha", + "beta", + "gamma", + "delta", + ]); + }); +}); + +describe("formatAllowlistEntries", () => { + it("formats compact comma-separated output", () => { + expect(formatAllowlistEntries([" alpha ", "", "beta"])).toBe("alpha, beta"); + }); +}); + +describe("promptChannelAllowlist", () => { + it("uses existing entries as initial value", async () => { + const prompter = createPrompter({ + text: async () => "one,two", + }); + + const result = await promptChannelAllowlist({ + // oxlint-disable-next-line typescript/no-explicit-any + prompter: prompter as any, + label: "Test", + currentEntries: ["alpha", "beta"], + }); + + expect(result).toEqual(["one", "two"]); + expect(prompter.text).toHaveBeenCalledWith( + expect.objectContaining({ + initialValue: "alpha, beta", + }), + ); + }); +}); + +describe("promptChannelAccessPolicy", () => { + it("returns selected policy", async () => { + const prompter = createPrompter({ + select: async () => "open", + }); + + const result = await promptChannelAccessPolicy({ + // oxlint-disable-next-line typescript/no-explicit-any + prompter: prompter as any, + label: "Discord", + currentPolicy: "allowlist", + }); + + expect(result).toBe("open"); + }); +}); + +describe("promptChannelAccessConfig", () => { + it("returns null when user skips configuration", async () => { + const prompter = createPrompter({ + confirm: async () => false, + }); + + const result = await promptChannelAccessConfig({ + // oxlint-disable-next-line typescript/no-explicit-any + prompter: prompter as any, + label: "Slack", + }); + + expect(result).toBeNull(); + }); + + it("returns allowlist entries when policy is allowlist", async () => { + const prompter = createPrompter({ + confirm: async () => true, + select: async () => "allowlist", + text: async () => "c1, c2", + }); + + const result = await promptChannelAccessConfig({ + // oxlint-disable-next-line typescript/no-explicit-any + prompter: prompter as any, + label: "Slack", + }); + + expect(result).toEqual({ + policy: "allowlist", + entries: ["c1", "c2"], + }); + }); + + it("returns non-allowlist policy with empty entries", async () => { + const prompter = createPrompter({ + confirm: async () => true, + select: async () => "open", + }); + + const result = await promptChannelAccessConfig({ + // oxlint-disable-next-line typescript/no-explicit-any + prompter: prompter as any, + label: "Slack", + allowDisabled: true, + }); + + expect(result).toEqual({ + policy: "open", + entries: [], + }); + }); +}); diff --git a/src/channels/plugins/onboarding/channel-access.ts b/src/channels/plugins/onboarding/channel-access.ts index 58e282266..ef86b37f3 100644 --- a/src/channels/plugins/onboarding/channel-access.ts +++ b/src/channels/plugins/onboarding/channel-access.ts @@ -1,12 +1,10 @@ import type { WizardPrompter } from "../../../wizard/prompts.js"; +import { splitOnboardingEntries } from "./helpers.js"; export type ChannelAccessPolicy = "allowlist" | "open" | "disabled"; export function parseAllowlistEntries(raw: string): string[] { - return String(raw ?? "") - .split(/[,\n]/g) - .map((entry) => entry.trim()) - .filter(Boolean); + return splitOnboardingEntries(String(raw ?? "")); } export function formatAllowlistEntries(entries: string[]): string { diff --git a/src/channels/plugins/onboarding/discord.ts b/src/channels/plugins/onboarding/discord.ts index 45410ee4e..9009f528e 100644 --- a/src/channels/plugins/onboarding/discord.ts +++ b/src/channels/plugins/onboarding/discord.ts @@ -12,12 +12,18 @@ import { type DiscordChannelResolution, } from "../../../discord/resolve-channels.js"; import { resolveDiscordUserAllowlist } from "../../../discord/resolve-users.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; import { formatDocsLink } from "../../../terminal/links.js"; import type { WizardPrompter } from "../../../wizard/prompts.js"; import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js"; import { promptChannelAccessConfig } from "./channel-access.js"; -import { addWildcardAllowFrom, promptAccountId, promptResolvedAllowFrom } from "./helpers.js"; +import { + addWildcardAllowFrom, + promptResolvedAllowFrom, + resolveAccountIdForConfigure, + resolveOnboardingAccountId, + splitOnboardingEntries, +} from "./helpers.js"; const channel = "discord" as const; @@ -145,22 +151,15 @@ function setDiscordAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClaw }; } -function parseDiscordAllowFromInput(raw: string): string[] { - return raw - .split(/[\n,;]+/g) - .map((entry) => entry.trim()) - .filter(Boolean); -} - async function promptDiscordAllowFrom(params: { cfg: OpenClawConfig; prompter: WizardPrompter; accountId?: string; }): Promise { - const accountId = - params.accountId && normalizeAccountId(params.accountId) - ? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID) - : resolveDefaultDiscordAccountId(params.cfg); + const accountId = resolveOnboardingAccountId({ + accountId: params.accountId, + defaultAccountId: resolveDefaultDiscordAccountId(params.cfg), + }); const resolved = resolveDiscordAccount({ cfg: params.cfg, accountId }); const token = resolved.token; const existing = @@ -178,7 +177,7 @@ async function promptDiscordAllowFrom(params: { "Discord allowlist", ); - const parseInputs = (value: string) => parseDiscordAllowFromInput(value); + const parseInputs = (value: string) => splitOnboardingEntries(value); const parseId = (value: string) => { const trimmed = value.trim(); if (!trimmed) { @@ -240,21 +239,16 @@ export const discordOnboardingAdapter: ChannelOnboardingAdapter = { }; }, configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => { - const discordOverride = accountOverrides.discord?.trim(); const defaultDiscordAccountId = resolveDefaultDiscordAccountId(cfg); - let discordAccountId = discordOverride - ? normalizeAccountId(discordOverride) - : defaultDiscordAccountId; - if (shouldPromptAccountIds && !discordOverride) { - discordAccountId = await promptAccountId({ - cfg, - prompter, - label: "Discord", - currentId: discordAccountId, - listAccountIds: listDiscordAccountIds, - defaultAccountId: defaultDiscordAccountId, - }); - } + const discordAccountId = await resolveAccountIdForConfigure({ + cfg, + prompter, + label: "Discord", + accountOverride: accountOverrides.discord, + shouldPromptAccountIds, + listAccountIds: listDiscordAccountIds, + defaultAccountId: defaultDiscordAccountId, + }); let next = cfg; const resolvedAccount = resolveDiscordAccount({ diff --git a/src/channels/plugins/onboarding/helpers.test.ts b/src/channels/plugins/onboarding/helpers.test.ts index 14f593f3c..2ff9b2967 100644 --- a/src/channels/plugins/onboarding/helpers.test.ts +++ b/src/channels/plugins/onboarding/helpers.test.ts @@ -1,5 +1,21 @@ -import { describe, expect, it, vi } from "vitest"; -import { promptResolvedAllowFrom } from "./helpers.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../../config/config.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; + +const promptAccountIdSdkMock = vi.hoisted(() => vi.fn(async () => "default")); +vi.mock("../../../plugin-sdk/onboarding.js", () => ({ + promptAccountId: promptAccountIdSdkMock, +})); + +import { + normalizeAllowFromEntries, + promptResolvedAllowFrom, + resolveAccountIdForConfigure, + resolveOnboardingAccountId, + setAccountAllowFromForChannel, + setChannelDmPolicyWithAllowFrom, + splitOnboardingEntries, +} from "./helpers.js"; function createPrompter(inputs: string[]) { return { @@ -9,6 +25,11 @@ function createPrompter(inputs: string[]) { } describe("promptResolvedAllowFrom", () => { + beforeEach(() => { + promptAccountIdSdkMock.mockReset(); + promptAccountIdSdkMock.mockResolvedValue("default"); + }); + it("re-prompts without token until all ids are parseable", async () => { const prompter = createPrompter(["@alice", "123"]); const resolveEntries = vi.fn(); @@ -66,4 +87,227 @@ describe("promptResolvedAllowFrom", () => { expect(prompter.note).toHaveBeenCalledWith("Could not resolve: alice", "allowlist"); expect(resolveEntries).toHaveBeenCalledTimes(2); }); + + it("re-prompts when resolver throws before succeeding", async () => { + const prompter = createPrompter(["alice", "bob"]); + const resolveEntries = vi + .fn() + .mockRejectedValueOnce(new Error("network")) + .mockResolvedValueOnce([{ input: "bob", resolved: true, id: "U234" }]); + + const result = await promptResolvedAllowFrom({ + // oxlint-disable-next-line typescript/no-explicit-any + prompter: prompter as any, + existing: [], + token: "xoxb-test", + message: "msg", + placeholder: "placeholder", + label: "allowlist", + parseInputs: (value) => + value + .split(",") + .map((part) => part.trim()) + .filter(Boolean), + parseId: () => null, + invalidWithoutTokenNote: "ids only", + resolveEntries, + }); + + expect(result).toEqual(["U234"]); + expect(prompter.note).toHaveBeenCalledWith( + "Failed to resolve usernames. Try again.", + "allowlist", + ); + expect(resolveEntries).toHaveBeenCalledTimes(2); + }); +}); + +describe("setAccountAllowFromForChannel", () => { + it("writes allowFrom on default account channel config", () => { + const cfg: OpenClawConfig = { + channels: { + imessage: { + enabled: true, + allowFrom: ["old"], + accounts: { + work: { allowFrom: ["work-old"] }, + }, + }, + }, + }; + + const next = setAccountAllowFromForChannel({ + cfg, + channel: "imessage", + accountId: DEFAULT_ACCOUNT_ID, + allowFrom: ["new-default"], + }); + + expect(next.channels?.imessage?.allowFrom).toEqual(["new-default"]); + expect(next.channels?.imessage?.accounts?.work?.allowFrom).toEqual(["work-old"]); + }); + + it("writes allowFrom on nested non-default account config", () => { + const cfg: OpenClawConfig = { + channels: { + signal: { + enabled: true, + allowFrom: ["default-old"], + accounts: { + alt: { enabled: true, account: "+15555550123", allowFrom: ["alt-old"] }, + }, + }, + }, + }; + + const next = setAccountAllowFromForChannel({ + cfg, + channel: "signal", + accountId: "alt", + allowFrom: ["alt-new"], + }); + + expect(next.channels?.signal?.allowFrom).toEqual(["default-old"]); + expect(next.channels?.signal?.accounts?.alt?.allowFrom).toEqual(["alt-new"]); + expect(next.channels?.signal?.accounts?.alt?.account).toBe("+15555550123"); + }); +}); + +describe("setChannelDmPolicyWithAllowFrom", () => { + it("adds wildcard allowFrom when setting dmPolicy=open", () => { + const cfg: OpenClawConfig = { + channels: { + signal: { + dmPolicy: "pairing", + allowFrom: ["+15555550123"], + }, + }, + }; + + const next = setChannelDmPolicyWithAllowFrom({ + cfg, + channel: "signal", + dmPolicy: "open", + }); + + expect(next.channels?.signal?.dmPolicy).toBe("open"); + expect(next.channels?.signal?.allowFrom).toEqual(["+15555550123", "*"]); + }); + + it("sets dmPolicy without changing allowFrom for non-open policies", () => { + const cfg: OpenClawConfig = { + channels: { + imessage: { + dmPolicy: "open", + allowFrom: ["*"], + }, + }, + }; + + const next = setChannelDmPolicyWithAllowFrom({ + cfg, + channel: "imessage", + dmPolicy: "pairing", + }); + + expect(next.channels?.imessage?.dmPolicy).toBe("pairing"); + expect(next.channels?.imessage?.allowFrom).toEqual(["*"]); + }); +}); + +describe("splitOnboardingEntries", () => { + it("splits comma/newline/semicolon input and trims blanks", () => { + expect(splitOnboardingEntries(" alice, bob \ncarol; ;\n")).toEqual(["alice", "bob", "carol"]); + }); +}); + +describe("normalizeAllowFromEntries", () => { + it("normalizes values, preserves wildcard, and removes duplicates", () => { + expect( + normalizeAllowFromEntries([" +15555550123 ", "*", "+15555550123", "bad"], (value) => + value.startsWith("+1") ? value : null, + ), + ).toEqual(["+15555550123", "*"]); + }); + + it("trims and de-duplicates without a normalizer", () => { + expect(normalizeAllowFromEntries([" alice ", "bob", "alice"])).toEqual(["alice", "bob"]); + }); +}); + +describe("resolveOnboardingAccountId", () => { + it("normalizes provided account ids", () => { + expect( + resolveOnboardingAccountId({ + accountId: " Work Account ", + defaultAccountId: DEFAULT_ACCOUNT_ID, + }), + ).toBe("work-account"); + }); + + it("falls back to default account id when input is blank", () => { + expect( + resolveOnboardingAccountId({ + accountId: " ", + defaultAccountId: "custom-default", + }), + ).toBe("custom-default"); + }); +}); + +describe("resolveAccountIdForConfigure", () => { + beforeEach(() => { + promptAccountIdSdkMock.mockReset(); + promptAccountIdSdkMock.mockResolvedValue("default"); + }); + + it("uses normalized override without prompting", async () => { + const accountId = await resolveAccountIdForConfigure({ + cfg: {}, + // oxlint-disable-next-line typescript/no-explicit-any + prompter: {} as any, + label: "Signal", + accountOverride: " Team Primary ", + shouldPromptAccountIds: true, + listAccountIds: () => ["default", "team-primary"], + defaultAccountId: DEFAULT_ACCOUNT_ID, + }); + expect(accountId).toBe("team-primary"); + }); + + it("uses default account when override is missing and prompting disabled", async () => { + const accountId = await resolveAccountIdForConfigure({ + cfg: {}, + // oxlint-disable-next-line typescript/no-explicit-any + prompter: {} as any, + label: "Signal", + shouldPromptAccountIds: false, + listAccountIds: () => ["default"], + defaultAccountId: "fallback", + }); + expect(accountId).toBe("fallback"); + }); + + it("prompts for account id when prompting is enabled and no override is provided", async () => { + promptAccountIdSdkMock.mockResolvedValueOnce("prompted-id"); + + const accountId = await resolveAccountIdForConfigure({ + cfg: {}, + // oxlint-disable-next-line typescript/no-explicit-any + prompter: {} as any, + label: "Signal", + shouldPromptAccountIds: true, + listAccountIds: () => ["default", "prompted-id"], + defaultAccountId: "fallback", + }); + + expect(accountId).toBe("prompted-id"); + expect(promptAccountIdSdkMock).toHaveBeenCalledWith( + expect.objectContaining({ + label: "Signal", + currentId: "fallback", + defaultAccountId: "fallback", + }), + ); + }); }); diff --git a/src/channels/plugins/onboarding/helpers.ts b/src/channels/plugins/onboarding/helpers.ts index f31f0768f..7b40c49c0 100644 --- a/src/channels/plugins/onboarding/helpers.ts +++ b/src/channels/plugins/onboarding/helpers.ts @@ -1,4 +1,7 @@ +import type { OpenClawConfig } from "../../../config/config.js"; +import type { DmPolicy } from "../../../config/types.js"; import { promptAccountId as promptAccountIdSdk } from "../../../plugin-sdk/onboarding.js"; +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"; @@ -22,6 +25,123 @@ export function mergeAllowFromEntries( return [...new Set(merged)]; } +export function splitOnboardingEntries(raw: string): string[] { + return raw + .split(/[\n,;]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +export function normalizeAllowFromEntries( + entries: Array, + normalizeEntry?: (value: string) => string | null | undefined, +): string[] { + const normalized = entries + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => { + if (entry === "*") { + return "*"; + } + if (!normalizeEntry) { + return entry; + } + const value = normalizeEntry(entry); + return typeof value === "string" ? value.trim() : ""; + }) + .filter(Boolean); + return [...new Set(normalized)]; +} + +export function resolveOnboardingAccountId(params: { + accountId?: string; + defaultAccountId: string; +}): string { + return params.accountId?.trim() ? normalizeAccountId(params.accountId) : params.defaultAccountId; +} + +export async function resolveAccountIdForConfigure(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + label: string; + accountOverride?: string; + shouldPromptAccountIds: boolean; + listAccountIds: (cfg: OpenClawConfig) => string[]; + defaultAccountId: string; +}): Promise { + const override = params.accountOverride?.trim(); + let accountId = override ? normalizeAccountId(override) : params.defaultAccountId; + if (params.shouldPromptAccountIds && !override) { + accountId = await promptAccountId({ + cfg: params.cfg, + prompter: params.prompter, + label: params.label, + currentId: accountId, + listAccountIds: params.listAccountIds, + defaultAccountId: params.defaultAccountId, + }); + } + return accountId; +} + +export function setAccountAllowFromForChannel(params: { + cfg: OpenClawConfig; + channel: "imessage" | "signal"; + accountId: string; + allowFrom: string[]; +}): OpenClawConfig { + const { cfg, channel, accountId, allowFrom } = params; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...cfg, + channels: { + ...cfg.channels, + [channel]: { + ...cfg.channels?.[channel], + allowFrom, + }, + }, + }; + } + return { + ...cfg, + channels: { + ...cfg.channels, + [channel]: { + ...cfg.channels?.[channel], + accounts: { + ...cfg.channels?.[channel]?.accounts, + [accountId]: { + ...cfg.channels?.[channel]?.accounts?.[accountId], + allowFrom, + }, + }, + }, + }, + }; +} + +export function setChannelDmPolicyWithAllowFrom(params: { + cfg: OpenClawConfig; + channel: "imessage" | "signal"; + dmPolicy: DmPolicy; +}): OpenClawConfig { + const { cfg, channel, dmPolicy } = params; + const allowFrom = + dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.[channel]?.allowFrom) : undefined; + return { + ...cfg, + channels: { + ...cfg.channels, + [channel]: { + ...cfg.channels?.[channel], + dmPolicy, + ...(allowFrom ? { allowFrom } : {}), + }, + }, + }; +} + type AllowFromResolution = { input: string; resolved: boolean; diff --git a/src/channels/plugins/onboarding/imessage.ts b/src/channels/plugins/onboarding/imessage.ts index c5cdeb836..20c433ec4 100644 --- a/src/channels/plugins/onboarding/imessage.ts +++ b/src/channels/plugins/onboarding/imessage.ts @@ -7,70 +7,27 @@ import { resolveIMessageAccount, } from "../../../imessage/accounts.js"; import { normalizeIMessageHandle } from "../../../imessage/targets.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; import { formatDocsLink } from "../../../terminal/links.js"; import type { WizardPrompter } from "../../../wizard/prompts.js"; import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js"; -import { addWildcardAllowFrom, mergeAllowFromEntries, promptAccountId } from "./helpers.js"; +import { + mergeAllowFromEntries, + resolveAccountIdForConfigure, + resolveOnboardingAccountId, + setAccountAllowFromForChannel, + setChannelDmPolicyWithAllowFrom, + splitOnboardingEntries, +} from "./helpers.js"; const channel = "imessage" as const; function setIMessageDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) { - const allowFrom = - dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.imessage?.allowFrom) : undefined; - return { - ...cfg, - channels: { - ...cfg.channels, - imessage: { - ...cfg.channels?.imessage, - dmPolicy, - ...(allowFrom ? { allowFrom } : {}), - }, - }, - }; -} - -function setIMessageAllowFrom( - cfg: OpenClawConfig, - accountId: string, - allowFrom: string[], -): OpenClawConfig { - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - channels: { - ...cfg.channels, - imessage: { - ...cfg.channels?.imessage, - allowFrom, - }, - }, - }; - } - return { - ...cfg, - channels: { - ...cfg.channels, - imessage: { - ...cfg.channels?.imessage, - accounts: { - ...cfg.channels?.imessage?.accounts, - [accountId]: { - ...cfg.channels?.imessage?.accounts?.[accountId], - allowFrom, - }, - }, - }, - }, - }; -} - -function parseIMessageAllowFromInput(raw: string): string[] { - return raw - .split(/[\n,;]+/g) - .map((entry) => entry.trim()) - .filter(Boolean); + return setChannelDmPolicyWithAllowFrom({ + cfg, + channel: "imessage", + dmPolicy, + }); } async function promptIMessageAllowFrom(params: { @@ -78,10 +35,10 @@ async function promptIMessageAllowFrom(params: { prompter: WizardPrompter; accountId?: string; }): Promise { - const accountId = - params.accountId && normalizeAccountId(params.accountId) - ? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID) - : resolveDefaultIMessageAccountId(params.cfg); + const accountId = resolveOnboardingAccountId({ + accountId: params.accountId, + defaultAccountId: resolveDefaultIMessageAccountId(params.cfg), + }); const resolved = resolveIMessageAccount({ cfg: params.cfg, accountId }); const existing = resolved.config.allowFrom ?? []; await params.prompter.note( @@ -106,7 +63,7 @@ async function promptIMessageAllowFrom(params: { if (!raw) { return "Required"; } - const parts = parseIMessageAllowFromInput(raw); + const parts = splitOnboardingEntries(raw); for (const part of parts) { if (part === "*") { continue; @@ -137,9 +94,14 @@ async function promptIMessageAllowFrom(params: { return undefined; }, }); - const parts = parseIMessageAllowFromInput(String(entry)); + const parts = splitOnboardingEntries(String(entry)); const unique = mergeAllowFromEntries(undefined, parts); - return setIMessageAllowFrom(params.cfg, accountId, unique); + return setAccountAllowFromForChannel({ + cfg: params.cfg, + channel: "imessage", + accountId, + allowFrom: unique, + }); } const dmPolicy: ChannelOnboardingDmPolicy = { @@ -179,21 +141,16 @@ export const imessageOnboardingAdapter: ChannelOnboardingAdapter = { }; }, configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => { - const imessageOverride = accountOverrides.imessage?.trim(); const defaultIMessageAccountId = resolveDefaultIMessageAccountId(cfg); - let imessageAccountId = imessageOverride - ? normalizeAccountId(imessageOverride) - : defaultIMessageAccountId; - if (shouldPromptAccountIds && !imessageOverride) { - imessageAccountId = await promptAccountId({ - cfg, - prompter, - label: "iMessage", - currentId: imessageAccountId, - listAccountIds: listIMessageAccountIds, - defaultAccountId: defaultIMessageAccountId, - }); - } + const imessageAccountId = await resolveAccountIdForConfigure({ + cfg, + prompter, + label: "iMessage", + accountOverride: accountOverrides.imessage, + shouldPromptAccountIds, + listAccountIds: listIMessageAccountIds, + defaultAccountId: defaultIMessageAccountId, + }); let next = cfg; const resolvedAccount = resolveIMessageAccount({ diff --git a/src/channels/plugins/onboarding/signal.ts b/src/channels/plugins/onboarding/signal.ts index 98b9e6910..4df479d86 100644 --- a/src/channels/plugins/onboarding/signal.ts +++ b/src/channels/plugins/onboarding/signal.ts @@ -3,7 +3,7 @@ import { detectBinary } from "../../../commands/onboard-helpers.js"; import { installSignalCli } from "../../../commands/signal-install.js"; import type { OpenClawConfig } from "../../../config/config.js"; import type { DmPolicy } from "../../../config/types.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; import { listSignalAccountIds, resolveDefaultSignalAccountId, @@ -13,7 +13,14 @@ import { formatDocsLink } from "../../../terminal/links.js"; import { normalizeE164 } from "../../../utils.js"; import type { WizardPrompter } from "../../../wizard/prompts.js"; import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js"; -import { addWildcardAllowFrom, mergeAllowFromEntries, promptAccountId } from "./helpers.js"; +import { + mergeAllowFromEntries, + resolveAccountIdForConfigure, + resolveOnboardingAccountId, + setAccountAllowFromForChannel, + setChannelDmPolicyWithAllowFrom, + splitOnboardingEntries, +} from "./helpers.js"; const channel = "signal" as const; const MIN_E164_DIGITS = 5; @@ -39,61 +46,11 @@ export function normalizeSignalAccountInput(value: string | null | undefined): s } function setSignalDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) { - const allowFrom = - dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.signal?.allowFrom) : undefined; - return { - ...cfg, - channels: { - ...cfg.channels, - signal: { - ...cfg.channels?.signal, - dmPolicy, - ...(allowFrom ? { allowFrom } : {}), - }, - }, - }; -} - -function setSignalAllowFrom( - cfg: OpenClawConfig, - accountId: string, - allowFrom: string[], -): OpenClawConfig { - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - channels: { - ...cfg.channels, - signal: { - ...cfg.channels?.signal, - allowFrom, - }, - }, - }; - } - return { - ...cfg, - channels: { - ...cfg.channels, - signal: { - ...cfg.channels?.signal, - accounts: { - ...cfg.channels?.signal?.accounts, - [accountId]: { - ...cfg.channels?.signal?.accounts?.[accountId], - allowFrom, - }, - }, - }, - }, - }; -} - -function parseSignalAllowFromInput(raw: string): string[] { - return raw - .split(/[\n,;]+/g) - .map((entry) => entry.trim()) - .filter(Boolean); + return setChannelDmPolicyWithAllowFrom({ + cfg, + channel: "signal", + dmPolicy, + }); } function isUuidLike(value: string): boolean { @@ -105,10 +62,10 @@ async function promptSignalAllowFrom(params: { prompter: WizardPrompter; accountId?: string; }): Promise { - const accountId = - params.accountId && normalizeAccountId(params.accountId) - ? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID) - : resolveDefaultSignalAccountId(params.cfg); + const accountId = resolveOnboardingAccountId({ + accountId: params.accountId, + defaultAccountId: resolveDefaultSignalAccountId(params.cfg), + }); const resolved = resolveSignalAccount({ cfg: params.cfg, accountId }); const existing = resolved.config.allowFrom ?? []; await params.prompter.note( @@ -131,7 +88,7 @@ async function promptSignalAllowFrom(params: { if (!raw) { return "Required"; } - const parts = parseSignalAllowFromInput(raw); + const parts = splitOnboardingEntries(raw); for (const part of parts) { if (part === "*") { continue; @@ -152,7 +109,7 @@ async function promptSignalAllowFrom(params: { return undefined; }, }); - const parts = parseSignalAllowFromInput(String(entry)); + const parts = splitOnboardingEntries(String(entry)); const normalized = parts.map((part) => { if (part === "*") { return "*"; @@ -169,7 +126,12 @@ async function promptSignalAllowFrom(params: { undefined, normalized.filter((part): part is string => typeof part === "string" && part.trim().length > 0), ); - return setSignalAllowFrom(params.cfg, accountId, unique); + return setAccountAllowFromForChannel({ + cfg: params.cfg, + channel: "signal", + accountId, + allowFrom: unique, + }); } const dmPolicy: ChannelOnboardingDmPolicy = { @@ -209,21 +171,16 @@ export const signalOnboardingAdapter: ChannelOnboardingAdapter = { shouldPromptAccountIds, options, }) => { - const signalOverride = accountOverrides.signal?.trim(); const defaultSignalAccountId = resolveDefaultSignalAccountId(cfg); - let signalAccountId = signalOverride - ? normalizeAccountId(signalOverride) - : defaultSignalAccountId; - if (shouldPromptAccountIds && !signalOverride) { - signalAccountId = await promptAccountId({ - cfg, - prompter, - label: "Signal", - currentId: signalAccountId, - listAccountIds: listSignalAccountIds, - defaultAccountId: defaultSignalAccountId, - }); - } + const signalAccountId = await resolveAccountIdForConfigure({ + cfg, + prompter, + label: "Signal", + accountOverride: accountOverrides.signal, + shouldPromptAccountIds, + listAccountIds: listSignalAccountIds, + defaultAccountId: defaultSignalAccountId, + }); let next = cfg; const resolvedAccount = resolveSignalAccount({ diff --git a/src/channels/plugins/onboarding/slack.ts b/src/channels/plugins/onboarding/slack.ts index 81cbdff76..3937ce298 100644 --- a/src/channels/plugins/onboarding/slack.ts +++ b/src/channels/plugins/onboarding/slack.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "../../../config/config.js"; import type { DmPolicy } from "../../../config/types.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; import { listSlackAccountIds, resolveDefaultSlackAccountId, @@ -12,21 +12,27 @@ import { formatDocsLink } from "../../../terminal/links.js"; import type { WizardPrompter } from "../../../wizard/prompts.js"; import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js"; import { promptChannelAccessConfig } from "./channel-access.js"; -import { addWildcardAllowFrom, promptAccountId, promptResolvedAllowFrom } from "./helpers.js"; +import { + addWildcardAllowFrom, + promptResolvedAllowFrom, + resolveAccountIdForConfigure, + resolveOnboardingAccountId, + splitOnboardingEntries, +} from "./helpers.js"; const channel = "slack" as const; -function setSlackDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) { - const existingAllowFrom = cfg.channels?.slack?.allowFrom ?? cfg.channels?.slack?.dm?.allowFrom; - const allowFrom = dmPolicy === "open" ? addWildcardAllowFrom(existingAllowFrom) : undefined; +function patchSlackConfigWithDm( + cfg: OpenClawConfig, + patch: Record, +): OpenClawConfig { return { ...cfg, channels: { ...cfg.channels, slack: { ...cfg.channels?.slack, - dmPolicy, - ...(allowFrom ? { allowFrom } : {}), + ...patch, dm: { ...cfg.channels?.slack?.dm, enabled: cfg.channels?.slack?.dm?.enabled ?? true, @@ -36,6 +42,15 @@ function setSlackDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) { }; } +function setSlackDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) { + const existingAllowFrom = cfg.channels?.slack?.allowFrom ?? cfg.channels?.slack?.dm?.allowFrom; + const allowFrom = dmPolicy === "open" ? addWildcardAllowFrom(existingAllowFrom) : undefined; + return patchSlackConfigWithDm(cfg, { + dmPolicy, + ...(allowFrom ? { allowFrom } : {}), + }); +} + function buildSlackManifest(botName: string) { const safeName = botName.trim() || "OpenClaw"; const manifest = { @@ -199,27 +214,7 @@ function setSlackChannelAllowlist( } function setSlackAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig { - return { - ...cfg, - channels: { - ...cfg.channels, - slack: { - ...cfg.channels?.slack, - allowFrom, - dm: { - ...cfg.channels?.slack?.dm, - enabled: cfg.channels?.slack?.dm?.enabled ?? true, - }, - }, - }, - }; -} - -function parseSlackAllowFromInput(raw: string): string[] { - return raw - .split(/[\n,;]+/g) - .map((entry) => entry.trim()) - .filter(Boolean); + return patchSlackConfigWithDm(cfg, { allowFrom }); } async function promptSlackAllowFrom(params: { @@ -227,10 +222,10 @@ async function promptSlackAllowFrom(params: { prompter: WizardPrompter; accountId?: string; }): Promise { - const accountId = - params.accountId && normalizeAccountId(params.accountId) - ? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID) - : resolveDefaultSlackAccountId(params.cfg); + const accountId = resolveOnboardingAccountId({ + accountId: params.accountId, + defaultAccountId: resolveDefaultSlackAccountId(params.cfg), + }); const resolved = resolveSlackAccount({ cfg: params.cfg, accountId }); const token = resolved.config.userToken ?? resolved.config.botToken ?? ""; const existing = @@ -246,7 +241,7 @@ async function promptSlackAllowFrom(params: { ].join("\n"), "Slack allowlist", ); - const parseInputs = (value: string) => parseSlackAllowFromInput(value); + const parseInputs = (value: string) => splitOnboardingEntries(value); const parseId = (value: string) => { const trimmed = value.trim(); if (!trimmed) { @@ -309,19 +304,16 @@ export const slackOnboardingAdapter: ChannelOnboardingAdapter = { }; }, configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => { - const slackOverride = accountOverrides.slack?.trim(); const defaultSlackAccountId = resolveDefaultSlackAccountId(cfg); - let slackAccountId = slackOverride ? normalizeAccountId(slackOverride) : defaultSlackAccountId; - if (shouldPromptAccountIds && !slackOverride) { - slackAccountId = await promptAccountId({ - cfg, - prompter, - label: "Slack", - currentId: slackAccountId, - listAccountIds: listSlackAccountIds, - defaultAccountId: defaultSlackAccountId, - }); - } + const slackAccountId = await resolveAccountIdForConfigure({ + cfg, + prompter, + label: "Slack", + accountOverride: accountOverrides.slack, + shouldPromptAccountIds, + listAccountIds: listSlackAccountIds, + defaultAccountId: defaultSlackAccountId, + }); let next = cfg; const resolvedAccount = resolveSlackAccount({ diff --git a/src/channels/plugins/onboarding/telegram.ts b/src/channels/plugins/onboarding/telegram.ts index c35140915..7efcaf914 100644 --- a/src/channels/plugins/onboarding/telegram.ts +++ b/src/channels/plugins/onboarding/telegram.ts @@ -1,7 +1,7 @@ import { formatCliCommand } from "../../../cli/command-format.js"; import type { OpenClawConfig } from "../../../config/config.js"; import type { DmPolicy } from "../../../config/types.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; import { listTelegramAccountIds, resolveDefaultTelegramAccountId, @@ -11,7 +11,13 @@ import { formatDocsLink } from "../../../terminal/links.js"; import type { WizardPrompter } from "../../../wizard/prompts.js"; import { fetchTelegramChatId } from "../../telegram/api.js"; import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js"; -import { addWildcardAllowFrom, mergeAllowFromEntries, promptAccountId } from "./helpers.js"; +import { + addWildcardAllowFrom, + mergeAllowFromEntries, + resolveAccountIdForConfigure, + resolveOnboardingAccountId, + splitOnboardingEntries, +} from "./helpers.js"; const channel = "telegram" as const; @@ -89,12 +95,6 @@ async function promptTelegramAllowFrom(params: { return await fetchTelegramChatId({ token, chatId: username }); }; - const parseInput = (value: string) => - value - .split(/[\n,;]+/g) - .map((entry) => entry.trim()) - .filter(Boolean); - let resolvedIds: string[] = []; while (resolvedIds.length === 0) { const entry = await prompter.text({ @@ -103,7 +103,7 @@ async function promptTelegramAllowFrom(params: { initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), }); - const parts = parseInput(String(entry)); + const parts = splitOnboardingEntries(String(entry)); const results = await Promise.all(parts.map((part) => resolveTelegramUserId(part))); const unresolved = parts.filter((_, idx) => !results[idx]); if (unresolved.length > 0) { @@ -159,10 +159,10 @@ async function promptTelegramAllowFromForAccount(params: { prompter: WizardPrompter; accountId?: string; }): Promise { - const accountId = - params.accountId && normalizeAccountId(params.accountId) - ? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID) - : resolveDefaultTelegramAccountId(params.cfg); + const accountId = resolveOnboardingAccountId({ + accountId: params.accountId, + defaultAccountId: resolveDefaultTelegramAccountId(params.cfg), + }); return promptTelegramAllowFrom({ cfg: params.cfg, prompter: params.prompter, @@ -201,21 +201,16 @@ export const telegramOnboardingAdapter: ChannelOnboardingAdapter = { shouldPromptAccountIds, forceAllowFrom, }) => { - const telegramOverride = accountOverrides.telegram?.trim(); const defaultTelegramAccountId = resolveDefaultTelegramAccountId(cfg); - let telegramAccountId = telegramOverride - ? normalizeAccountId(telegramOverride) - : defaultTelegramAccountId; - if (shouldPromptAccountIds && !telegramOverride) { - telegramAccountId = await promptAccountId({ - cfg, - prompter, - label: "Telegram", - currentId: telegramAccountId, - listAccountIds: listTelegramAccountIds, - defaultAccountId: defaultTelegramAccountId, - }); - } + const telegramAccountId = await resolveAccountIdForConfigure({ + cfg, + prompter, + label: "Telegram", + accountOverride: accountOverrides.telegram, + shouldPromptAccountIds, + listAccountIds: listTelegramAccountIds, + defaultAccountId: defaultTelegramAccountId, + }); let next = cfg; const resolvedAccount = resolveTelegramAccount({ diff --git a/src/channels/plugins/onboarding/whatsapp.test.ts b/src/channels/plugins/onboarding/whatsapp.test.ts new file mode 100644 index 000000000..90ba94060 --- /dev/null +++ b/src/channels/plugins/onboarding/whatsapp.test.ts @@ -0,0 +1,287 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; +import type { RuntimeEnv } from "../../../runtime.js"; +import type { WizardPrompter } from "../../../wizard/prompts.js"; +import { whatsappOnboardingAdapter } from "./whatsapp.js"; + +const loginWebMock = vi.hoisted(() => vi.fn(async () => {})); +const pathExistsMock = vi.hoisted(() => vi.fn(async () => false)); +const listWhatsAppAccountIdsMock = vi.hoisted(() => vi.fn(() => [] as string[])); +const resolveDefaultWhatsAppAccountIdMock = vi.hoisted(() => vi.fn(() => DEFAULT_ACCOUNT_ID)); +const resolveWhatsAppAuthDirMock = vi.hoisted(() => + vi.fn(() => ({ + authDir: "/tmp/openclaw-whatsapp-test", + })), +); + +vi.mock("../../../channel-web.js", () => ({ + loginWeb: loginWebMock, +})); + +vi.mock("../../../utils.js", async () => { + const actual = await vi.importActual("../../../utils.js"); + return { + ...actual, + pathExists: pathExistsMock, + }; +}); + +vi.mock("../../../web/accounts.js", () => ({ + listWhatsAppAccountIds: listWhatsAppAccountIdsMock, + resolveDefaultWhatsAppAccountId: resolveDefaultWhatsAppAccountIdMock, + resolveWhatsAppAuthDir: resolveWhatsAppAuthDirMock, +})); + +function createPrompterHarness(params?: { + selectValues?: string[]; + textValues?: string[]; + confirmValues?: boolean[]; +}) { + const selectValues = [...(params?.selectValues ?? [])]; + const textValues = [...(params?.textValues ?? [])]; + const confirmValues = [...(params?.confirmValues ?? [])]; + + const intro = vi.fn(async () => undefined); + const outro = vi.fn(async () => undefined); + const note = vi.fn(async () => undefined); + const select = vi.fn(async () => selectValues.shift() ?? ""); + const multiselect = vi.fn(async () => [] as string[]); + const text = vi.fn(async () => textValues.shift() ?? ""); + const confirm = vi.fn(async () => confirmValues.shift() ?? false); + const progress = vi.fn(() => ({ + update: vi.fn(), + stop: vi.fn(), + })); + + return { + intro, + outro, + note, + select, + multiselect, + text, + confirm, + progress, + prompter: { + intro, + outro, + note, + select, + multiselect, + text, + confirm, + progress, + } as WizardPrompter, + }; +} + +function createRuntime(): RuntimeEnv { + return { + error: vi.fn(), + } as unknown as RuntimeEnv; +} + +describe("whatsappOnboardingAdapter.configure", () => { + beforeEach(() => { + vi.clearAllMocks(); + pathExistsMock.mockResolvedValue(false); + listWhatsAppAccountIdsMock.mockReturnValue([]); + resolveDefaultWhatsAppAccountIdMock.mockReturnValue(DEFAULT_ACCOUNT_ID); + resolveWhatsAppAuthDirMock.mockReturnValue({ authDir: "/tmp/openclaw-whatsapp-test" }); + }); + + it("applies owner allowlist when forceAllowFrom is enabled", async () => { + const harness = createPrompterHarness({ + confirmValues: [false], + textValues: ["+1 (555) 555-0123"], + }); + + const result = await whatsappOnboardingAdapter.configure({ + cfg: {}, + runtime: createRuntime(), + prompter: harness.prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: true, + }); + + expect(result.accountId).toBe(DEFAULT_ACCOUNT_ID); + expect(loginWebMock).not.toHaveBeenCalled(); + expect(result.cfg.channels?.whatsapp?.selfChatMode).toBe(true); + expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("allowlist"); + expect(result.cfg.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]); + expect(harness.text).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Your personal WhatsApp number (the phone you will message from)", + }), + ); + }); + + it("supports disabled DM policy for separate-phone setup", async () => { + pathExistsMock.mockResolvedValue(true); + const harness = createPrompterHarness({ + confirmValues: [false], + selectValues: ["separate", "disabled"], + }); + + const result = await whatsappOnboardingAdapter.configure({ + cfg: {}, + runtime: createRuntime(), + prompter: harness.prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.cfg.channels?.whatsapp?.selfChatMode).toBe(false); + expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("disabled"); + expect(result.cfg.channels?.whatsapp?.allowFrom).toBeUndefined(); + expect(harness.text).not.toHaveBeenCalled(); + }); + + it("normalizes allowFrom entries when list mode is selected", async () => { + pathExistsMock.mockResolvedValue(true); + const harness = createPrompterHarness({ + confirmValues: [false], + selectValues: ["separate", "allowlist", "list"], + textValues: ["+1 (555) 555-0123, +15555550123, *"], + }); + + const result = await whatsappOnboardingAdapter.configure({ + cfg: {}, + runtime: createRuntime(), + prompter: harness.prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.cfg.channels?.whatsapp?.selfChatMode).toBe(false); + expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("allowlist"); + expect(result.cfg.channels?.whatsapp?.allowFrom).toEqual(["+15555550123", "*"]); + }); + + it("enables allowlist self-chat mode for personal-phone setup", async () => { + pathExistsMock.mockResolvedValue(true); + const harness = createPrompterHarness({ + confirmValues: [false], + selectValues: ["personal"], + textValues: ["+1 (555) 111-2222"], + }); + + const result = await whatsappOnboardingAdapter.configure({ + cfg: {}, + runtime: createRuntime(), + prompter: harness.prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.cfg.channels?.whatsapp?.selfChatMode).toBe(true); + expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("allowlist"); + expect(result.cfg.channels?.whatsapp?.allowFrom).toEqual(["+15551112222"]); + }); + + it("forces wildcard allowFrom for open policy without allowFrom follow-up prompts", async () => { + pathExistsMock.mockResolvedValue(true); + const harness = createPrompterHarness({ + confirmValues: [false], + selectValues: ["separate", "open"], + }); + + const result = await whatsappOnboardingAdapter.configure({ + cfg: { + channels: { + whatsapp: { + allowFrom: ["+15555550123"], + }, + }, + }, + runtime: createRuntime(), + prompter: harness.prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.cfg.channels?.whatsapp?.selfChatMode).toBe(false); + expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("open"); + expect(result.cfg.channels?.whatsapp?.allowFrom).toEqual(["*", "+15555550123"]); + expect(harness.select).toHaveBeenCalledTimes(2); + expect(harness.text).not.toHaveBeenCalled(); + }); + + it("runs WhatsApp login when not linked and user confirms linking", async () => { + pathExistsMock.mockResolvedValue(false); + const harness = createPrompterHarness({ + confirmValues: [true], + selectValues: ["separate", "disabled"], + }); + const runtime = createRuntime(); + + await whatsappOnboardingAdapter.configure({ + cfg: {}, + runtime, + prompter: harness.prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(loginWebMock).toHaveBeenCalledWith(false, undefined, runtime, DEFAULT_ACCOUNT_ID); + }); + + it("skips relink note when already linked and relink is declined", async () => { + pathExistsMock.mockResolvedValue(true); + const harness = createPrompterHarness({ + confirmValues: [false], + selectValues: ["separate", "disabled"], + }); + + await whatsappOnboardingAdapter.configure({ + cfg: {}, + runtime: createRuntime(), + prompter: harness.prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(loginWebMock).not.toHaveBeenCalled(); + expect(harness.note).not.toHaveBeenCalledWith( + expect.stringContaining("openclaw channels login"), + "WhatsApp", + ); + }); + + it("shows follow-up login command note when not linked and linking is skipped", async () => { + pathExistsMock.mockResolvedValue(false); + const harness = createPrompterHarness({ + confirmValues: [false], + selectValues: ["separate", "disabled"], + }); + + await whatsappOnboardingAdapter.configure({ + cfg: {}, + runtime: createRuntime(), + prompter: harness.prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(harness.note).toHaveBeenCalledWith( + expect.stringContaining("openclaw channels login"), + "WhatsApp", + ); + }); +}); diff --git a/src/channels/plugins/onboarding/whatsapp.ts b/src/channels/plugins/onboarding/whatsapp.ts index 80be2a470..4b0d9ceda 100644 --- a/src/channels/plugins/onboarding/whatsapp.ts +++ b/src/channels/plugins/onboarding/whatsapp.ts @@ -4,7 +4,7 @@ import { formatCliCommand } from "../../../cli/command-format.js"; import type { OpenClawConfig } from "../../../config/config.js"; import { mergeWhatsAppConfig } from "../../../config/merge-config.js"; import type { DmPolicy } from "../../../config/types.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; import type { RuntimeEnv } from "../../../runtime.js"; import { formatDocsLink } from "../../../terminal/links.js"; import { normalizeE164, pathExists } from "../../../utils.js"; @@ -15,7 +15,12 @@ import { } from "../../../web/accounts.js"; import type { WizardPrompter } from "../../../wizard/prompts.js"; import type { ChannelOnboardingAdapter } from "../onboarding-types.js"; -import { mergeAllowFromEntries, promptAccountId } from "./helpers.js"; +import { + normalizeAllowFromEntries, + resolveAccountIdForConfigure, + resolveOnboardingAccountId, + splitOnboardingEntries, +} from "./helpers.js"; const channel = "whatsapp" as const; @@ -68,14 +73,10 @@ async function promptWhatsAppOwnerAllowFrom(params: { if (!normalized) { throw new Error("Invalid WhatsApp owner number (expected E.164 after validation)."); } - const merged = [ - ...existingAllowFrom - .filter((item) => item !== "*") - .map((item) => normalizeE164(item)) - .filter((item): item is string => typeof item === "string" && item.trim().length > 0), - normalized, - ]; - const allowFrom = mergeAllowFromEntries(undefined, merged); + const allowFrom = normalizeAllowFromEntries( + [...existingAllowFrom.filter((item) => item !== "*"), normalized], + normalizeE164, + ); return { normalized, allowFrom }; } @@ -100,6 +101,26 @@ async function applyWhatsAppOwnerAllowlist(params: { return next; } +function parseWhatsAppAllowFromEntries(raw: string): { entries: string[]; invalidEntry?: string } { + const parts = splitOnboardingEntries(raw); + if (parts.length === 0) { + return { entries: [] }; + } + const entries: string[] = []; + for (const part of parts) { + if (part === "*") { + entries.push("*"); + continue; + } + const normalized = normalizeE164(part); + if (!normalized) { + return { entries: [], invalidEntry: part }; + } + entries.push(normalized); + } + return { entries: normalizeAllowFromEntries(entries, normalizeE164) }; +} + async function promptWhatsAppAllowFrom( cfg: OpenClawConfig, _runtime: RuntimeEnv, @@ -168,7 +189,9 @@ async function promptWhatsAppAllowFrom( let next = setWhatsAppSelfChatMode(cfg, false); next = setWhatsAppDmPolicy(next, policy); if (policy === "open") { - next = setWhatsAppAllowFrom(next, ["*"]); + const allowFrom = normalizeAllowFromEntries(["*", ...existingAllowFrom], normalizeE164); + next = setWhatsAppAllowFrom(next, allowFrom.length > 0 ? allowFrom : ["*"]); + return next; } if (policy === "disabled") { return next; @@ -210,35 +233,19 @@ async function promptWhatsAppAllowFrom( if (!raw) { return "Required"; } - const parts = raw - .split(/[\n,;]+/g) - .map((p) => p.trim()) - .filter(Boolean); - if (parts.length === 0) { + const parsed = parseWhatsAppAllowFromEntries(raw); + if (parsed.entries.length === 0 && !parsed.invalidEntry) { return "Required"; } - for (const part of parts) { - if (part === "*") { - continue; - } - const normalized = normalizeE164(part); - if (!normalized) { - return `Invalid number: ${part}`; - } + if (parsed.invalidEntry) { + return `Invalid number: ${parsed.invalidEntry}`; } return undefined; }, }); - const parts = String(allowRaw) - .split(/[\n,;]+/g) - .map((p) => p.trim()) - .filter(Boolean); - const normalized = parts - .map((part) => (part === "*" ? "*" : normalizeE164(part))) - .filter((part): part is string => typeof part === "string" && part.trim().length > 0); - const unique = mergeAllowFromEntries(undefined, normalized); - next = setWhatsAppAllowFrom(next, unique); + const parsed = parseWhatsAppAllowFromEntries(String(allowRaw)); + next = setWhatsAppAllowFrom(next, parsed.entries); } return next; @@ -247,9 +254,11 @@ async function promptWhatsAppAllowFrom( export const whatsappOnboardingAdapter: ChannelOnboardingAdapter = { channel, getStatus: async ({ cfg, accountOverrides }) => { - const overrideId = accountOverrides.whatsapp?.trim(); const defaultAccountId = resolveDefaultWhatsAppAccountId(cfg); - const accountId = overrideId ? normalizeAccountId(overrideId) : defaultAccountId; + const accountId = resolveOnboardingAccountId({ + accountId: accountOverrides.whatsapp, + defaultAccountId, + }); const linked = await detectWhatsAppLinked(cfg, accountId); const accountLabel = accountId === DEFAULT_ACCOUNT_ID ? "default" : accountId; return { @@ -269,22 +278,15 @@ export const whatsappOnboardingAdapter: ChannelOnboardingAdapter = { shouldPromptAccountIds, forceAllowFrom, }) => { - const overrideId = accountOverrides.whatsapp?.trim(); - let accountId = overrideId - ? normalizeAccountId(overrideId) - : resolveDefaultWhatsAppAccountId(cfg); - if (shouldPromptAccountIds || options?.promptWhatsAppAccountId) { - if (!overrideId) { - accountId = await promptAccountId({ - cfg, - prompter, - label: "WhatsApp", - currentId: accountId, - listAccountIds: listWhatsAppAccountIds, - defaultAccountId: resolveDefaultWhatsAppAccountId(cfg), - }); - } - } + const accountId = await resolveAccountIdForConfigure({ + cfg, + prompter, + label: "WhatsApp", + accountOverride: accountOverrides.whatsapp, + shouldPromptAccountIds: Boolean(shouldPromptAccountIds || options?.promptWhatsAppAccountId), + listAccountIds: listWhatsAppAccountIds, + defaultAccountId: resolveDefaultWhatsAppAccountId(cfg), + }); let next = cfg; if (accountId !== DEFAULT_ACCOUNT_ID) { diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index eec9287fa..152d37e01 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -4,10 +4,12 @@ import { beforeEach, describe, expect, it, type MockInstance, vi } from "vitest" import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; import { setTelegramRuntime } from "../../extensions/telegram/src/runtime.js"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; +import * as cliRunnerModule from "../agents/cli-runner.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import type { OpenClawConfig } from "../config/config.js"; import * as configModule from "../config/config.js"; +import * as sessionsModule from "../config/sessions.js"; import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createPluginRuntime } from "../plugins/runtime/index.js"; @@ -40,6 +42,7 @@ const runtime: RuntimeEnv = { }; const configSpy = vi.spyOn(configModule, "loadConfig"); +const runCliAgentSpy = vi.spyOn(cliRunnerModule, "runCliAgent"); async function withTempHome(fn: (home: string) => Promise): Promise { return withTempHomeBase(fn, { prefix: "openclaw-agent-" }); @@ -79,6 +82,13 @@ function writeSessionStoreSeed( beforeEach(() => { vi.clearAllMocks(); + runCliAgentSpy.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + } as never); vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ payloads: [{ text: "ok" }], meta: { @@ -146,6 +156,28 @@ describe("agentCommand", () => { }); }); + it("resolves resumed session transcript path from custom session store directory", async () => { + await withTempHome(async (home) => { + const customStoreDir = path.join(home, "custom-state"); + const store = path.join(customStoreDir, "sessions.json"); + writeSessionStoreSeed(store, {}); + mockConfig(home, store); + const resolveSessionFilePathSpy = vi.spyOn(sessionsModule, "resolveSessionFilePath"); + + await agentCommand({ message: "resume me", sessionId: "session-custom-123" }, runtime); + + const matchingCall = resolveSessionFilePathSpy.mock.calls.find( + (call) => call[0] === "session-custom-123", + ); + expect(matchingCall?.[2]).toEqual( + expect.objectContaining({ + agentId: "main", + sessionsDir: customStoreDir, + }), + ); + }); + }); + it("does not duplicate agent events from embedded runs", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index a4ceb01c4..314b2948b 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -1,4 +1,3 @@ -import path from "node:path"; import { listAgentIds, resolveAgentDir, @@ -45,6 +44,7 @@ import { resolveAndPersistSessionFile, resolveAgentIdFromSessionKey, resolveSessionFilePath, + resolveSessionFilePathOptions, resolveSessionTranscriptPath, type SessionEntry, updateSessionStore, @@ -510,9 +510,11 @@ export async function agentCommand( }); } } - let sessionFile = resolveSessionFilePath(sessionId, sessionEntry, { + const sessionPathOpts = resolveSessionFilePathOptions({ agentId: sessionAgentId, + storePath, }); + let sessionFile = resolveSessionFilePath(sessionId, sessionEntry, sessionPathOpts); if (sessionStore && sessionKey) { const threadIdFromSessionKey = parseSessionThreadInfo(sessionKey).threadId; const fallbackSessionFile = !sessionEntry?.sessionFile @@ -528,8 +530,8 @@ export async function agentCommand( sessionStore, storePath, sessionEntry, - agentId: sessionAgentId, - sessionsDir: path.dirname(storePath), + agentId: sessionPathOpts?.agentId, + sessionsDir: sessionPathOpts?.sessionsDir, fallbackSessionFile, }); sessionFile = resolvedSessionFile.sessionFile; diff --git a/src/commands/auth-choice.apply-helpers.test.ts b/src/commands/auth-choice.apply-helpers.test.ts new file mode 100644 index 000000000..0318a3a41 --- /dev/null +++ b/src/commands/auth-choice.apply-helpers.test.ts @@ -0,0 +1,208 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { WizardPrompter } from "../wizard/prompts.js"; +import { + ensureApiKeyFromOptionEnvOrPrompt, + ensureApiKeyFromEnvOrPrompt, + maybeApplyApiKeyFromOption, + normalizeTokenProviderInput, +} from "./auth-choice.apply-helpers.js"; + +const ORIGINAL_MINIMAX_API_KEY = process.env.MINIMAX_API_KEY; +const ORIGINAL_MINIMAX_OAUTH_TOKEN = process.env.MINIMAX_OAUTH_TOKEN; + +function restoreMinimaxEnv(): void { + if (ORIGINAL_MINIMAX_API_KEY === undefined) { + delete process.env.MINIMAX_API_KEY; + } else { + process.env.MINIMAX_API_KEY = ORIGINAL_MINIMAX_API_KEY; + } + if (ORIGINAL_MINIMAX_OAUTH_TOKEN === undefined) { + delete process.env.MINIMAX_OAUTH_TOKEN; + } else { + process.env.MINIMAX_OAUTH_TOKEN = ORIGINAL_MINIMAX_OAUTH_TOKEN; + } +} + +function createPrompter(params?: { + confirm?: WizardPrompter["confirm"]; + note?: WizardPrompter["note"]; + text?: WizardPrompter["text"]; +}): WizardPrompter { + return { + confirm: params?.confirm ?? (vi.fn(async () => true) as WizardPrompter["confirm"]), + note: params?.note ?? (vi.fn(async () => undefined) as WizardPrompter["note"]), + text: params?.text ?? (vi.fn(async () => "prompt-key") as WizardPrompter["text"]), + } as unknown as WizardPrompter; +} + +afterEach(() => { + restoreMinimaxEnv(); + vi.restoreAllMocks(); +}); + +describe("normalizeTokenProviderInput", () => { + it("trims and lowercases non-empty values", () => { + expect(normalizeTokenProviderInput(" HuGgInGfAcE ")).toBe("huggingface"); + expect(normalizeTokenProviderInput("")).toBeUndefined(); + }); +}); + +describe("maybeApplyApiKeyFromOption", () => { + it("stores normalized token when provider matches", async () => { + const setCredential = vi.fn(async () => undefined); + + const result = await maybeApplyApiKeyFromOption({ + token: " opt-key ", + tokenProvider: "huggingface", + expectedProviders: ["huggingface"], + normalize: (value) => value.trim(), + setCredential, + }); + + expect(result).toBe("opt-key"); + expect(setCredential).toHaveBeenCalledWith("opt-key"); + }); + + it("matches provider with whitespace/case normalization", async () => { + const setCredential = vi.fn(async () => undefined); + + const result = await maybeApplyApiKeyFromOption({ + token: " opt-key ", + tokenProvider: " HuGgInGfAcE ", + expectedProviders: ["huggingface"], + normalize: (value) => value.trim(), + setCredential, + }); + + expect(result).toBe("opt-key"); + expect(setCredential).toHaveBeenCalledWith("opt-key"); + }); + + it("skips when provider does not match", async () => { + const setCredential = vi.fn(async () => undefined); + + const result = await maybeApplyApiKeyFromOption({ + token: "opt-key", + tokenProvider: "openai", + expectedProviders: ["huggingface"], + normalize: (value) => value.trim(), + setCredential, + }); + + expect(result).toBeUndefined(); + expect(setCredential).not.toHaveBeenCalled(); + }); +}); + +describe("ensureApiKeyFromEnvOrPrompt", () => { + it("uses env credential when user confirms", async () => { + process.env.MINIMAX_API_KEY = "env-key"; + delete process.env.MINIMAX_OAUTH_TOKEN; + + const confirm = vi.fn(async () => true); + const text = vi.fn(async () => "prompt-key"); + const setCredential = vi.fn(async () => undefined); + + const result = await ensureApiKeyFromEnvOrPrompt({ + provider: "minimax", + envLabel: "MINIMAX_API_KEY", + promptMessage: "Enter key", + normalize: (value) => value.trim(), + validate: () => undefined, + prompter: createPrompter({ confirm, text }), + setCredential, + }); + + expect(result).toBe("env-key"); + expect(setCredential).toHaveBeenCalledWith("env-key"); + expect(text).not.toHaveBeenCalled(); + }); + + it("falls back to prompt when env is declined", async () => { + process.env.MINIMAX_API_KEY = "env-key"; + delete process.env.MINIMAX_OAUTH_TOKEN; + + const confirm = vi.fn(async () => false); + const text = vi.fn(async () => " prompted-key "); + const setCredential = vi.fn(async () => undefined); + + const result = await ensureApiKeyFromEnvOrPrompt({ + provider: "minimax", + envLabel: "MINIMAX_API_KEY", + promptMessage: "Enter key", + normalize: (value) => value.trim(), + validate: () => undefined, + prompter: createPrompter({ confirm, text }), + setCredential, + }); + + expect(result).toBe("prompted-key"); + expect(setCredential).toHaveBeenCalledWith("prompted-key"); + expect(text).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Enter key", + }), + ); + }); +}); + +describe("ensureApiKeyFromOptionEnvOrPrompt", () => { + it("uses opts token and skips note/env/prompt", async () => { + const confirm = vi.fn(async () => true); + const note = vi.fn(async () => undefined); + const text = vi.fn(async () => "prompt-key"); + const setCredential = vi.fn(async () => undefined); + + const result = await ensureApiKeyFromOptionEnvOrPrompt({ + token: " opts-key ", + tokenProvider: " HUGGINGFACE ", + expectedProviders: ["huggingface"], + provider: "huggingface", + envLabel: "HF_TOKEN", + promptMessage: "Enter key", + normalize: (value) => value.trim(), + validate: () => undefined, + prompter: createPrompter({ confirm, note, text }), + setCredential, + noteMessage: "Hugging Face note", + noteTitle: "Hugging Face", + }); + + expect(result).toBe("opts-key"); + expect(setCredential).toHaveBeenCalledWith("opts-key"); + expect(note).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + expect(text).not.toHaveBeenCalled(); + }); + + it("falls back to env flow and shows note when opts provider does not match", async () => { + delete process.env.MINIMAX_OAUTH_TOKEN; + process.env.MINIMAX_API_KEY = "env-key"; + + const confirm = vi.fn(async () => true); + const note = vi.fn(async () => undefined); + const text = vi.fn(async () => "prompt-key"); + const setCredential = vi.fn(async () => undefined); + + const result = await ensureApiKeyFromOptionEnvOrPrompt({ + token: "opts-key", + tokenProvider: "openai", + expectedProviders: ["minimax"], + provider: "minimax", + envLabel: "MINIMAX_API_KEY", + promptMessage: "Enter key", + normalize: (value) => value.trim(), + validate: () => undefined, + prompter: createPrompter({ confirm, note, text }), + setCredential, + noteMessage: "MiniMax note", + noteTitle: "MiniMax", + }); + + expect(result).toBe("env-key"); + expect(note).toHaveBeenCalledWith("MiniMax note", "MiniMax"); + expect(confirm).toHaveBeenCalled(); + expect(text).not.toHaveBeenCalled(); + expect(setCredential).toHaveBeenCalledWith("env-key"); + }); +}); diff --git a/src/commands/auth-choice.apply-helpers.ts b/src/commands/auth-choice.apply-helpers.ts index 8a10d830e..8e7e08535 100644 --- a/src/commands/auth-choice.apply-helpers.ts +++ b/src/commands/auth-choice.apply-helpers.ts @@ -1,4 +1,8 @@ +import { resolveEnvApiKey } from "../agents/model-auth.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; +import { formatApiKeyPreview } from "./auth-choice.api-key.js"; import type { ApplyAuthChoiceParams } from "./auth-choice.apply.js"; +import { applyDefaultModelChoice } from "./auth-choice.default-model.js"; export function createAuthChoiceAgentModelNoter( params: ApplyAuthChoiceParams, @@ -13,3 +17,152 @@ export function createAuthChoiceAgentModelNoter( ); }; } + +export interface ApplyAuthChoiceModelState { + config: ApplyAuthChoiceParams["config"]; + agentModelOverride: string | undefined; +} + +export function createAuthChoiceModelStateBridge(bindings: { + getConfig: () => ApplyAuthChoiceParams["config"]; + setConfig: (config: ApplyAuthChoiceParams["config"]) => void; + getAgentModelOverride: () => string | undefined; + setAgentModelOverride: (model: string | undefined) => void; +}): ApplyAuthChoiceModelState { + return { + get config() { + return bindings.getConfig(); + }, + set config(config) { + bindings.setConfig(config); + }, + get agentModelOverride() { + return bindings.getAgentModelOverride(); + }, + set agentModelOverride(model) { + bindings.setAgentModelOverride(model); + }, + }; +} + +export function createAuthChoiceDefaultModelApplier( + params: ApplyAuthChoiceParams, + state: ApplyAuthChoiceModelState, +): ( + options: Omit< + Parameters[0], + "config" | "setDefaultModel" | "noteAgentModel" | "prompter" + >, +) => Promise { + const noteAgentModel = createAuthChoiceAgentModelNoter(params); + + return async (options) => { + const applied = await applyDefaultModelChoice({ + config: state.config, + setDefaultModel: params.setDefaultModel, + noteAgentModel, + prompter: params.prompter, + ...options, + }); + state.config = applied.config; + state.agentModelOverride = applied.agentModelOverride ?? state.agentModelOverride; + }; +} + +export function normalizeTokenProviderInput( + tokenProvider: string | null | undefined, +): string | undefined { + const normalized = String(tokenProvider ?? "") + .trim() + .toLowerCase(); + return normalized || undefined; +} + +export async function maybeApplyApiKeyFromOption(params: { + token: string | undefined; + tokenProvider: string | undefined; + expectedProviders: string[]; + normalize: (value: string) => string; + setCredential: (apiKey: string) => Promise; +}): Promise { + const tokenProvider = normalizeTokenProviderInput(params.tokenProvider); + const expectedProviders = params.expectedProviders + .map((provider) => normalizeTokenProviderInput(provider)) + .filter((provider): provider is string => Boolean(provider)); + if (!params.token || !tokenProvider || !expectedProviders.includes(tokenProvider)) { + return undefined; + } + const apiKey = params.normalize(params.token); + await params.setCredential(apiKey); + return apiKey; +} + +export async function ensureApiKeyFromOptionEnvOrPrompt(params: { + token: string | undefined; + tokenProvider: string | undefined; + expectedProviders: string[]; + provider: string; + envLabel: string; + promptMessage: string; + normalize: (value: string) => string; + validate: (value: string) => string | undefined; + prompter: WizardPrompter; + setCredential: (apiKey: string) => Promise; + noteMessage?: string; + noteTitle?: string; +}): Promise { + const optionApiKey = await maybeApplyApiKeyFromOption({ + token: params.token, + tokenProvider: params.tokenProvider, + expectedProviders: params.expectedProviders, + normalize: params.normalize, + setCredential: params.setCredential, + }); + if (optionApiKey) { + return optionApiKey; + } + + if (params.noteMessage) { + await params.prompter.note(params.noteMessage, params.noteTitle); + } + + return await ensureApiKeyFromEnvOrPrompt({ + provider: params.provider, + envLabel: params.envLabel, + promptMessage: params.promptMessage, + normalize: params.normalize, + validate: params.validate, + prompter: params.prompter, + setCredential: params.setCredential, + }); +} + +export async function ensureApiKeyFromEnvOrPrompt(params: { + provider: string; + envLabel: string; + promptMessage: string; + normalize: (value: string) => string; + validate: (value: string) => string | undefined; + prompter: WizardPrompter; + setCredential: (apiKey: string) => Promise; +}): Promise { + const envKey = resolveEnvApiKey(params.provider); + if (envKey) { + const useExisting = await params.prompter.confirm({ + message: `Use existing ${params.envLabel} (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, + initialValue: true, + }); + if (useExisting) { + await params.setCredential(envKey.apiKey); + return envKey.apiKey; + } + } + + const key = await params.prompter.text({ + message: params.promptMessage, + validate: params.validate, + }); + const apiKey = params.normalize(String(key ?? "")); + await params.setCredential(apiKey); + return apiKey; +} diff --git a/src/commands/auth-choice.apply.api-providers.ts b/src/commands/auth-choice.apply.api-providers.ts index dd574b988..430e32650 100644 --- a/src/commands/auth-choice.apply.api-providers.ts +++ b/src/commands/auth-choice.apply.api-providers.ts @@ -5,11 +5,16 @@ import { normalizeApiKeyInput, validateApiKeyInput, } from "./auth-choice.api-key.js"; -import { createAuthChoiceAgentModelNoter } from "./auth-choice.apply-helpers.js"; +import { + createAuthChoiceAgentModelNoter, + createAuthChoiceDefaultModelApplier, + createAuthChoiceModelStateBridge, + ensureApiKeyFromOptionEnvOrPrompt, + normalizeTokenProviderInput, +} from "./auth-choice.apply-helpers.js"; import { applyAuthChoiceHuggingface } from "./auth-choice.apply.huggingface.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; import { applyAuthChoiceOpenRouter } from "./auth-choice.apply.openrouter.js"; -import { applyDefaultModelChoice } from "./auth-choice.default-model.js"; import { applyGoogleGeminiModelDefault, GOOGLE_GEMINI_DEFAULT_MODEL, @@ -67,86 +72,300 @@ import { setZaiApiKey, ZAI_DEFAULT_MODEL_REF, } from "./onboard-auth.js"; +import type { AuthChoice } from "./onboard-types.js"; import { OPENCODE_ZEN_DEFAULT_MODEL } from "./opencode-zen-model-default.js"; import { detectZaiEndpoint } from "./zai-endpoint-detect.js"; +const API_KEY_TOKEN_PROVIDER_AUTH_CHOICE: Record = { + openrouter: "openrouter-api-key", + litellm: "litellm-api-key", + "vercel-ai-gateway": "ai-gateway-api-key", + "cloudflare-ai-gateway": "cloudflare-ai-gateway-api-key", + moonshot: "moonshot-api-key", + "kimi-code": "kimi-code-api-key", + "kimi-coding": "kimi-code-api-key", + google: "gemini-api-key", + zai: "zai-api-key", + xiaomi: "xiaomi-api-key", + synthetic: "synthetic-api-key", + venice: "venice-api-key", + together: "together-api-key", + huggingface: "huggingface-api-key", + opencode: "opencode-zen", + qianfan: "qianfan-api-key", +}; + +const ZAI_AUTH_CHOICE_ENDPOINT: Partial< + Record +> = { + "zai-coding-global": "coding-global", + "zai-coding-cn": "coding-cn", + "zai-global": "global", + "zai-cn": "cn", +}; + +type ApiKeyProviderConfigApplier = ( + config: ApplyAuthChoiceParams["config"], +) => ApplyAuthChoiceParams["config"]; + +type SimpleApiKeyProviderFlow = { + provider: Parameters[0]["provider"]; + profileId: string; + expectedProviders: string[]; + envLabel: string; + promptMessage: string; + setCredential: (apiKey: string, agentDir?: string) => void | Promise; + defaultModel: string; + applyDefaultConfig: ApiKeyProviderConfigApplier; + applyProviderConfig: ApiKeyProviderConfigApplier; + tokenProvider?: string; + normalize?: (value: string) => string; + validate?: (value: string) => string | undefined; + noteDefault?: string; + noteMessage?: string; + noteTitle?: string; +}; + +const SIMPLE_API_KEY_PROVIDER_FLOWS: Partial> = { + "ai-gateway-api-key": { + provider: "vercel-ai-gateway", + profileId: "vercel-ai-gateway:default", + expectedProviders: ["vercel-ai-gateway"], + envLabel: "AI_GATEWAY_API_KEY", + promptMessage: "Enter Vercel AI Gateway API key", + setCredential: setVercelAiGatewayApiKey, + defaultModel: VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, + applyDefaultConfig: applyVercelAiGatewayConfig, + applyProviderConfig: applyVercelAiGatewayProviderConfig, + noteDefault: VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, + }, + "moonshot-api-key": { + provider: "moonshot", + profileId: "moonshot:default", + expectedProviders: ["moonshot"], + envLabel: "MOONSHOT_API_KEY", + promptMessage: "Enter Moonshot API key", + setCredential: setMoonshotApiKey, + defaultModel: MOONSHOT_DEFAULT_MODEL_REF, + applyDefaultConfig: applyMoonshotConfig, + applyProviderConfig: applyMoonshotProviderConfig, + }, + "moonshot-api-key-cn": { + provider: "moonshot", + profileId: "moonshot:default", + expectedProviders: ["moonshot"], + envLabel: "MOONSHOT_API_KEY", + promptMessage: "Enter Moonshot API key (.cn)", + setCredential: setMoonshotApiKey, + defaultModel: MOONSHOT_DEFAULT_MODEL_REF, + applyDefaultConfig: applyMoonshotConfigCn, + applyProviderConfig: applyMoonshotProviderConfigCn, + }, + "kimi-code-api-key": { + provider: "kimi-coding", + profileId: "kimi-coding:default", + expectedProviders: ["kimi-code", "kimi-coding"], + envLabel: "KIMI_API_KEY", + promptMessage: "Enter Kimi Coding API key", + setCredential: setKimiCodingApiKey, + defaultModel: KIMI_CODING_MODEL_REF, + applyDefaultConfig: applyKimiCodeConfig, + applyProviderConfig: applyKimiCodeProviderConfig, + noteDefault: KIMI_CODING_MODEL_REF, + noteMessage: [ + "Kimi Coding uses a dedicated endpoint and API key.", + "Get your API key at: https://www.kimi.com/code/en", + ].join("\n"), + noteTitle: "Kimi Coding", + }, + "xiaomi-api-key": { + provider: "xiaomi", + profileId: "xiaomi:default", + expectedProviders: ["xiaomi"], + envLabel: "XIAOMI_API_KEY", + promptMessage: "Enter Xiaomi API key", + setCredential: setXiaomiApiKey, + defaultModel: XIAOMI_DEFAULT_MODEL_REF, + applyDefaultConfig: applyXiaomiConfig, + applyProviderConfig: applyXiaomiProviderConfig, + noteDefault: XIAOMI_DEFAULT_MODEL_REF, + }, + "venice-api-key": { + provider: "venice", + profileId: "venice:default", + expectedProviders: ["venice"], + envLabel: "VENICE_API_KEY", + promptMessage: "Enter Venice AI API key", + setCredential: setVeniceApiKey, + defaultModel: VENICE_DEFAULT_MODEL_REF, + applyDefaultConfig: applyVeniceConfig, + applyProviderConfig: applyVeniceProviderConfig, + noteDefault: VENICE_DEFAULT_MODEL_REF, + noteMessage: [ + "Venice AI provides privacy-focused inference with uncensored models.", + "Get your API key at: https://venice.ai/settings/api", + "Supports 'private' (fully private) and 'anonymized' (proxy) modes.", + ].join("\n"), + noteTitle: "Venice AI", + }, + "opencode-zen": { + provider: "opencode", + profileId: "opencode:default", + expectedProviders: ["opencode"], + envLabel: "OPENCODE_API_KEY", + promptMessage: "Enter OpenCode Zen API key", + setCredential: setOpencodeZenApiKey, + defaultModel: OPENCODE_ZEN_DEFAULT_MODEL, + applyDefaultConfig: applyOpencodeZenConfig, + applyProviderConfig: applyOpencodeZenProviderConfig, + noteDefault: OPENCODE_ZEN_DEFAULT_MODEL, + noteMessage: [ + "OpenCode Zen provides access to Claude, GPT, Gemini, and more models.", + "Get your API key at: https://opencode.ai/auth", + "OpenCode Zen bills per request. Check your OpenCode dashboard for details.", + ].join("\n"), + noteTitle: "OpenCode Zen", + }, + "together-api-key": { + provider: "together", + profileId: "together:default", + expectedProviders: ["together"], + envLabel: "TOGETHER_API_KEY", + promptMessage: "Enter Together AI API key", + setCredential: setTogetherApiKey, + defaultModel: TOGETHER_DEFAULT_MODEL_REF, + applyDefaultConfig: applyTogetherConfig, + applyProviderConfig: applyTogetherProviderConfig, + noteDefault: TOGETHER_DEFAULT_MODEL_REF, + noteMessage: [ + "Together AI provides access to leading open-source models including Llama, DeepSeek, Qwen, and more.", + "Get your API key at: https://api.together.xyz/settings/api-keys", + ].join("\n"), + noteTitle: "Together AI", + }, + "qianfan-api-key": { + provider: "qianfan", + profileId: "qianfan:default", + expectedProviders: ["qianfan"], + envLabel: "QIANFAN_API_KEY", + promptMessage: "Enter QIANFAN API key", + setCredential: setQianfanApiKey, + defaultModel: QIANFAN_DEFAULT_MODEL_REF, + applyDefaultConfig: applyQianfanConfig, + applyProviderConfig: applyQianfanProviderConfig, + noteDefault: QIANFAN_DEFAULT_MODEL_REF, + noteMessage: [ + "Get your API key at: https://console.bce.baidu.com/qianfan/ais/console/apiKey", + "API key format: bce-v3/ALTAK-...", + ].join("\n"), + noteTitle: "QIANFAN", + }, + "synthetic-api-key": { + provider: "synthetic", + profileId: "synthetic:default", + expectedProviders: ["synthetic"], + envLabel: "SYNTHETIC_API_KEY", + promptMessage: "Enter Synthetic API key", + setCredential: setSyntheticApiKey, + defaultModel: SYNTHETIC_DEFAULT_MODEL_REF, + applyDefaultConfig: applySyntheticConfig, + applyProviderConfig: applySyntheticProviderConfig, + normalize: (value) => String(value ?? "").trim(), + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }, +}; + export async function applyAuthChoiceApiProviders( params: ApplyAuthChoiceParams, ): Promise { let nextConfig = params.config; let agentModelOverride: string | undefined; const noteAgentModel = createAuthChoiceAgentModelNoter(params); + const applyProviderDefaultModel = createAuthChoiceDefaultModelApplier( + params, + createAuthChoiceModelStateBridge({ + getConfig: () => nextConfig, + setConfig: (config) => (nextConfig = config), + getAgentModelOverride: () => agentModelOverride, + setAgentModelOverride: (model) => (agentModelOverride = model), + }), + ); let authChoice = params.authChoice; - if ( - authChoice === "apiKey" && - params.opts?.tokenProvider && - params.opts.tokenProvider !== "anthropic" && - params.opts.tokenProvider !== "openai" - ) { - if (params.opts.tokenProvider === "openrouter") { - authChoice = "openrouter-api-key"; - } else if (params.opts.tokenProvider === "litellm") { - authChoice = "litellm-api-key"; - } else if (params.opts.tokenProvider === "vercel-ai-gateway") { - authChoice = "ai-gateway-api-key"; - } else if (params.opts.tokenProvider === "cloudflare-ai-gateway") { - authChoice = "cloudflare-ai-gateway-api-key"; - } else if (params.opts.tokenProvider === "moonshot") { - authChoice = "moonshot-api-key"; - } else if ( - params.opts.tokenProvider === "kimi-code" || - params.opts.tokenProvider === "kimi-coding" - ) { - authChoice = "kimi-code-api-key"; - } else if (params.opts.tokenProvider === "google") { - authChoice = "gemini-api-key"; - } else if (params.opts.tokenProvider === "zai") { - authChoice = "zai-api-key"; - } else if (params.opts.tokenProvider === "xiaomi") { - authChoice = "xiaomi-api-key"; - } else if (params.opts.tokenProvider === "synthetic") { - authChoice = "synthetic-api-key"; - } else if (params.opts.tokenProvider === "venice") { - authChoice = "venice-api-key"; - } else if (params.opts.tokenProvider === "together") { - authChoice = "together-api-key"; - } else if (params.opts.tokenProvider === "huggingface") { - authChoice = "huggingface-api-key"; - } else if (params.opts.tokenProvider === "opencode") { - authChoice = "opencode-zen"; - } else if (params.opts.tokenProvider === "qianfan") { - authChoice = "qianfan-api-key"; + const normalizedTokenProvider = normalizeTokenProviderInput(params.opts?.tokenProvider); + if (authChoice === "apiKey" && params.opts?.tokenProvider) { + if (normalizedTokenProvider !== "anthropic" && normalizedTokenProvider !== "openai") { + authChoice = API_KEY_TOKEN_PROVIDER_AUTH_CHOICE[normalizedTokenProvider ?? ""] ?? authChoice; } } - async function ensureMoonshotApiKeyCredential(promptMessage: string): Promise { - let hasCredential = false; + async function applyApiKeyProviderWithDefaultModel({ + provider, + profileId, + expectedProviders, + envLabel, + promptMessage, + setCredential, + defaultModel, + applyDefaultConfig, + applyProviderConfig, + noteMessage, + noteTitle, + tokenProvider = normalizedTokenProvider, + normalize = normalizeApiKeyInput, + validate = validateApiKeyInput, + noteDefault = defaultModel, + }: { + provider: Parameters[0]["provider"]; + profileId: string; + expectedProviders: string[]; + envLabel: string; + promptMessage: string; + setCredential: (apiKey: string) => void | Promise; + defaultModel: string; + applyDefaultConfig: ( + config: ApplyAuthChoiceParams["config"], + ) => ApplyAuthChoiceParams["config"]; + applyProviderConfig: ( + config: ApplyAuthChoiceParams["config"], + ) => ApplyAuthChoiceParams["config"]; + noteMessage?: string; + noteTitle?: string; + tokenProvider?: string; + normalize?: (value: string) => string; + validate?: (value: string) => string | undefined; + noteDefault?: string; + }): Promise { + await ensureApiKeyFromOptionEnvOrPrompt({ + token: params.opts?.token, + provider, + tokenProvider, + expectedProviders, + envLabel, + promptMessage, + setCredential: async (apiKey) => { + await setCredential(apiKey); + }, + noteMessage, + noteTitle, + normalize, + validate, + prompter: params.prompter, + }); - if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "moonshot") { - await setMoonshotApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); - hasCredential = true; - } + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId, + provider, + mode: "api_key", + }); + await applyProviderDefaultModel({ + defaultModel, + applyDefaultConfig, + applyProviderConfig, + noteDefault, + }); - const envKey = resolveEnvApiKey("moonshot"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing MOONSHOT_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setMoonshotApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - - if (!hasCredential) { - const key = await params.prompter.text({ - message: promptMessage, - validate: validateApiKeyInput, - }); - await setMoonshotApiKey(normalizeApiKeyInput(String(key ?? "")), params.agentDir); - } + return { config: nextConfig, agentModelOverride }; } if (authChoice === "openrouter-api-key") { @@ -159,41 +378,30 @@ export async function applyAuthChoiceApiProviders( const existingProfileId = profileOrder.find((profileId) => Boolean(store.profiles[profileId])); const existingCred = existingProfileId ? store.profiles[existingProfileId] : undefined; let profileId = "litellm:default"; - let hasCredential = false; - - if (existingProfileId && existingCred?.type === "api_key") { + let hasCredential = Boolean(existingProfileId && existingCred?.type === "api_key"); + if (hasCredential && existingProfileId) { profileId = existingProfileId; - hasCredential = true; - } - if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "litellm") { - await setLitellmApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); - hasCredential = true; } + if (!hasCredential) { - await params.prompter.note( - "LiteLLM provides a unified API to 100+ LLM providers.\nGet your API key from your LiteLLM proxy or https://litellm.ai\nDefault proxy runs on http://localhost:4000", - "LiteLLM", - ); - const envKey = resolveEnvApiKey("litellm"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing LITELLM_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setLitellmApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter LiteLLM API key", - validate: validateApiKeyInput, - }); - await setLitellmApiKey(normalizeApiKeyInput(String(key ?? "")), params.agentDir); - hasCredential = true; - } + await ensureApiKeyFromOptionEnvOrPrompt({ + token: params.opts?.token, + tokenProvider: normalizedTokenProvider, + expectedProviders: ["litellm"], + provider: "litellm", + envLabel: "LITELLM_API_KEY", + promptMessage: "Enter LiteLLM API key", + normalize: normalizeApiKeyInput, + validate: validateApiKeyInput, + prompter: params.prompter, + setCredential: async (apiKey) => setLitellmApiKey(apiKey, params.agentDir), + noteMessage: + "LiteLLM provides a unified API to 100+ LLM providers.\nGet your API key from your LiteLLM proxy or https://litellm.ai\nDefault proxy runs on http://localhost:4000", + noteTitle: "LiteLLM", + }); + hasCredential = true; } + if (hasCredential) { nextConfig = applyAuthProfileConfig(nextConfig, { profileId, @@ -201,75 +409,38 @@ export async function applyAuthChoiceApiProviders( mode: "api_key", }); } - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, + await applyProviderDefaultModel({ defaultModel: LITELLM_DEFAULT_MODEL_REF, applyDefaultConfig: applyLitellmConfig, applyProviderConfig: applyLitellmProviderConfig, noteDefault: LITELLM_DEFAULT_MODEL_REF, - noteAgentModel, - prompter: params.prompter, }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; return { config: nextConfig, agentModelOverride }; } - if (authChoice === "ai-gateway-api-key") { - let hasCredential = false; - - if ( - !hasCredential && - params.opts?.token && - params.opts?.tokenProvider === "vercel-ai-gateway" - ) { - await setVercelAiGatewayApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); - hasCredential = true; - } - - const envKey = resolveEnvApiKey("vercel-ai-gateway"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing AI_GATEWAY_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setVercelAiGatewayApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter Vercel AI Gateway API key", - validate: validateApiKeyInput, - }); - await setVercelAiGatewayApiKey(normalizeApiKeyInput(String(key ?? "")), params.agentDir); - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "vercel-ai-gateway:default", - provider: "vercel-ai-gateway", - mode: "api_key", + const simpleApiKeyProviderFlow = SIMPLE_API_KEY_PROVIDER_FLOWS[authChoice]; + if (simpleApiKeyProviderFlow) { + return await applyApiKeyProviderWithDefaultModel({ + provider: simpleApiKeyProviderFlow.provider, + profileId: simpleApiKeyProviderFlow.profileId, + expectedProviders: simpleApiKeyProviderFlow.expectedProviders, + envLabel: simpleApiKeyProviderFlow.envLabel, + promptMessage: simpleApiKeyProviderFlow.promptMessage, + setCredential: async (apiKey) => + simpleApiKeyProviderFlow.setCredential(apiKey, params.agentDir), + defaultModel: simpleApiKeyProviderFlow.defaultModel, + applyDefaultConfig: simpleApiKeyProviderFlow.applyDefaultConfig, + applyProviderConfig: simpleApiKeyProviderFlow.applyProviderConfig, + noteDefault: simpleApiKeyProviderFlow.noteDefault, + noteMessage: simpleApiKeyProviderFlow.noteMessage, + noteTitle: simpleApiKeyProviderFlow.noteTitle, + tokenProvider: simpleApiKeyProviderFlow.tokenProvider, + normalize: simpleApiKeyProviderFlow.normalize, + validate: simpleApiKeyProviderFlow.validate, }); - { - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, - applyDefaultConfig: applyVercelAiGatewayConfig, - applyProviderConfig: applyVercelAiGatewayProviderConfig, - noteDefault: VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - } - return { config: nextConfig, agentModelOverride }; } if (authChoice === "cloudflare-ai-gateway-api-key") { - let hasCredential = false; let accountId = params.opts?.cloudflareAiGatewayAccountId?.trim() ?? ""; let gatewayId = params.opts?.cloudflareAiGatewayGatewayId?.trim() ?? ""; @@ -291,215 +462,73 @@ export async function applyAuthChoiceApiProviders( }; const optsApiKey = normalizeApiKeyInput(params.opts?.cloudflareAiGatewayApiKey ?? ""); - if (!hasCredential && accountId && gatewayId && optsApiKey) { - await setCloudflareAiGatewayConfig(accountId, gatewayId, optsApiKey, params.agentDir); - hasCredential = true; + let resolvedApiKey = ""; + if (accountId && gatewayId && optsApiKey) { + resolvedApiKey = optsApiKey; } const envKey = resolveEnvApiKey("cloudflare-ai-gateway"); - if (!hasCredential && envKey) { + if (!resolvedApiKey && envKey) { const useExisting = await params.prompter.confirm({ message: `Use existing CLOUDFLARE_AI_GATEWAY_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, initialValue: true, }); if (useExisting) { await ensureAccountGateway(); - await setCloudflareAiGatewayConfig( - accountId, - gatewayId, - normalizeApiKeyInput(envKey.apiKey), - params.agentDir, - ); - hasCredential = true; + resolvedApiKey = normalizeApiKeyInput(envKey.apiKey); } } - if (!hasCredential && optsApiKey) { + if (!resolvedApiKey && optsApiKey) { await ensureAccountGateway(); - await setCloudflareAiGatewayConfig(accountId, gatewayId, optsApiKey, params.agentDir); - hasCredential = true; + resolvedApiKey = optsApiKey; } - if (!hasCredential) { + if (!resolvedApiKey) { await ensureAccountGateway(); const key = await params.prompter.text({ message: "Enter Cloudflare AI Gateway API key", validate: validateApiKeyInput, }); - await setCloudflareAiGatewayConfig( - accountId, - gatewayId, - normalizeApiKeyInput(String(key ?? "")), - params.agentDir, - ); - hasCredential = true; + resolvedApiKey = normalizeApiKeyInput(String(key ?? "")); } - if (hasCredential) { - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "cloudflare-ai-gateway:default", - provider: "cloudflare-ai-gateway", - mode: "api_key", - }); - } - { - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, - applyDefaultConfig: (cfg) => - applyCloudflareAiGatewayConfig(cfg, { - accountId: accountId || params.opts?.cloudflareAiGatewayAccountId, - gatewayId: gatewayId || params.opts?.cloudflareAiGatewayGatewayId, - }), - applyProviderConfig: (cfg) => - applyCloudflareAiGatewayProviderConfig(cfg, { - accountId: accountId || params.opts?.cloudflareAiGatewayAccountId, - gatewayId: gatewayId || params.opts?.cloudflareAiGatewayGatewayId, - }), - noteDefault: CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - } - return { config: nextConfig, agentModelOverride }; - } - - if (authChoice === "moonshot-api-key") { - await ensureMoonshotApiKeyCredential("Enter Moonshot API key"); + await setCloudflareAiGatewayConfig(accountId, gatewayId, resolvedApiKey, params.agentDir); nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "moonshot:default", - provider: "moonshot", + profileId: "cloudflare-ai-gateway:default", + provider: "cloudflare-ai-gateway", mode: "api_key", }); - { - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: MOONSHOT_DEFAULT_MODEL_REF, - applyDefaultConfig: applyMoonshotConfig, - applyProviderConfig: applyMoonshotProviderConfig, - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - } - return { config: nextConfig, agentModelOverride }; - } - - if (authChoice === "moonshot-api-key-cn") { - await ensureMoonshotApiKeyCredential("Enter Moonshot API key (.cn)"); - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "moonshot:default", - provider: "moonshot", - mode: "api_key", + await applyProviderDefaultModel({ + defaultModel: CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, + applyDefaultConfig: (cfg) => + applyCloudflareAiGatewayConfig(cfg, { + accountId: accountId || params.opts?.cloudflareAiGatewayAccountId, + gatewayId: gatewayId || params.opts?.cloudflareAiGatewayGatewayId, + }), + applyProviderConfig: (cfg) => + applyCloudflareAiGatewayProviderConfig(cfg, { + accountId: accountId || params.opts?.cloudflareAiGatewayAccountId, + gatewayId: gatewayId || params.opts?.cloudflareAiGatewayGatewayId, + }), + noteDefault: CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, }); - { - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: MOONSHOT_DEFAULT_MODEL_REF, - applyDefaultConfig: applyMoonshotConfigCn, - applyProviderConfig: applyMoonshotProviderConfigCn, - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - } - return { config: nextConfig, agentModelOverride }; - } - - if (authChoice === "kimi-code-api-key") { - let hasCredential = false; - const tokenProvider = params.opts?.tokenProvider?.trim().toLowerCase(); - if ( - !hasCredential && - params.opts?.token && - (tokenProvider === "kimi-code" || tokenProvider === "kimi-coding") - ) { - await setKimiCodingApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); - hasCredential = true; - } - - if (!hasCredential) { - await params.prompter.note( - [ - "Kimi Coding uses a dedicated endpoint and API key.", - "Get your API key at: https://www.kimi.com/code/en", - ].join("\n"), - "Kimi Coding", - ); - } - const envKey = resolveEnvApiKey("kimi-coding"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing KIMI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setKimiCodingApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter Kimi Coding API key", - validate: validateApiKeyInput, - }); - await setKimiCodingApiKey(normalizeApiKeyInput(String(key ?? "")), params.agentDir); - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "kimi-coding:default", - provider: "kimi-coding", - mode: "api_key", - }); - { - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: KIMI_CODING_MODEL_REF, - applyDefaultConfig: applyKimiCodeConfig, - applyProviderConfig: applyKimiCodeProviderConfig, - noteDefault: KIMI_CODING_MODEL_REF, - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - } return { config: nextConfig, agentModelOverride }; } if (authChoice === "gemini-api-key") { - let hasCredential = false; - - if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "google") { - await setGeminiApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); - hasCredential = true; - } - - const envKey = resolveEnvApiKey("google"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing GEMINI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setGeminiApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter Gemini API key", - validate: validateApiKeyInput, - }); - await setGeminiApiKey(normalizeApiKeyInput(String(key ?? "")), params.agentDir); - } + await ensureApiKeyFromOptionEnvOrPrompt({ + token: params.opts?.token, + provider: "google", + tokenProvider: normalizedTokenProvider, + expectedProviders: ["google"], + envLabel: "GEMINI_API_KEY", + promptMessage: "Enter Gemini API key", + normalize: normalizeApiKeyInput, + validate: validateApiKeyInput, + prompter: params.prompter, + setCredential: async (apiKey) => setGeminiApiKey(apiKey, params.agentDir), + }); nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "google:default", provider: "google", @@ -528,47 +557,20 @@ export async function applyAuthChoiceApiProviders( authChoice === "zai-global" || authChoice === "zai-cn" ) { - let endpoint: "global" | "cn" | "coding-global" | "coding-cn" | undefined; - if (authChoice === "zai-coding-global") { - endpoint = "coding-global"; - } else if (authChoice === "zai-coding-cn") { - endpoint = "coding-cn"; - } else if (authChoice === "zai-global") { - endpoint = "global"; - } else if (authChoice === "zai-cn") { - endpoint = "cn"; - } + let endpoint = ZAI_AUTH_CHOICE_ENDPOINT[authChoice]; - // Input API key - let hasCredential = false; - let apiKey = ""; - - if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "zai") { - apiKey = normalizeApiKeyInput(params.opts.token); - await setZaiApiKey(apiKey, params.agentDir); - hasCredential = true; - } - - const envKey = resolveEnvApiKey("zai"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing ZAI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - apiKey = envKey.apiKey; - await setZaiApiKey(apiKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter Z.AI API key", - validate: validateApiKeyInput, - }); - apiKey = normalizeApiKeyInput(String(key ?? "")); - await setZaiApiKey(apiKey, params.agentDir); - } + const apiKey = await ensureApiKeyFromOptionEnvOrPrompt({ + token: params.opts?.token, + provider: "zai", + tokenProvider: normalizedTokenProvider, + expectedProviders: ["zai"], + envLabel: "ZAI_API_KEY", + promptMessage: "Enter Z.AI API key", + normalize: normalizeApiKeyInput, + validate: validateApiKeyInput, + prompter: params.prompter, + setCredential: async (apiKey) => setZaiApiKey(apiKey, params.agentDir), + }); // zai-api-key: auto-detect endpoint + choose a working default model. let modelIdOverride: string | undefined; @@ -615,9 +617,7 @@ export async function applyAuthChoiceApiProviders( }); const defaultModel = modelIdOverride ? `zai/${modelIdOverride}` : ZAI_DEFAULT_MODEL_REF; - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, + await applyProviderDefaultModel({ defaultModel, applyDefaultConfig: (config) => applyZaiConfig(config, { @@ -630,328 +630,14 @@ export async function applyAuthChoiceApiProviders( ...(modelIdOverride ? { modelId: modelIdOverride } : {}), }), noteDefault: defaultModel, - noteAgentModel, - prompter: params.prompter, }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; return { config: nextConfig, agentModelOverride }; } - if (authChoice === "xiaomi-api-key") { - let hasCredential = false; - - if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "xiaomi") { - await setXiaomiApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); - hasCredential = true; - } - - const envKey = resolveEnvApiKey("xiaomi"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing XIAOMI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setXiaomiApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter Xiaomi API key", - validate: validateApiKeyInput, - }); - await setXiaomiApiKey(normalizeApiKeyInput(String(key ?? "")), params.agentDir); - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "xiaomi:default", - provider: "xiaomi", - mode: "api_key", - }); - { - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: XIAOMI_DEFAULT_MODEL_REF, - applyDefaultConfig: applyXiaomiConfig, - applyProviderConfig: applyXiaomiProviderConfig, - noteDefault: XIAOMI_DEFAULT_MODEL_REF, - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - } - return { config: nextConfig, agentModelOverride }; - } - - if (authChoice === "synthetic-api-key") { - if (params.opts?.token && params.opts?.tokenProvider === "synthetic") { - await setSyntheticApiKey(String(params.opts.token ?? "").trim(), params.agentDir); - } else { - const key = await params.prompter.text({ - message: "Enter Synthetic API key", - validate: (value) => (value?.trim() ? undefined : "Required"), - }); - await setSyntheticApiKey(String(key ?? "").trim(), params.agentDir); - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "synthetic:default", - provider: "synthetic", - mode: "api_key", - }); - { - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: SYNTHETIC_DEFAULT_MODEL_REF, - applyDefaultConfig: applySyntheticConfig, - applyProviderConfig: applySyntheticProviderConfig, - noteDefault: SYNTHETIC_DEFAULT_MODEL_REF, - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - } - return { config: nextConfig, agentModelOverride }; - } - - if (authChoice === "venice-api-key") { - let hasCredential = false; - - if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "venice") { - await setVeniceApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); - hasCredential = true; - } - - if (!hasCredential) { - await params.prompter.note( - [ - "Venice AI provides privacy-focused inference with uncensored models.", - "Get your API key at: https://venice.ai/settings/api", - "Supports 'private' (fully private) and 'anonymized' (proxy) modes.", - ].join("\n"), - "Venice AI", - ); - } - - const envKey = resolveEnvApiKey("venice"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing VENICE_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setVeniceApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter Venice AI API key", - validate: validateApiKeyInput, - }); - await setVeniceApiKey(normalizeApiKeyInput(String(key ?? "")), params.agentDir); - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "venice:default", - provider: "venice", - mode: "api_key", - }); - { - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: VENICE_DEFAULT_MODEL_REF, - applyDefaultConfig: applyVeniceConfig, - applyProviderConfig: applyVeniceProviderConfig, - noteDefault: VENICE_DEFAULT_MODEL_REF, - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - } - return { config: nextConfig, agentModelOverride }; - } - - if (authChoice === "opencode-zen") { - let hasCredential = false; - if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "opencode") { - await setOpencodeZenApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); - hasCredential = true; - } - - if (!hasCredential) { - await params.prompter.note( - [ - "OpenCode Zen provides access to Claude, GPT, Gemini, and more models.", - "Get your API key at: https://opencode.ai/auth", - "OpenCode Zen bills per request. Check your OpenCode dashboard for details.", - ].join("\n"), - "OpenCode Zen", - ); - } - const envKey = resolveEnvApiKey("opencode"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing OPENCODE_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setOpencodeZenApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter OpenCode Zen API key", - validate: validateApiKeyInput, - }); - await setOpencodeZenApiKey(normalizeApiKeyInput(String(key ?? "")), params.agentDir); - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "opencode:default", - provider: "opencode", - mode: "api_key", - }); - { - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: OPENCODE_ZEN_DEFAULT_MODEL, - applyDefaultConfig: applyOpencodeZenConfig, - applyProviderConfig: applyOpencodeZenProviderConfig, - noteDefault: OPENCODE_ZEN_DEFAULT_MODEL, - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - } - return { config: nextConfig, agentModelOverride }; - } - - if (authChoice === "together-api-key") { - let hasCredential = false; - - if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "together") { - await setTogetherApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); - hasCredential = true; - } - - if (!hasCredential) { - await params.prompter.note( - [ - "Together AI provides access to leading open-source models including Llama, DeepSeek, Qwen, and more.", - "Get your API key at: https://api.together.xyz/settings/api-keys", - ].join("\n"), - "Together AI", - ); - } - - const envKey = resolveEnvApiKey("together"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing TOGETHER_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setTogetherApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter Together AI API key", - validate: validateApiKeyInput, - }); - await setTogetherApiKey(normalizeApiKeyInput(String(key ?? "")), params.agentDir); - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "together:default", - provider: "together", - mode: "api_key", - }); - { - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: TOGETHER_DEFAULT_MODEL_REF, - applyDefaultConfig: applyTogetherConfig, - applyProviderConfig: applyTogetherProviderConfig, - noteDefault: TOGETHER_DEFAULT_MODEL_REF, - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - } - return { config: nextConfig, agentModelOverride }; - } - if (authChoice === "huggingface-api-key") { return applyAuthChoiceHuggingface({ ...params, authChoice }); } - if (authChoice === "qianfan-api-key") { - let hasCredential = false; - if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "qianfan") { - setQianfanApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); - hasCredential = true; - } - - if (!hasCredential) { - await params.prompter.note( - [ - "Get your API key at: https://console.bce.baidu.com/qianfan/ais/console/apiKey", - "API key format: bce-v3/ALTAK-...", - ].join("\n"), - "QIANFAN", - ); - } - const envKey = resolveEnvApiKey("qianfan"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing QIANFAN_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - setQianfanApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter QIANFAN API key", - validate: validateApiKeyInput, - }); - setQianfanApiKey(normalizeApiKeyInput(String(key ?? "")), params.agentDir); - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "qianfan:default", - provider: "qianfan", - mode: "api_key", - }); - { - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: QIANFAN_DEFAULT_MODEL_REF, - applyDefaultConfig: applyQianfanConfig, - applyProviderConfig: applyQianfanProviderConfig, - noteDefault: QIANFAN_DEFAULT_MODEL_REF, - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - } - return { config: nextConfig, agentModelOverride }; - } - return null; } diff --git a/src/commands/auth-choice.apply.huggingface.test.ts b/src/commands/auth-choice.apply.huggingface.test.ts index 7cf1ebc96..4090b5473 100644 --- a/src/commands/auth-choice.apply.huggingface.test.ts +++ b/src/commands/auth-choice.apply.huggingface.test.ts @@ -127,4 +127,37 @@ describe("applyAuthChoiceHuggingface", () => { const parsed = await readAuthProfiles(agentDir); expect(parsed.profiles?.["huggingface:default"]?.key).toBe("hf-opts-token"); }); + + it("accepts mixed-case tokenProvider from opts without prompting", async () => { + const agentDir = await setupTempState(); + delete process.env.HF_TOKEN; + delete process.env.HUGGINGFACE_HUB_TOKEN; + + const text = vi.fn().mockResolvedValue("hf-text-token"); + const select: WizardPrompter["select"] = vi.fn( + async (params) => params.options?.[0]?.value as never, + ); + const confirm = vi.fn(async () => true); + const prompter = createHuggingfacePrompter({ text, select, confirm }); + const runtime = createExitThrowingRuntime(); + + const result = await applyAuthChoiceHuggingface({ + authChoice: "huggingface-api-key", + config: {}, + prompter, + runtime, + setDefaultModel: true, + opts: { + tokenProvider: " HuGgInGfAcE ", + token: "hf-opts-mixed", + }, + }); + + expect(result).not.toBeNull(); + expect(confirm).not.toHaveBeenCalled(); + expect(text).not.toHaveBeenCalled(); + + const parsed = await readAuthProfiles(agentDir); + expect(parsed.profiles?.["huggingface:default"]?.key).toBe("hf-opts-mixed"); + }); }); diff --git a/src/commands/auth-choice.apply.huggingface.ts b/src/commands/auth-choice.apply.huggingface.ts index c1210921b..3f4c98087 100644 --- a/src/commands/auth-choice.apply.huggingface.ts +++ b/src/commands/auth-choice.apply.huggingface.ts @@ -2,13 +2,11 @@ import { discoverHuggingfaceModels, isHuggingfacePolicyLocked, } from "../agents/huggingface-models.js"; -import { resolveEnvApiKey } from "../agents/model-auth.js"; +import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js"; import { - formatApiKeyPreview, - normalizeApiKeyInput, - validateApiKeyInput, -} from "./auth-choice.api-key.js"; -import { createAuthChoiceAgentModelNoter } from "./auth-choice.apply-helpers.js"; + createAuthChoiceAgentModelNoter, + ensureApiKeyFromOptionEnvOrPrompt, +} from "./auth-choice.apply-helpers.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; import { applyDefaultModelChoice } from "./auth-choice.default-model.js"; import { ensureModelAllowlistEntry } from "./model-allowlist.js"; @@ -30,47 +28,23 @@ export async function applyAuthChoiceHuggingface( let agentModelOverride: string | undefined; const noteAgentModel = createAuthChoiceAgentModelNoter(params); - let hasCredential = false; - let hfKey = ""; - - if (!hasCredential && params.opts?.token && params.opts.tokenProvider === "huggingface") { - hfKey = normalizeApiKeyInput(params.opts.token); - await setHuggingfaceApiKey(hfKey, params.agentDir); - hasCredential = true; - } - - if (!hasCredential) { - await params.prompter.note( - [ - "Hugging Face Inference Providers offer OpenAI-compatible chat completions.", - "Create a token at: https://huggingface.co/settings/tokens (fine-grained, 'Make calls to Inference Providers').", - ].join("\n"), - "Hugging Face", - ); - } - - if (!hasCredential) { - const envKey = resolveEnvApiKey("huggingface"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing Hugging Face token (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - hfKey = envKey.apiKey; - await setHuggingfaceApiKey(hfKey, params.agentDir); - hasCredential = true; - } - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter Hugging Face API key (HF token)", - validate: validateApiKeyInput, - }); - hfKey = normalizeApiKeyInput(String(key ?? "")); - await setHuggingfaceApiKey(hfKey, params.agentDir); - } + const hfKey = await ensureApiKeyFromOptionEnvOrPrompt({ + token: params.opts?.token, + tokenProvider: params.opts?.tokenProvider, + expectedProviders: ["huggingface"], + provider: "huggingface", + envLabel: "Hugging Face token", + promptMessage: "Enter Hugging Face API key (HF token)", + normalize: normalizeApiKeyInput, + validate: validateApiKeyInput, + prompter: params.prompter, + setCredential: async (apiKey) => setHuggingfaceApiKey(apiKey, params.agentDir), + noteMessage: [ + "Hugging Face Inference Providers offer OpenAI-compatible chat completions.", + "Create a token at: https://huggingface.co/settings/tokens (fine-grained, 'Make calls to Inference Providers').", + ].join("\n"), + noteTitle: "Hugging Face", + }); nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "huggingface:default", provider: "huggingface", diff --git a/src/commands/auth-choice.apply.minimax.test.ts b/src/commands/auth-choice.apply.minimax.test.ts new file mode 100644 index 000000000..ba17cd476 --- /dev/null +++ b/src/commands/auth-choice.apply.minimax.test.ts @@ -0,0 +1,160 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { WizardPrompter } from "../wizard/prompts.js"; +import { applyAuthChoiceMiniMax } from "./auth-choice.apply.minimax.js"; +import { + createAuthTestLifecycle, + createExitThrowingRuntime, + createWizardPrompter, + readAuthProfilesForAgent, + setupAuthTestEnv, +} from "./test-wizard-helpers.js"; + +function createMinimaxPrompter( + params: { + text?: WizardPrompter["text"]; + confirm?: WizardPrompter["confirm"]; + select?: WizardPrompter["select"]; + } = {}, +): WizardPrompter { + return createWizardPrompter( + { + text: params.text, + confirm: params.confirm, + select: params.select, + }, + { defaultSelect: "oauth" }, + ); +} + +describe("applyAuthChoiceMiniMax", () => { + const lifecycle = createAuthTestLifecycle([ + "OPENCLAW_STATE_DIR", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + "MINIMAX_API_KEY", + "MINIMAX_OAUTH_TOKEN", + ]); + + async function setupTempState() { + const env = await setupAuthTestEnv("openclaw-minimax-"); + lifecycle.setStateDir(env.stateDir); + return env.agentDir; + } + + async function readAuthProfiles(agentDir: string) { + return await readAuthProfilesForAgent<{ + profiles?: Record; + }>(agentDir); + } + + afterEach(async () => { + await lifecycle.cleanup(); + }); + + it("returns null for unrelated authChoice", async () => { + const result = await applyAuthChoiceMiniMax({ + authChoice: "openrouter-api-key", + config: {}, + prompter: createMinimaxPrompter(), + runtime: createExitThrowingRuntime(), + setDefaultModel: true, + }); + + expect(result).toBeNull(); + }); + + it("uses opts token for minimax-api without prompt", async () => { + const agentDir = await setupTempState(); + delete process.env.MINIMAX_API_KEY; + delete process.env.MINIMAX_OAUTH_TOKEN; + + const text = vi.fn(async () => "should-not-be-used"); + const confirm = vi.fn(async () => true); + + const result = await applyAuthChoiceMiniMax({ + authChoice: "minimax-api", + config: {}, + prompter: createMinimaxPrompter({ text, confirm }), + runtime: createExitThrowingRuntime(), + setDefaultModel: true, + opts: { + tokenProvider: "minimax", + token: "mm-opts-token", + }, + }); + + expect(result).not.toBeNull(); + expect(result?.config.auth?.profiles?.["minimax:default"]).toMatchObject({ + provider: "minimax", + mode: "api_key", + }); + expect(result?.config.agents?.defaults?.model?.primary).toBe("minimax/MiniMax-M2.5"); + expect(text).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + + const parsed = await readAuthProfiles(agentDir); + expect(parsed.profiles?.["minimax:default"]?.key).toBe("mm-opts-token"); + }); + + it("uses env token for minimax-api-key-cn when confirmed", async () => { + const agentDir = await setupTempState(); + process.env.MINIMAX_API_KEY = "mm-env-token"; + delete process.env.MINIMAX_OAUTH_TOKEN; + + const text = vi.fn(async () => "should-not-be-used"); + const confirm = vi.fn(async () => true); + + const result = await applyAuthChoiceMiniMax({ + authChoice: "minimax-api-key-cn", + config: {}, + prompter: createMinimaxPrompter({ text, confirm }), + runtime: createExitThrowingRuntime(), + setDefaultModel: true, + }); + + expect(result).not.toBeNull(); + expect(result?.config.auth?.profiles?.["minimax-cn:default"]).toMatchObject({ + provider: "minimax-cn", + mode: "api_key", + }); + expect(result?.config.agents?.defaults?.model?.primary).toBe("minimax-cn/MiniMax-M2.5"); + expect(text).not.toHaveBeenCalled(); + expect(confirm).toHaveBeenCalled(); + + const parsed = await readAuthProfiles(agentDir); + expect(parsed.profiles?.["minimax-cn:default"]?.key).toBe("mm-env-token"); + }); + + it("uses opts token for minimax-api-key-cn with trimmed/case-insensitive tokenProvider", async () => { + const agentDir = await setupTempState(); + delete process.env.MINIMAX_API_KEY; + delete process.env.MINIMAX_OAUTH_TOKEN; + + const text = vi.fn(async () => "should-not-be-used"); + const confirm = vi.fn(async () => true); + + const result = await applyAuthChoiceMiniMax({ + authChoice: "minimax-api-key-cn", + config: {}, + prompter: createMinimaxPrompter({ text, confirm }), + runtime: createExitThrowingRuntime(), + setDefaultModel: true, + opts: { + tokenProvider: " MINIMAX-CN ", + token: "mm-cn-opts-token", + }, + }); + + expect(result).not.toBeNull(); + expect(result?.config.auth?.profiles?.["minimax-cn:default"]).toMatchObject({ + provider: "minimax-cn", + mode: "api_key", + }); + expect(result?.config.agents?.defaults?.model?.primary).toBe("minimax-cn/MiniMax-M2.5"); + expect(text).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + + const parsed = await readAuthProfiles(agentDir); + expect(parsed.profiles?.["minimax-cn:default"]?.key).toBe("mm-cn-opts-token"); + }); +}); diff --git a/src/commands/auth-choice.apply.minimax.ts b/src/commands/auth-choice.apply.minimax.ts index 5afd52b21..d7c99ff8f 100644 --- a/src/commands/auth-choice.apply.minimax.ts +++ b/src/commands/auth-choice.apply.minimax.ts @@ -1,13 +1,11 @@ -import { resolveEnvApiKey } from "../agents/model-auth.js"; +import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js"; import { - formatApiKeyPreview, - normalizeApiKeyInput, - validateApiKeyInput, -} from "./auth-choice.api-key.js"; -import { createAuthChoiceAgentModelNoter } from "./auth-choice.apply-helpers.js"; + createAuthChoiceDefaultModelApplier, + createAuthChoiceModelStateBridge, + ensureApiKeyFromOptionEnvOrPrompt, +} from "./auth-choice.apply-helpers.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js"; -import { applyDefaultModelChoice } from "./auth-choice.default-model.js"; import { applyAuthProfileConfig, applyMinimaxApiConfig, @@ -24,31 +22,64 @@ export async function applyAuthChoiceMiniMax( ): Promise { let nextConfig = params.config; let agentModelOverride: string | undefined; + const applyProviderDefaultModel = createAuthChoiceDefaultModelApplier( + params, + createAuthChoiceModelStateBridge({ + getConfig: () => nextConfig, + setConfig: (config) => (nextConfig = config), + getAgentModelOverride: () => agentModelOverride, + setAgentModelOverride: (model) => (agentModelOverride = model), + }), + ); const ensureMinimaxApiKey = async (opts: { profileId: string; promptMessage: string; }): Promise => { - let hasCredential = false; - const envKey = resolveEnvApiKey("minimax"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing MINIMAX_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setMinimaxApiKey(envKey.apiKey, params.agentDir, opts.profileId); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: opts.promptMessage, - validate: validateApiKeyInput, - }); - await setMinimaxApiKey(normalizeApiKeyInput(String(key)), params.agentDir, opts.profileId); - } + await ensureApiKeyFromOptionEnvOrPrompt({ + token: params.opts?.token, + tokenProvider: params.opts?.tokenProvider, + expectedProviders: ["minimax", "minimax-cn"], + provider: "minimax", + envLabel: "MINIMAX_API_KEY", + promptMessage: opts.promptMessage, + normalize: normalizeApiKeyInput, + validate: validateApiKeyInput, + prompter: params.prompter, + setCredential: async (apiKey) => setMinimaxApiKey(apiKey, params.agentDir, opts.profileId), + }); + }; + const applyMinimaxApiVariant = async (opts: { + profileId: string; + provider: "minimax" | "minimax-cn"; + promptMessage: string; + modelRefPrefix: "minimax" | "minimax-cn"; + modelId: string; + applyDefaultConfig: ( + config: ApplyAuthChoiceParams["config"], + modelId: string, + ) => ApplyAuthChoiceParams["config"]; + applyProviderConfig: ( + config: ApplyAuthChoiceParams["config"], + modelId: string, + ) => ApplyAuthChoiceParams["config"]; + }): Promise => { + await ensureMinimaxApiKey({ + profileId: opts.profileId, + promptMessage: opts.promptMessage, + }); + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: opts.profileId, + provider: opts.provider, + mode: "api_key", + }); + const modelRef = `${opts.modelRefPrefix}/${opts.modelId}`; + await applyProviderDefaultModel({ + defaultModel: modelRef, + applyDefaultConfig: (config) => opts.applyDefaultConfig(config, opts.modelId), + applyProviderConfig: (config) => opts.applyProviderConfig(config, opts.modelId), + }); + return { config: nextConfig, agentModelOverride }; }; - const noteAgentModel = createAuthChoiceAgentModelNoter(params); if (params.authChoice === "minimax-portal") { // Let user choose between Global/CN endpoints const endpoint = await params.prompter.select({ @@ -73,74 +104,36 @@ export async function applyAuthChoiceMiniMax( params.authChoice === "minimax-api" || params.authChoice === "minimax-api-lightning" ) { - const modelId = - params.authChoice === "minimax-api-lightning" ? "MiniMax-M2.5-Lightning" : "MiniMax-M2.5"; - await ensureMinimaxApiKey({ - profileId: "minimax:default", - promptMessage: "Enter MiniMax API key", - }); - nextConfig = applyAuthProfileConfig(nextConfig, { + return await applyMinimaxApiVariant({ profileId: "minimax:default", provider: "minimax", - mode: "api_key", + promptMessage: "Enter MiniMax API key", + modelRefPrefix: "minimax", + modelId: + params.authChoice === "minimax-api-lightning" ? "MiniMax-M2.5-Lightning" : "MiniMax-M2.5", + applyDefaultConfig: applyMinimaxApiConfig, + applyProviderConfig: applyMinimaxApiProviderConfig, }); - { - const modelRef = `minimax/${modelId}`; - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: modelRef, - applyDefaultConfig: (config) => applyMinimaxApiConfig(config, modelId), - applyProviderConfig: (config) => applyMinimaxApiProviderConfig(config, modelId), - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - } - return { config: nextConfig, agentModelOverride }; } if (params.authChoice === "minimax-api-key-cn") { - const modelId = "MiniMax-M2.5"; - await ensureMinimaxApiKey({ - profileId: "minimax-cn:default", - promptMessage: "Enter MiniMax China API key", - }); - nextConfig = applyAuthProfileConfig(nextConfig, { + return await applyMinimaxApiVariant({ profileId: "minimax-cn:default", provider: "minimax-cn", - mode: "api_key", + promptMessage: "Enter MiniMax China API key", + modelRefPrefix: "minimax-cn", + modelId: "MiniMax-M2.5", + applyDefaultConfig: applyMinimaxApiConfigCn, + applyProviderConfig: applyMinimaxApiProviderConfigCn, }); - { - const modelRef = `minimax-cn/${modelId}`; - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: modelRef, - applyDefaultConfig: (config) => applyMinimaxApiConfigCn(config, modelId), - applyProviderConfig: (config) => applyMinimaxApiProviderConfigCn(config, modelId), - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - } - return { config: nextConfig, agentModelOverride }; } if (params.authChoice === "minimax") { - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, + await applyProviderDefaultModel({ defaultModel: "lmstudio/minimax-m2.1-gs32", applyDefaultConfig: applyMinimaxConfig, applyProviderConfig: applyMinimaxProviderConfig, - noteAgentModel, - prompter: params.prompter, }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; return { config: nextConfig, agentModelOverride }; } diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 0c7481a33..d3fd20bef 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -3,6 +3,7 @@ import type { OAuthCredentials } from "@mariozechner/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { WizardPrompter } from "../wizard/prompts.js"; import { applyAuthChoice, resolvePreferredProviderForAuthChoice } from "./auth-choice.js"; +import { GOOGLE_GEMINI_DEFAULT_MODEL } from "./google-gemini-model-default.js"; import { MINIMAX_CN_API_BASE_URL, ZAI_CODING_CN_BASE_URL, @@ -19,6 +20,8 @@ import { setupAuthTestEnv, } from "./test-wizard-helpers.js"; +type DetectZaiEndpoint = typeof import("./zai-endpoint-detect.js").detectZaiEndpoint; + vi.mock("../providers/github-copilot-auth.js", () => ({ githubCopilotLoginCommand: vi.fn(async () => {}), })); @@ -35,6 +38,11 @@ vi.mock("../plugins/providers.js", () => ({ resolvePluginProviders, })); +const detectZaiEndpoint = vi.hoisted(() => vi.fn(async () => null)); +vi.mock("./zai-endpoint-detect.js", () => ({ + detectZaiEndpoint, +})); + type StoredAuthProfile = { key?: string; access?: string; @@ -57,6 +65,15 @@ describe("applyAuthChoice", () => { "LITELLM_API_KEY", "AI_GATEWAY_API_KEY", "CLOUDFLARE_AI_GATEWAY_API_KEY", + "MOONSHOT_API_KEY", + "KIMI_API_KEY", + "GEMINI_API_KEY", + "XIAOMI_API_KEY", + "VENICE_API_KEY", + "OPENCODE_API_KEY", + "TOGETHER_API_KEY", + "QIANFAN_API_KEY", + "SYNTHETIC_API_KEY", "SSH_TTY", "CHUTES_CLIENT_ID", ]); @@ -101,8 +118,10 @@ describe("applyAuthChoice", () => { afterEach(async () => { vi.unstubAllGlobals(); - resolvePluginProviders.mockClear(); - loginOpenAICodexOAuth.mockClear(); + resolvePluginProviders.mockReset(); + detectZaiEndpoint.mockReset(); + detectZaiEndpoint.mockResolvedValue(null); + loginOpenAICodexOAuth.mockReset(); loginOpenAICodexOAuth.mockResolvedValue(null); await lifecycle.cleanup(); }); @@ -319,6 +338,38 @@ describe("applyAuthChoice", () => { expect(result.config.models?.providers?.zai?.baseUrl).toBe(ZAI_CODING_GLOBAL_BASE_URL); }); + it("uses detected Z.AI endpoint without prompting for endpoint selection", async () => { + await setupTempState(); + detectZaiEndpoint.mockResolvedValueOnce({ + endpoint: "coding-global", + modelId: "glm-4.5", + baseUrl: ZAI_CODING_GLOBAL_BASE_URL, + note: "Detected coding-global endpoint", + }); + + const text = vi.fn().mockResolvedValue("zai-detected-key"); + const select = vi.fn(async () => "default"); + const { prompter, runtime } = createApiKeyPromptHarness({ + select: select as WizardPrompter["select"], + text, + }); + + const result = await applyAuthChoice({ + authChoice: "zai-api-key", + config: {}, + prompter, + runtime, + setDefaultModel: true, + }); + + expect(detectZaiEndpoint).toHaveBeenCalledWith({ apiKey: "zai-detected-key" }); + expect(select).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "Select Z.AI endpoint" }), + ); + expect(result.config.models?.providers?.zai?.baseUrl).toBe(ZAI_CODING_GLOBAL_BASE_URL); + expect(result.config.agents?.defaults?.model?.primary).toBe("zai/glm-4.5"); + }); + it("maps apiKey + tokenProvider=huggingface to huggingface-api-key flow", async () => { await setupTempState(); delete process.env.HF_TOKEN; @@ -349,6 +400,309 @@ describe("applyAuthChoice", () => { expect((await readAuthProfile("huggingface:default"))?.key).toBe("hf-token-provider-test"); }); + + it("maps apiKey + tokenProvider=together to together-api-key flow", async () => { + await setupTempState(); + + const text = vi.fn().mockResolvedValue("should-not-be-used"); + const confirm = vi.fn(async () => false); + const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm }); + + const result = await applyAuthChoice({ + authChoice: "apiKey", + config: {}, + prompter, + runtime, + setDefaultModel: true, + opts: { + tokenProvider: " ToGeThEr ", + token: "sk-together-token-provider-test", + }, + }); + + expect(result.config.auth?.profiles?.["together:default"]).toMatchObject({ + provider: "together", + mode: "api_key", + }); + expect(result.config.agents?.defaults?.model?.primary).toMatch(/^together\/.+/); + expect(text).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + expect((await readAuthProfile("together:default"))?.key).toBe( + "sk-together-token-provider-test", + ); + }); + + it("maps apiKey + tokenProvider=KIMI-CODING (case-insensitive) to kimi-code-api-key flow", async () => { + await setupTempState(); + + const text = vi.fn().mockResolvedValue("should-not-be-used"); + const confirm = vi.fn(async () => false); + const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm }); + + const result = await applyAuthChoice({ + authChoice: "apiKey", + config: {}, + prompter, + runtime, + setDefaultModel: true, + opts: { + tokenProvider: "KIMI-CODING", + token: "sk-kimi-token-provider-test", + }, + }); + + expect(result.config.auth?.profiles?.["kimi-coding:default"]).toMatchObject({ + provider: "kimi-coding", + mode: "api_key", + }); + expect(result.config.agents?.defaults?.model?.primary).toMatch(/^kimi-coding\/.+/); + expect(text).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + expect((await readAuthProfile("kimi-coding:default"))?.key).toBe("sk-kimi-token-provider-test"); + }); + + it("maps apiKey + tokenProvider= GOOGLE (case-insensitive/trimmed) to gemini-api-key flow", async () => { + await setupTempState(); + + const text = vi.fn().mockResolvedValue("should-not-be-used"); + const confirm = vi.fn(async () => false); + const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm }); + + const result = await applyAuthChoice({ + authChoice: "apiKey", + config: {}, + prompter, + runtime, + setDefaultModel: true, + opts: { + tokenProvider: " GOOGLE ", + token: "sk-gemini-token-provider-test", + }, + }); + + expect(result.config.auth?.profiles?.["google:default"]).toMatchObject({ + provider: "google", + mode: "api_key", + }); + expect(result.config.agents?.defaults?.model?.primary).toBe(GOOGLE_GEMINI_DEFAULT_MODEL); + expect(text).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + expect((await readAuthProfile("google:default"))?.key).toBe("sk-gemini-token-provider-test"); + }); + + it("maps apiKey + tokenProvider= LITELLM (case-insensitive/trimmed) to litellm-api-key flow", async () => { + await setupTempState(); + + const text = vi.fn().mockResolvedValue("should-not-be-used"); + const confirm = vi.fn(async () => false); + const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm }); + + const result = await applyAuthChoice({ + authChoice: "apiKey", + config: {}, + prompter, + runtime, + setDefaultModel: true, + opts: { + tokenProvider: " LITELLM ", + token: "sk-litellm-token-provider-test", + }, + }); + + expect(result.config.auth?.profiles?.["litellm:default"]).toMatchObject({ + provider: "litellm", + mode: "api_key", + }); + expect(result.config.agents?.defaults?.model?.primary).toMatch(/^litellm\/.+/); + expect(text).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + expect((await readAuthProfile("litellm:default"))?.key).toBe("sk-litellm-token-provider-test"); + }); + + it.each([ + { + authChoice: "moonshot-api-key", + tokenProvider: "moonshot", + profileId: "moonshot:default", + provider: "moonshot", + modelPrefix: "moonshot/", + }, + { + authChoice: "kimi-code-api-key", + tokenProvider: "kimi-code", + profileId: "kimi-coding:default", + provider: "kimi-coding", + modelPrefix: "kimi-coding/", + }, + { + authChoice: "xiaomi-api-key", + tokenProvider: "xiaomi", + profileId: "xiaomi:default", + provider: "xiaomi", + modelPrefix: "xiaomi/", + }, + { + authChoice: "venice-api-key", + tokenProvider: "venice", + profileId: "venice:default", + provider: "venice", + modelPrefix: "venice/", + }, + { + authChoice: "opencode-zen", + tokenProvider: "opencode", + profileId: "opencode:default", + provider: "opencode", + modelPrefix: "opencode/", + }, + { + authChoice: "together-api-key", + tokenProvider: "together", + profileId: "together:default", + provider: "together", + modelPrefix: "together/", + }, + { + authChoice: "qianfan-api-key", + tokenProvider: "qianfan", + profileId: "qianfan:default", + provider: "qianfan", + modelPrefix: "qianfan/", + }, + { + authChoice: "synthetic-api-key", + tokenProvider: "synthetic", + profileId: "synthetic:default", + provider: "synthetic", + modelPrefix: "synthetic/", + }, + ] as const)( + "uses opts token for $authChoice without prompting", + async ({ authChoice, tokenProvider, profileId, provider, modelPrefix }) => { + await setupTempState(); + + const text = vi.fn(); + const confirm = vi.fn(async () => false); + const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm }); + const token = `sk-${tokenProvider}-test`; + + const result = await applyAuthChoice({ + authChoice, + config: {}, + prompter, + runtime, + setDefaultModel: true, + opts: { + tokenProvider, + token, + }, + }); + + expect(text).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + expect(result.config.auth?.profiles?.[profileId]).toMatchObject({ + provider, + mode: "api_key", + }); + expect(result.config.agents?.defaults?.model?.primary?.startsWith(modelPrefix)).toBe(true); + expect((await readAuthProfile(profileId))?.key).toBe(token); + }, + ); + + it("uses opts token for Gemini and keeps global default model when setDefaultModel=false", async () => { + await setupTempState(); + + const text = vi.fn(); + const confirm = vi.fn(async () => false); + const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm }); + + const result = await applyAuthChoice({ + authChoice: "gemini-api-key", + config: { agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } } }, + prompter, + runtime, + setDefaultModel: false, + opts: { + tokenProvider: "google", + token: "sk-gemini-test", + }, + }); + + expect(text).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + expect(result.config.auth?.profiles?.["google:default"]).toMatchObject({ + provider: "google", + mode: "api_key", + }); + expect(result.config.agents?.defaults?.model?.primary).toBe("openai/gpt-4o-mini"); + expect(result.agentModelOverride).toBe(GOOGLE_GEMINI_DEFAULT_MODEL); + expect((await readAuthProfile("google:default"))?.key).toBe("sk-gemini-test"); + }); + + it("prompts for Venice API key and shows the Venice note when no token is provided", async () => { + await setupTempState(); + process.env.VENICE_API_KEY = ""; + + const note = vi.fn(async () => {}); + const text = vi.fn(async () => "sk-venice-manual"); + const prompter = createPrompter({ note, text }); + const runtime = createExitThrowingRuntime(); + + const result = await applyAuthChoice({ + authChoice: "venice-api-key", + config: {}, + prompter, + runtime, + setDefaultModel: true, + }); + + expect(note).toHaveBeenCalledWith( + expect.stringContaining("privacy-focused inference"), + "Venice AI", + ); + expect(text).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Enter Venice AI API key", + }), + ); + expect(result.config.auth?.profiles?.["venice:default"]).toMatchObject({ + provider: "venice", + mode: "api_key", + }); + expect((await readAuthProfile("venice:default"))?.key).toBe("sk-venice-manual"); + }); + + it("uses existing SYNTHETIC_API_KEY when selecting synthetic-api-key", async () => { + await setupTempState(); + process.env.SYNTHETIC_API_KEY = "sk-synthetic-env"; + + const text = vi.fn(); + const confirm = vi.fn(async () => true); + const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm }); + + const result = await applyAuthChoice({ + authChoice: "synthetic-api-key", + config: {}, + prompter, + runtime, + setDefaultModel: true, + }); + + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("SYNTHETIC_API_KEY"), + }), + ); + expect(text).not.toHaveBeenCalled(); + expect(result.config.auth?.profiles?.["synthetic:default"]).toMatchObject({ + provider: "synthetic", + mode: "api_key", + }); + expect(result.config.agents?.defaults?.model?.primary).toMatch(/^synthetic\/.+/); + + expect((await readAuthProfile("synthetic:default"))?.key).toBe("sk-synthetic-env"); + }); + it("does not override the global default model when selecting xai-api-key without setDefaultModel", async () => { await setupTempState(); @@ -654,6 +1008,39 @@ describe("applyAuthChoice", () => { delete process.env.CLOUDFLARE_AI_GATEWAY_API_KEY; }); + it("uses explicit Cloudflare account/gateway/api key opts without extra prompts", async () => { + await setupTempState(); + + const text = vi.fn(); + const confirm = vi.fn(async () => false); + const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm }); + + const result = await applyAuthChoice({ + authChoice: "cloudflare-ai-gateway-api-key", + config: {}, + prompter, + runtime, + setDefaultModel: true, + opts: { + cloudflareAiGatewayAccountId: "acc-direct", + cloudflareAiGatewayGatewayId: "gw-direct", + cloudflareAiGatewayApiKey: "cf-direct-key", + }, + }); + + expect(confirm).not.toHaveBeenCalled(); + expect(text).not.toHaveBeenCalled(); + expect(result.config.auth?.profiles?.["cloudflare-ai-gateway:default"]).toMatchObject({ + provider: "cloudflare-ai-gateway", + mode: "api_key", + }); + expect((await readAuthProfile("cloudflare-ai-gateway:default"))?.key).toBe("cf-direct-key"); + expect((await readAuthProfile("cloudflare-ai-gateway:default"))?.metadata).toEqual({ + accountId: "acc-direct", + gatewayId: "gw-direct", + }); + }); + it("writes Chutes OAuth credentials when selecting chutes (remote/manual)", async () => { await setupTempState(); process.env.SSH_TTY = "1"; diff --git a/src/commands/doctor-config-flow.safe-bins.test.ts b/src/commands/doctor-config-flow.safe-bins.test.ts new file mode 100644 index 000000000..5d1651ce5 --- /dev/null +++ b/src/commands/doctor-config-flow.safe-bins.test.ts @@ -0,0 +1,108 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { withTempHome } from "../../test/helpers/temp-home.js"; + +const { noteSpy } = vi.hoisted(() => ({ + noteSpy: vi.fn(), +})); + +vi.mock("../terminal/note.js", () => ({ + note: noteSpy, +})); + +import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js"; + +async function runDoctorConfigWithInput(params: { + config: Record; + repair?: boolean; +}) { + return withTempHome(async (home) => { + const configDir = path.join(home, ".openclaw"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "openclaw.json"), + JSON.stringify(params.config, null, 2), + "utf-8", + ); + return loadAndMaybeMigrateDoctorConfig({ + options: { nonInteractive: true, repair: params.repair }, + confirm: async () => false, + }); + }); +} + +describe("doctor config flow safe bins", () => { + beforeEach(() => { + noteSpy.mockClear(); + }); + + it("scaffolds missing custom safe-bin profiles on repair but skips interpreter bins", async () => { + const result = await runDoctorConfigWithInput({ + repair: true, + config: { + tools: { + exec: { + safeBins: ["myfilter", "python3"], + }, + }, + agents: { + list: [ + { + id: "ops", + tools: { + exec: { + safeBins: ["mytool", "node"], + }, + }, + }, + ], + }, + }, + }); + + const cfg = result.cfg as { + tools?: { + exec?: { + safeBinProfiles?: Record; + }; + }; + agents?: { + list?: Array<{ + id: string; + tools?: { + exec?: { + safeBinProfiles?: Record; + }; + }; + }>; + }; + }; + expect(cfg.tools?.exec?.safeBinProfiles?.myfilter).toEqual({}); + expect(cfg.tools?.exec?.safeBinProfiles?.python3).toBeUndefined(); + const ops = cfg.agents?.list?.find((entry) => entry.id === "ops"); + expect(ops?.tools?.exec?.safeBinProfiles?.mytool).toEqual({}); + expect(ops?.tools?.exec?.safeBinProfiles?.node).toBeUndefined(); + }); + + it("warns when interpreter/custom safeBins entries are missing profiles in non-repair mode", async () => { + await runDoctorConfigWithInput({ + config: { + tools: { + exec: { + safeBins: ["python3", "myfilter"], + }, + }, + }, + }); + + expect(noteSpy).toHaveBeenCalledWith( + expect.stringContaining("tools.exec.safeBins includes interpreter/runtime 'python3'"), + "Doctor warnings", + ); + expect(noteSpy).toHaveBeenCalledWith( + expect.stringContaining("openclaw doctor --fix"), + "Doctor warnings", + ); + }); +}); diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 0199f8bc5..6b4a5e13f 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -15,6 +15,10 @@ import { readConfigFileSnapshot, } from "../config/config.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; +import { + listInterpreterLikeSafeBins, + resolveMergedSafeBinProfileFixtures, +} from "../infra/exec-safe-bin-runtime-policy.js"; import { listTelegramAccountIds, resolveTelegramAccount } from "../telegram/accounts.js"; import { note } from "../terminal/note.js"; import { isRecord, resolveHomeDir } from "../utils.js"; @@ -704,6 +708,134 @@ function maybeRepairOpenPolicyAllowFrom(cfg: OpenClawConfig): { return { config: next, changes }; } +type ExecSafeBinCoverageHit = { + scopePath: string; + bin: string; + isInterpreter: boolean; +}; + +type ExecSafeBinScopeRef = { + scopePath: string; + safeBins: string[]; + exec: Record; + mergedProfiles: Record; +}; + +function normalizeConfiguredSafeBins(entries: unknown): string[] { + if (!Array.isArray(entries)) { + return []; + } + return Array.from( + new Set( + entries + .map((entry) => (typeof entry === "string" ? entry.trim().toLowerCase() : "")) + .filter((entry) => entry.length > 0), + ), + ).toSorted(); +} + +function collectExecSafeBinScopes(cfg: OpenClawConfig): ExecSafeBinScopeRef[] { + const scopes: ExecSafeBinScopeRef[] = []; + const globalExec = asObjectRecord(cfg.tools?.exec); + if (globalExec) { + const safeBins = normalizeConfiguredSafeBins(globalExec.safeBins); + if (safeBins.length > 0) { + scopes.push({ + scopePath: "tools.exec", + safeBins, + exec: globalExec, + mergedProfiles: + resolveMergedSafeBinProfileFixtures({ + global: globalExec, + }) ?? {}, + }); + } + } + const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; + for (const agent of agents) { + if (!agent || typeof agent !== "object" || typeof agent.id !== "string") { + continue; + } + const agentExec = asObjectRecord(agent.tools?.exec); + if (!agentExec) { + continue; + } + const safeBins = normalizeConfiguredSafeBins(agentExec.safeBins); + if (safeBins.length === 0) { + continue; + } + scopes.push({ + scopePath: `agents.list.${agent.id}.tools.exec`, + safeBins, + exec: agentExec, + mergedProfiles: + resolveMergedSafeBinProfileFixtures({ + global: globalExec, + local: agentExec, + }) ?? {}, + }); + } + return scopes; +} + +function scanExecSafeBinCoverage(cfg: OpenClawConfig): ExecSafeBinCoverageHit[] { + const hits: ExecSafeBinCoverageHit[] = []; + for (const scope of collectExecSafeBinScopes(cfg)) { + const interpreterBins = new Set(listInterpreterLikeSafeBins(scope.safeBins)); + for (const bin of scope.safeBins) { + if (scope.mergedProfiles[bin]) { + continue; + } + hits.push({ + scopePath: scope.scopePath, + bin, + isInterpreter: interpreterBins.has(bin), + }); + } + } + return hits; +} + +function maybeRepairExecSafeBinProfiles(cfg: OpenClawConfig): { + config: OpenClawConfig; + changes: string[]; + warnings: string[]; +} { + const next = structuredClone(cfg); + const changes: string[] = []; + const warnings: string[] = []; + + for (const scope of collectExecSafeBinScopes(next)) { + const interpreterBins = new Set(listInterpreterLikeSafeBins(scope.safeBins)); + const missingBins = scope.safeBins.filter((bin) => !scope.mergedProfiles[bin]); + if (missingBins.length === 0) { + continue; + } + const profileHolder = + asObjectRecord(scope.exec.safeBinProfiles) ?? (scope.exec.safeBinProfiles = {}); + for (const bin of missingBins) { + if (interpreterBins.has(bin)) { + warnings.push( + `- ${scope.scopePath}.safeBins includes interpreter/runtime '${bin}' without profile; remove it from safeBins or use explicit allowlist entries.`, + ); + continue; + } + if (profileHolder[bin] !== undefined) { + continue; + } + profileHolder[bin] = {}; + changes.push( + `- ${scope.scopePath}.safeBinProfiles.${bin}: added scaffold profile {} (review and tighten flags/positionals).`, + ); + } + } + + if (changes.length === 0 && warnings.length === 0) { + return { config: cfg, changes: [], warnings: [] }; + } + return { config: next, changes, warnings }; +} + async function maybeMigrateLegacyConfig(): Promise { const changes: string[] = []; const home = resolveHomeDir(); @@ -859,6 +991,16 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { pendingChanges = true; cfg = allowFromRepair.config; } + const safeBinProfileRepair = maybeRepairExecSafeBinProfiles(candidate); + if (safeBinProfileRepair.changes.length > 0) { + note(safeBinProfileRepair.changes.join("\n"), "Doctor changes"); + candidate = safeBinProfileRepair.config; + pendingChanges = true; + cfg = safeBinProfileRepair.config; + } + if (safeBinProfileRepair.warnings.length > 0) { + note(safeBinProfileRepair.warnings.join("\n"), "Doctor warnings"); + } } else { const hits = scanTelegramAllowFromUsernameEntries(candidate); if (hits.length > 0) { @@ -892,6 +1034,41 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { "Doctor warnings", ); } + + const safeBinCoverage = scanExecSafeBinCoverage(candidate); + if (safeBinCoverage.length > 0) { + const interpreterHits = safeBinCoverage.filter((hit) => hit.isInterpreter); + const customHits = safeBinCoverage.filter((hit) => !hit.isInterpreter); + const lines: string[] = []; + if (interpreterHits.length > 0) { + for (const hit of interpreterHits.slice(0, 5)) { + lines.push( + `- ${hit.scopePath}.safeBins includes interpreter/runtime '${hit.bin}' without profile.`, + ); + } + if (interpreterHits.length > 5) { + lines.push( + `- ${interpreterHits.length - 5} more interpreter/runtime safeBins entries are missing profiles.`, + ); + } + } + if (customHits.length > 0) { + for (const hit of customHits.slice(0, 5)) { + lines.push( + `- ${hit.scopePath}.safeBins entry '${hit.bin}' is missing safeBinProfiles.${hit.bin}.`, + ); + } + if (customHits.length > 5) { + lines.push( + `- ${customHits.length - 5} more custom safeBins entries are missing profiles.`, + ); + } + } + lines.push( + `- Run "${formatCliCommand("openclaw doctor --fix")}" to scaffold missing custom safeBinProfiles entries.`, + ); + note(lines.join("\n"), "Doctor warnings"); + } } const unknown = stripUnknownConfigKeys(candidate); diff --git a/src/commands/doctor-security.test.ts b/src/commands/doctor-security.test.ts index c2f0e6f1e..faee8f192 100644 --- a/src/commands/doctor-security.test.ts +++ b/src/commands/doctor-security.test.ts @@ -48,6 +48,8 @@ describe("noteSecurityWarnings gateway exposure", () => { const message = lastMessage(); expect(message).toContain("CRITICAL"); expect(message).toContain("without authentication"); + expect(message).toContain("Safer remote access"); + expect(message).toContain("ssh -N -L 18789:127.0.0.1:18789"); }); it("uses env token to avoid critical warning", async () => { diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts index f58107e68..cbd93e970 100644 --- a/src/commands/doctor-security.ts +++ b/src/commands/doctor-security.ts @@ -42,6 +42,11 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) { (resolvedAuth.mode === "token" && hasToken) || (resolvedAuth.mode === "password" && hasPassword); const bindDescriptor = `"${gatewayBind}" (${resolvedBindHost})`; + const saferRemoteAccessLines = [ + " Safer remote access: keep bind loopback and use Tailscale Serve/Funnel or an SSH tunnel.", + " Example tunnel: ssh -N -L 18789:127.0.0.1:18789 user@gateway-host", + " Docs: https://docs.openclaw.ai/gateway/remote", + ]; if (isExposed) { if (!hasSharedSecret) { @@ -61,6 +66,7 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) { `- CRITICAL: Gateway bound to ${bindDescriptor} without authentication.`, ` Anyone on your network (or internet if port-forwarded) can fully control your agent.`, ` Fix: ${formatCliCommand("openclaw config set gateway.bind loopback")}`, + ...saferRemoteAccessLines, ...authFixLines, ); } else { @@ -68,6 +74,7 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) { warnings.push( `- WARNING: Gateway bound to ${bindDescriptor} (network-accessible).`, ` Ensure your auth credentials are strong and not exposed.`, + ...saferRemoteAccessLines, ); } } diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index a62fcfb31..d5beae1ce 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -8,6 +8,7 @@ import { loadSessionStore, resolveMainSessionKey, resolveSessionFilePath, + resolveSessionFilePathOptions, resolveSessionTranscriptsDirForAgent, resolveStorePath, } from "../config/sessions.js"; @@ -386,6 +387,7 @@ export async function noteStateIntegrity( } const store = loadSessionStore(storePath); + const sessionPathOpts = resolveSessionFilePathOptions({ agentId, storePath }); const entries = Object.entries(store).filter(([, entry]) => entry && typeof entry === "object"); if (entries.length > 0) { const recent = entries @@ -401,9 +403,7 @@ export async function noteStateIntegrity( if (!sessionId) { return false; } - const transcriptPath = resolveSessionFilePath(sessionId, entry, { - agentId, - }); + const transcriptPath = resolveSessionFilePath(sessionId, entry, sessionPathOpts); return !existsFile(transcriptPath); }); if (missing.length > 0) { @@ -415,7 +415,11 @@ export async function noteStateIntegrity( const mainKey = resolveMainSessionKey(cfg); const mainEntry = store[mainKey]; if (mainEntry?.sessionId) { - const transcriptPath = resolveSessionFilePath(mainEntry.sessionId, mainEntry, { agentId }); + const transcriptPath = resolveSessionFilePath( + mainEntry.sessionId, + mainEntry, + sessionPathOpts, + ); if (!existsFile(transcriptPath)) { warnings.push( `- Main session transcript missing (${shortenHomePath(transcriptPath)}). History will appear to reset.`, diff --git a/src/commands/onboard-config.test.ts b/src/commands/onboard-config.test.ts new file mode 100644 index 000000000..ac98bdc4f --- /dev/null +++ b/src/commands/onboard-config.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { + applyOnboardingLocalWorkspaceConfig, + ONBOARDING_DEFAULT_DM_SCOPE, +} from "./onboard-config.js"; + +describe("applyOnboardingLocalWorkspaceConfig", () => { + it("sets secure dmScope default when unset", () => { + const baseConfig: OpenClawConfig = {}; + const result = applyOnboardingLocalWorkspaceConfig(baseConfig, "/tmp/workspace"); + + expect(result.session?.dmScope).toBe(ONBOARDING_DEFAULT_DM_SCOPE); + expect(result.gateway?.mode).toBe("local"); + expect(result.agents?.defaults?.workspace).toBe("/tmp/workspace"); + }); + + it("preserves existing dmScope when already configured", () => { + const baseConfig: OpenClawConfig = { + session: { + dmScope: "main", + }, + }; + const result = applyOnboardingLocalWorkspaceConfig(baseConfig, "/tmp/workspace"); + + expect(result.session?.dmScope).toBe("main"); + }); + + it("preserves explicit non-main dmScope values", () => { + const baseConfig: OpenClawConfig = { + session: { + dmScope: "per-account-channel-peer", + }, + }; + const result = applyOnboardingLocalWorkspaceConfig(baseConfig, "/tmp/workspace"); + + expect(result.session?.dmScope).toBe("per-account-channel-peer"); + }); +}); diff --git a/src/commands/onboard-config.ts b/src/commands/onboard-config.ts index dc7c8cd4f..3fb6e7308 100644 --- a/src/commands/onboard-config.ts +++ b/src/commands/onboard-config.ts @@ -1,4 +1,7 @@ import type { OpenClawConfig } from "../config/config.js"; +import type { DmScope } from "../config/types.base.js"; + +export const ONBOARDING_DEFAULT_DM_SCOPE: DmScope = "per-channel-peer"; export function applyOnboardingLocalWorkspaceConfig( baseConfig: OpenClawConfig, @@ -17,5 +20,9 @@ export function applyOnboardingLocalWorkspaceConfig( ...baseConfig.gateway, mode: "local", }, + session: { + ...baseConfig.session, + dmScope: baseConfig.session?.dmScope ?? ONBOARDING_DEFAULT_DM_SCOPE, + }, }; } diff --git a/src/commands/onboard-remote.test.ts b/src/commands/onboard-remote.test.ts new file mode 100644 index 000000000..4292a7b09 --- /dev/null +++ b/src/commands/onboard-remote.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import type { GatewayBonjourBeacon } from "../infra/bonjour-discovery.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; +import { createWizardPrompter } from "./test-wizard-helpers.js"; + +const discoverGatewayBeacons = vi.hoisted(() => vi.fn<() => Promise>()); +const resolveWideAreaDiscoveryDomain = vi.hoisted(() => vi.fn(() => undefined)); +const detectBinary = vi.hoisted(() => vi.fn<(name: string) => Promise>()); + +vi.mock("../infra/bonjour-discovery.js", () => ({ + discoverGatewayBeacons, +})); + +vi.mock("../infra/widearea-dns.js", () => ({ + resolveWideAreaDiscoveryDomain, +})); + +vi.mock("./onboard-helpers.js", () => ({ + detectBinary, +})); + +const { promptRemoteGatewayConfig } = await import("./onboard-remote.js"); + +function createPrompter(overrides: Partial): WizardPrompter { + return createWizardPrompter(overrides, { defaultSelect: "" }); +} + +describe("promptRemoteGatewayConfig", () => { + beforeEach(() => { + vi.clearAllMocks(); + detectBinary.mockResolvedValue(false); + discoverGatewayBeacons.mockResolvedValue([]); + resolveWideAreaDiscoveryDomain.mockReturnValue(undefined); + }); + + it("defaults discovered direct remote URLs to wss://", async () => { + detectBinary.mockResolvedValue(true); + discoverGatewayBeacons.mockResolvedValue([ + { + instanceName: "gateway", + displayName: "Gateway", + host: "gateway.tailnet.ts.net", + port: 18789, + }, + ]); + + const select: WizardPrompter["select"] = vi.fn(async (params) => { + if (params.message === "Select gateway") { + return "0" as never; + } + if (params.message === "Connection method") { + return "direct" as never; + } + if (params.message === "Gateway auth") { + return "token" as never; + } + return (params.options[0]?.value ?? "") as never; + }); + + const text: WizardPrompter["text"] = vi.fn(async (params) => { + if (params.message === "Gateway WebSocket URL") { + expect(params.initialValue).toBe("wss://gateway.tailnet.ts.net:18789"); + expect(params.validate?.(String(params.initialValue))).toBeUndefined(); + return String(params.initialValue); + } + if (params.message === "Gateway token") { + return "token-123"; + } + return ""; + }) as WizardPrompter["text"]; + + const cfg = {} as OpenClawConfig; + const prompter = createPrompter({ + confirm: vi.fn(async () => true), + select, + text, + }); + + const next = await promptRemoteGatewayConfig(cfg, prompter); + + expect(next.gateway?.mode).toBe("remote"); + expect(next.gateway?.remote?.url).toBe("wss://gateway.tailnet.ts.net:18789"); + expect(next.gateway?.remote?.token).toBe("token-123"); + expect(prompter.note).toHaveBeenCalledWith( + expect.stringContaining("Direct remote access defaults to TLS."), + "Direct remote", + ); + }); + + it("validates insecure ws:// remote URLs and allows loopback ws://", async () => { + const text: WizardPrompter["text"] = vi.fn(async (params) => { + if (params.message === "Gateway WebSocket URL") { + expect(params.validate?.("ws://10.0.0.8:18789")).toContain("Use wss://"); + expect(params.validate?.("ws://127.0.0.1:18789")).toBeUndefined(); + expect(params.validate?.("wss://remote.example.com:18789")).toBeUndefined(); + return "wss://remote.example.com:18789"; + } + return ""; + }) as WizardPrompter["text"]; + + const select: WizardPrompter["select"] = vi.fn(async (params) => { + if (params.message === "Gateway auth") { + return "off" as never; + } + return (params.options[0]?.value ?? "") as never; + }); + + const cfg = {} as OpenClawConfig; + const prompter = createPrompter({ + confirm: vi.fn(async () => false), + select, + text, + }); + + const next = await promptRemoteGatewayConfig(cfg, prompter); + + expect(next.gateway?.mode).toBe("remote"); + expect(next.gateway?.remote?.url).toBe("wss://remote.example.com:18789"); + expect(next.gateway?.remote?.token).toBeUndefined(); + }); +}); diff --git a/src/commands/onboard-remote.ts b/src/commands/onboard-remote.ts index 01c1c9941..3126a0d9f 100644 --- a/src/commands/onboard-remote.ts +++ b/src/commands/onboard-remote.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/config.js"; +import { isSecureWebSocketUrl } from "../gateway/net.js"; import type { GatewayBonjourBeacon } from "../infra/bonjour-discovery.js"; import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js"; import { resolveWideAreaDiscoveryDomain } from "../infra/widearea-dns.js"; @@ -29,6 +30,17 @@ function ensureWsUrl(value: string): string { return trimmed; } +function validateGatewayWebSocketUrl(value: string): string | undefined { + const trimmed = value.trim(); + if (!trimmed.startsWith("ws://") && !trimmed.startsWith("wss://")) { + return "URL must start with ws:// or wss://"; + } + if (!isSecureWebSocketUrl(trimmed)) { + return "Use wss:// for remote hosts, or ws://127.0.0.1/localhost via SSH tunnel."; + } + return undefined; +} + export async function promptRemoteGatewayConfig( cfg: OpenClawConfig, prompter: WizardPrompter, @@ -95,7 +107,15 @@ export async function promptRemoteGatewayConfig( ], }); if (mode === "direct") { - suggestedUrl = `ws://${host}:${port}`; + suggestedUrl = `wss://${host}:${port}`; + await prompter.note( + [ + "Direct remote access defaults to TLS.", + `Using: ${suggestedUrl}`, + "If your gateway is loopback-only, choose SSH tunnel and keep ws://127.0.0.1:18789.", + ].join("\n"), + "Direct remote", + ); } else { suggestedUrl = DEFAULT_GATEWAY_URL; await prompter.note( @@ -115,10 +135,7 @@ export async function promptRemoteGatewayConfig( const urlInput = await prompter.text({ message: "Gateway WebSocket URL", initialValue: suggestedUrl, - validate: (value) => - String(value).trim().startsWith("ws://") || String(value).trim().startsWith("wss://") - ? undefined - : "URL must start with ws:// or wss://", + validate: (value) => validateGatewayWebSocketUrl(String(value)), }); const url = ensureWsUrl(String(urlInput)); diff --git a/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts b/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts index 7d2a54ddb..e4a5ddcfd 100644 --- a/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts +++ b/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts @@ -363,6 +363,16 @@ describe("legacy config detection", () => { expectedValue: "work", }); }); + it("accepts bindings[].comment on load", () => { + expectValidConfigValue({ + config: { + bindings: [{ agentId: "main", comment: "primary route", match: { channel: "telegram" } }], + }, + readValue: (config) => + (config as { bindings?: Array<{ comment?: string }> }).bindings?.[0]?.comment, + expectedValue: "primary route", + }); + }); it("rejects session.sendPolicy.rules[].match.provider on load", async () => { await withTempHome(async (home) => { const configPath = path.join(home, ".openclaw", "openclaw.json"); diff --git a/src/config/io.compat.test.ts b/src/config/io.compat.test.ts index 7da811a76..6ac794b19 100644 --- a/src/config/io.compat.test.ts +++ b/src/config/io.compat.test.ts @@ -77,4 +77,60 @@ describe("config io paths", () => { expect(io.loadConfig().gateway?.port).toBe(20003); }); }); + + it("normalizes safeBinProfiles at config load time", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".openclaw"); + await fs.mkdir(configDir, { recursive: true }); + const configPath = path.join(configDir, "openclaw.json"); + await fs.writeFile( + configPath, + JSON.stringify( + { + tools: { + exec: { + safeBinProfiles: { + " MyFilter ": { + allowedValueFlags: ["--limit", " --limit ", ""], + }, + }, + }, + }, + agents: { + list: [ + { + id: "ops", + tools: { + exec: { + safeBinProfiles: { + " Custom ": { + deniedFlags: ["-f", " -f ", ""], + }, + }, + }, + }, + }, + ], + }, + }, + null, + 2, + ), + "utf-8", + ); + const io = createIoForHome(home); + expect(io.configPath).toBe(configPath); + const cfg = io.loadConfig(); + expect(cfg.tools?.exec?.safeBinProfiles).toEqual({ + myfilter: { + allowedValueFlags: ["--limit"], + }, + }); + expect(cfg.agents?.list?.[0]?.tools?.exec?.safeBinProfiles).toEqual({ + custom: { + deniedFlags: ["-f"], + }, + }); + }); + }); }); diff --git a/src/config/io.ts b/src/config/io.ts index c5df09e43..2a41883f7 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -6,6 +6,7 @@ import { isDeepStrictEqual } from "node:util"; import JSON5 from "json5"; import { ensureOwnerDisplaySecret } from "../agents/owner-display.js"; import { loadDotEnv } from "../infra/dotenv.js"; +import { normalizeSafeBinProfileFixtures } from "../infra/exec-safe-bin-policy.js"; import { resolveRequiredHomeDir } from "../infra/home-dir.js"; import { loadShellEnvFallback, @@ -555,6 +556,33 @@ function maybeLoadDotEnvForConfig(env: NodeJS.ProcessEnv): void { loadDotEnv({ quiet: true }); } +function normalizeExecSafeBinProfilesInConfig(cfg: OpenClawConfig): void { + const normalizeExec = (exec: unknown) => { + if (!exec || typeof exec !== "object" || Array.isArray(exec)) { + return; + } + const typedExec = exec as { safeBinProfiles?: Record }; + const normalized = normalizeSafeBinProfileFixtures( + typedExec.safeBinProfiles as Record< + string, + { + minPositional?: number; + maxPositional?: number; + allowedValueFlags?: readonly string[]; + deniedFlags?: readonly string[]; + } + >, + ); + typedExec.safeBinProfiles = Object.keys(normalized).length > 0 ? normalized : undefined; + }; + + normalizeExec(cfg.tools?.exec); + const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; + for (const agent of agents) { + normalizeExec(agent?.tools?.exec); + } +} + export function parseConfigJson5( raw: string, json5: { parse: (value: string) => unknown } = JSON5, @@ -675,6 +703,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { ), ); normalizeConfigPaths(cfg); + normalizeExecSafeBinProfilesInConfig(cfg); const duplicates = findDuplicateAgentDirs(cfg, { env: deps.env, @@ -875,6 +904,16 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { } warnIfConfigFromFuture(validated.config, deps.logger); + const snapshotConfig = normalizeConfigPaths( + applyTalkApiKey( + applyModelDefaults( + applyAgentDefaults( + applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))), + ), + ), + ), + ); + normalizeExecSafeBinProfilesInConfig(snapshotConfig); return { snapshot: { path: configPath, @@ -885,17 +924,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { // for config set/unset operations (issue #6070) resolved: coerceConfig(resolvedConfigRaw), valid: true, - config: normalizeConfigPaths( - applyTalkApiKey( - applyModelDefaults( - applyAgentDefaults( - applySessionDefaults( - applyLoggingDefaults(applyMessageDefaults(validated.config)), - ), - ), - ), - ), - ), + config: snapshotConfig, hash, issues: [], warnings: validated.warnings, diff --git a/src/config/runtime-group-policy.test.ts b/src/config/runtime-group-policy.test.ts new file mode 100644 index 000000000..5475fc064 --- /dev/null +++ b/src/config/runtime-group-policy.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + GROUP_POLICY_BLOCKED_LABEL, + resetMissingProviderGroupPolicyFallbackWarningsForTesting, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + resolveOpenProviderRuntimeGroupPolicy, + resolveRuntimeGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, +} from "./runtime-group-policy.js"; + +beforeEach(() => { + resetMissingProviderGroupPolicyFallbackWarningsForTesting(); +}); + +describe("resolveRuntimeGroupPolicy", () => { + it.each([ + { + title: "fails closed when provider config is missing and no defaults are set", + params: { providerConfigPresent: false }, + expectedPolicy: "allowlist", + expectedFallbackApplied: true, + }, + { + title: "keeps configured fallback when provider config is present", + params: { providerConfigPresent: true, configuredFallbackPolicy: "open" as const }, + expectedPolicy: "open", + expectedFallbackApplied: false, + }, + { + title: "ignores global defaults when provider config is missing", + params: { + providerConfigPresent: false, + defaultGroupPolicy: "disabled" as const, + configuredFallbackPolicy: "open" as const, + missingProviderFallbackPolicy: "allowlist" as const, + }, + expectedPolicy: "allowlist", + expectedFallbackApplied: true, + }, + ])("$title", ({ params, expectedPolicy, expectedFallbackApplied }) => { + const resolved = resolveRuntimeGroupPolicy(params); + expect(resolved.groupPolicy).toBe(expectedPolicy); + expect(resolved.providerMissingFallbackApplied).toBe(expectedFallbackApplied); + }); +}); + +describe("resolveOpenProviderRuntimeGroupPolicy", () => { + it("uses open fallback when provider config exists", () => { + const resolved = resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent: true, + }); + expect(resolved.groupPolicy).toBe("open"); + expect(resolved.providerMissingFallbackApplied).toBe(false); + }); +}); + +describe("resolveAllowlistProviderRuntimeGroupPolicy", () => { + it("uses allowlist fallback when provider config exists", () => { + const resolved = resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: true, + }); + expect(resolved.groupPolicy).toBe("allowlist"); + expect(resolved.providerMissingFallbackApplied).toBe(false); + }); +}); + +describe("resolveDefaultGroupPolicy", () => { + it("returns channels.defaults.groupPolicy when present", () => { + const resolved = resolveDefaultGroupPolicy({ + channels: { defaults: { groupPolicy: "disabled" } }, + }); + expect(resolved).toBe("disabled"); + }); +}); + +describe("warnMissingProviderGroupPolicyFallbackOnce", () => { + it("logs only once per provider/account key", () => { + const lines: string[] = []; + const first = warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied: true, + providerKey: "runtime-policy-test", + accountId: "account-a", + blockedLabel: GROUP_POLICY_BLOCKED_LABEL.room, + log: (message) => lines.push(message), + }); + const second = warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied: true, + providerKey: "runtime-policy-test", + accountId: "account-a", + blockedLabel: GROUP_POLICY_BLOCKED_LABEL.room, + log: (message) => lines.push(message), + }); + + expect(first).toBe(true); + expect(second).toBe(false); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain("channels.runtime-policy-test is missing"); + expect(lines[0]).toContain("room messages blocked"); + }); +}); diff --git a/src/config/runtime-group-policy.ts b/src/config/runtime-group-policy.ts new file mode 100644 index 000000000..62ee6db7d --- /dev/null +++ b/src/config/runtime-group-policy.ts @@ -0,0 +1,118 @@ +import type { GroupPolicy } from "./types.base.js"; + +export type RuntimeGroupPolicyResolution = { + groupPolicy: GroupPolicy; + providerMissingFallbackApplied: boolean; +}; + +export type RuntimeGroupPolicyParams = { + providerConfigPresent: boolean; + groupPolicy?: GroupPolicy; + defaultGroupPolicy?: GroupPolicy; + configuredFallbackPolicy?: GroupPolicy; + missingProviderFallbackPolicy?: GroupPolicy; +}; + +export function resolveRuntimeGroupPolicy( + params: RuntimeGroupPolicyParams, +): RuntimeGroupPolicyResolution { + const configuredFallbackPolicy = params.configuredFallbackPolicy ?? "open"; + const missingProviderFallbackPolicy = params.missingProviderFallbackPolicy ?? "allowlist"; + const groupPolicy = params.providerConfigPresent + ? (params.groupPolicy ?? params.defaultGroupPolicy ?? configuredFallbackPolicy) + : (params.groupPolicy ?? missingProviderFallbackPolicy); + const providerMissingFallbackApplied = + !params.providerConfigPresent && params.groupPolicy === undefined; + return { groupPolicy, providerMissingFallbackApplied }; +} + +export type ResolveProviderRuntimeGroupPolicyParams = { + providerConfigPresent: boolean; + groupPolicy?: GroupPolicy; + defaultGroupPolicy?: GroupPolicy; +}; + +export type GroupPolicyDefaultsConfig = { + channels?: { + defaults?: { + groupPolicy?: GroupPolicy; + }; + }; +}; + +export function resolveDefaultGroupPolicy(cfg: GroupPolicyDefaultsConfig): GroupPolicy | undefined { + return cfg.channels?.defaults?.groupPolicy; +} + +export const GROUP_POLICY_BLOCKED_LABEL = { + group: "group messages", + guild: "guild messages", + room: "room messages", + channel: "channel messages", + space: "space messages", +} as const; + +/** + * Standard provider runtime policy: + * - configured provider fallback: open + * - missing provider fallback: allowlist (fail-closed) + */ +export function resolveOpenProviderRuntimeGroupPolicy( + params: ResolveProviderRuntimeGroupPolicyParams, +): RuntimeGroupPolicyResolution { + return resolveRuntimeGroupPolicy({ + providerConfigPresent: params.providerConfigPresent, + groupPolicy: params.groupPolicy, + defaultGroupPolicy: params.defaultGroupPolicy, + configuredFallbackPolicy: "open", + missingProviderFallbackPolicy: "allowlist", + }); +} + +/** + * Strict provider runtime policy: + * - configured provider fallback: allowlist + * - missing provider fallback: allowlist (fail-closed) + */ +export function resolveAllowlistProviderRuntimeGroupPolicy( + params: ResolveProviderRuntimeGroupPolicyParams, +): RuntimeGroupPolicyResolution { + return resolveRuntimeGroupPolicy({ + providerConfigPresent: params.providerConfigPresent, + groupPolicy: params.groupPolicy, + defaultGroupPolicy: params.defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); +} + +const warnedMissingProviderGroupPolicy = new Set(); + +export function warnMissingProviderGroupPolicyFallbackOnce(params: { + providerMissingFallbackApplied: boolean; + providerKey: string; + accountId?: string; + blockedLabel?: string; + log: (message: string) => void; +}): boolean { + if (!params.providerMissingFallbackApplied) { + return false; + } + const key = `${params.providerKey}:${params.accountId ?? "*"}`; + if (warnedMissingProviderGroupPolicy.has(key)) { + return false; + } + warnedMissingProviderGroupPolicy.add(key); + const blockedLabel = params.blockedLabel?.trim() || "group messages"; + params.log( + `${params.providerKey}: channels.${params.providerKey} is missing; defaulting groupPolicy to "allowlist" (${blockedLabel} blocked until explicitly configured).`, + ); + return true; +} + +/** + * Test helper. Keeps warning-cache state deterministic across test files. + */ +export function resetMissingProviderGroupPolicyFallbackWarningsForTesting(): void { + warnedMissingProviderGroupPolicy.clear(); +} diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 144a72ecd..18c53b5f9 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -88,12 +88,14 @@ export const FIELD_HELP: Record = { "Enable known poll tool no-progress loop detection (default: true).", "tools.loopDetection.detectors.pingPong": "Enable ping-pong loop detection (default: true).", "tools.exec.notifyOnExit": - "When true (default), backgrounded exec sessions enqueue a system event and request a heartbeat on exit.", + "When true (default), backgrounded exec sessions on exit and node exec lifecycle events enqueue a system event and request a heartbeat.", "tools.exec.notifyOnExitEmptySuccess": "When true, successful backgrounded exec exits with empty output still enqueue a completion system event (default: false).", "tools.exec.pathPrepend": "Directories to prepend to PATH for exec runs (gateway/sandbox).", "tools.exec.safeBins": "Allow stdin-only safe binaries to run without explicit allowlist entries.", + "tools.exec.safeBinProfiles": + "Optional per-binary safe-bin profiles (positional limits + allowed/denied flags).", "tools.fs.workspaceOnly": "Restrict filesystem tools (read/write/edit/apply_patch) to the workspace directory (default: false).", "tools.sessions.visibility": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 1a7ab498e..0563341dc 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -92,6 +92,7 @@ export const FIELD_LABELS: Record = { "tools.exec.node": "Exec Node Binding", "tools.exec.pathPrepend": "Exec PATH Prepend", "tools.exec.safeBins": "Exec Safe Bins", + "tools.exec.safeBinProfiles": "Exec Safe Bin Profiles", "tools.message.allowCrossContextSend": "Allow Cross-Context Messaging", "tools.message.crossContext.allowWithinProvider": "Allow Cross-Context (Same Provider)", "tools.message.crossContext.allowAcrossProviders": "Allow Cross-Context (Across Providers)", diff --git a/src/config/types.agents.ts b/src/config/types.agents.ts index 2816d33a7..478e14e52 100644 --- a/src/config/types.agents.ts +++ b/src/config/types.agents.ts @@ -72,6 +72,7 @@ export type AgentsConfig = { export type AgentBinding = { agentId: string; + comment?: string; match: { channel: string; accountId?: string; diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index bdfde8209..1cf81f771 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -1,4 +1,5 @@ import type { ChatType } from "../channels/chat-type.js"; +import type { SafeBinProfileFixture } from "../infra/exec-safe-bin-policy.js"; import type { AgentElevatedAllowFromConfig, SessionSendPolicyAction } from "./types.base.js"; export type MediaUnderstandingScopeMatch = { @@ -190,6 +191,8 @@ export type ExecToolConfig = { pathPrepend?: string[]; /** Safe stdin-only binaries that can run without allowlist entries. */ safeBins?: string[]; + /** Optional custom safe-bin profiles for entries in tools.exec.safeBins. */ + safeBinProfiles?: Record; /** Default time (ms) before an exec command auto-backgrounds. */ backgroundMs?: number; /** Default timeout (seconds) before auto-killing exec commands. */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 6e0a92cfd..f3f5a8b7a 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -337,6 +337,15 @@ const ToolExecApplyPatchSchema = z .strict() .optional(); +const ToolExecSafeBinProfileSchema = z + .object({ + minPositional: z.number().int().nonnegative().optional(), + maxPositional: z.number().int().nonnegative().optional(), + allowedValueFlags: z.array(z.string()).optional(), + deniedFlags: z.array(z.string()).optional(), + }) + .strict(); + const ToolExecBaseShape = { host: z.enum(["sandbox", "gateway", "node"]).optional(), security: z.enum(["deny", "allowlist", "full"]).optional(), @@ -344,6 +353,7 @@ const ToolExecBaseShape = { node: z.string().optional(), pathPrepend: z.array(z.string()).optional(), safeBins: z.array(z.string()).optional(), + safeBinProfiles: z.record(z.string(), ToolExecSafeBinProfileSchema).optional(), backgroundMs: z.number().int().positive().optional(), timeoutSec: z.number().int().positive().optional(), cleanupMs: z.number().int().positive().optional(), diff --git a/src/config/zod-schema.agents.ts b/src/config/zod-schema.agents.ts index 704d1752c..c7c921a5e 100644 --- a/src/config/zod-schema.agents.ts +++ b/src/config/zod-schema.agents.ts @@ -16,6 +16,7 @@ export const BindingsSchema = z z .object({ agentId: z.string(), + comment: z.string().optional(), match: z .object({ channel: z.string(), diff --git a/src/cron/service.every-jobs-fire.test.ts b/src/cron/service.every-jobs-fire.test.ts index f1ef2d9ee..fa7b53e59 100644 --- a/src/cron/service.every-jobs-fire.test.ts +++ b/src/cron/service.every-jobs-fire.test.ts @@ -1,5 +1,3 @@ -import fs from "node:fs/promises"; -import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { CronService } from "./service.js"; import { @@ -7,6 +5,7 @@ import { createCronStoreHarness, createNoopLogger, installCronTestHooks, + writeCronStoreSnapshot, } from "./service.test-harness.js"; const noopLogger = createNoopLogger(); @@ -120,44 +119,35 @@ describe("CronService interval/cron jobs fire on time", () => { const requestHeartbeatNow = vi.fn(); const nowMs = Date.parse("2025-12-13T00:00:00.000Z"); - await fs.mkdir(path.dirname(store.storePath), { recursive: true }); - await fs.writeFile( - store.storePath, - JSON.stringify( + await writeCronStoreSnapshot({ + storePath: store.storePath, + jobs: [ { - version: 1, - jobs: [ - { - id: "legacy-every", - name: "legacy every", - enabled: true, - createdAtMs: nowMs, - updatedAtMs: nowMs, - schedule: { kind: "every", everyMs: 120_000 }, - sessionTarget: "main", - wakeMode: "now", - payload: { kind: "systemEvent", text: "sf-tick" }, - state: { nextRunAtMs: nowMs + 120_000 }, - }, - { - id: "minute-cron", - name: "minute cron", - enabled: true, - createdAtMs: nowMs, - updatedAtMs: nowMs, - schedule: { kind: "cron", expr: "* * * * *", tz: "UTC" }, - sessionTarget: "main", - wakeMode: "now", - payload: { kind: "systemEvent", text: "minute-tick" }, - state: { nextRunAtMs: nowMs + 60_000 }, - }, - ], + id: "legacy-every", + name: "legacy every", + enabled: true, + createdAtMs: nowMs, + updatedAtMs: nowMs, + schedule: { kind: "every", everyMs: 120_000 }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "systemEvent", text: "sf-tick" }, + state: { nextRunAtMs: nowMs + 120_000 }, }, - null, - 2, - ), - "utf-8", - ); + { + id: "minute-cron", + name: "minute cron", + enabled: true, + createdAtMs: nowMs, + updatedAtMs: nowMs, + schedule: { kind: "cron", expr: "* * * * *", tz: "UTC" }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "systemEvent", text: "minute-tick" }, + state: { nextRunAtMs: nowMs + 60_000 }, + }, + ], + }); const cron = new CronService({ storePath: store.storePath, diff --git a/src/cron/service.read-ops-nonblocking.test.ts b/src/cron/service.read-ops-nonblocking.test.ts index 120061de4..e6a24957a 100644 --- a/src/cron/service.read-ops-nonblocking.test.ts +++ b/src/cron/service.read-ops-nonblocking.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { CronService } from "./service.js"; +import { writeCronStoreSnapshot } from "./service.test-harness.js"; const noopLogger = { debug: vi.fn(), @@ -167,29 +168,24 @@ describe("CronService read ops while job is running", () => { const requestHeartbeatNow = vi.fn(); const nowMs = Date.parse("2025-12-13T00:00:00.000Z"); - await fs.mkdir(path.dirname(store.storePath), { recursive: true }); - await fs.writeFile( - store.storePath, - JSON.stringify({ - version: 1, - jobs: [ - { - id: "startup-catchup", - name: "startup catch-up", - enabled: true, - createdAtMs: nowMs - 86_400_000, - updatedAtMs: nowMs - 86_400_000, - schedule: { kind: "at", at: new Date(nowMs - 60_000).toISOString() }, - sessionTarget: "isolated", - wakeMode: "next-heartbeat", - payload: { kind: "agentTurn", message: "startup replay" }, - delivery: { mode: "none" }, - state: { nextRunAtMs: nowMs - 60_000 }, - }, - ], - }), - "utf-8", - ); + await writeCronStoreSnapshot({ + storePath: store.storePath, + jobs: [ + { + id: "startup-catchup", + name: "startup catch-up", + enabled: true, + createdAtMs: nowMs - 86_400_000, + updatedAtMs: nowMs - 86_400_000, + schedule: { kind: "at", at: new Date(nowMs - 60_000).toISOString() }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "startup replay" }, + delivery: { mode: "none" }, + state: { nextRunAtMs: nowMs - 60_000 }, + }, + ], + }); const isolatedRun = createDeferredIsolatedRun(); diff --git a/src/cron/service.test-harness.ts b/src/cron/service.test-harness.ts index 641f8fd3a..5ed45e337 100644 --- a/src/cron/service.test-harness.ts +++ b/src/cron/service.test-harness.ts @@ -51,6 +51,22 @@ export function createCronStoreHarness(options?: { prefix?: string }) { return { makeStorePath }; } +export async function writeCronStoreSnapshot(params: { storePath: string; jobs: CronJob[] }) { + await fs.mkdir(path.dirname(params.storePath), { recursive: true }); + await fs.writeFile( + params.storePath, + JSON.stringify( + { + version: 1, + jobs: params.jobs, + }, + null, + 2, + ), + "utf-8", + ); +} + export function installCronTestHooks(options: { logger: ReturnType; baseTimeIso?: string; diff --git a/src/discord/monitor/message-handler.ts b/src/discord/monitor/message-handler.ts index aceae950d..fd69ff4e3 100644 --- a/src/discord/monitor/message-handler.ts +++ b/src/discord/monitor/message-handler.ts @@ -4,6 +4,7 @@ import { createInboundDebouncer, resolveInboundDebounceMs, } from "../../auto-reply/inbound-debounce.js"; +import { resolveOpenProviderRuntimeGroupPolicy } from "../../config/runtime-group-policy.js"; import { danger } from "../../globals.js"; import type { DiscordMessageEvent, DiscordMessageHandler } from "./listeners.js"; import { preflightDiscordMessage } from "./message-handler.preflight.js"; @@ -23,7 +24,11 @@ type DiscordMessageHandlerParams = Omit< export function createDiscordMessageHandler( params: DiscordMessageHandlerParams, ): DiscordMessageHandler { - const groupPolicy = params.discordConfig?.groupPolicy ?? "open"; + const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent: params.cfg.channels?.discord !== undefined, + groupPolicy: params.discordConfig?.groupPolicy, + defaultGroupPolicy: params.cfg.channels?.defaults?.groupPolicy, + }); const ackReactionScope = params.cfg.messages?.ackReactionScope ?? "group-mentions"; const debounceMs = resolveInboundDebounceMs({ cfg: params.cfg, channel: "discord" }); diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index cc45838c3..adad1be70 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -39,6 +39,7 @@ import type { ReplyPayload } from "../../auto-reply/types.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; import type { OpenClawConfig, loadConfig } from "../../config/config.js"; +import { resolveOpenProviderRuntimeGroupPolicy } from "../../config/runtime-group-policy.js"; import { loadSessionStore, resolveStorePath } from "../../config/sessions.js"; import { logVerbose } from "../../globals.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; @@ -1329,8 +1330,13 @@ async function dispatchDiscordCommandInteraction(params: { const channelAllowlistConfigured = Boolean(guildInfo?.channels) && Object.keys(guildInfo?.channels ?? {}).length > 0; const channelAllowed = channelConfig?.allowed !== false; + const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.discord !== undefined, + groupPolicy: discordConfig?.groupPolicy, + defaultGroupPolicy: cfg.channels?.defaults?.groupPolicy, + }); const allowByPolicy = isDiscordGroupAllowedByPolicy({ - groupPolicy: discordConfig?.groupPolicy ?? "open", + groupPolicy, guildAllowlisted: Boolean(guildInfo), channelAllowlistConfigured, channelAllowed, diff --git a/src/discord/monitor/provider.group-policy.test.ts b/src/discord/monitor/provider.group-policy.test.ts new file mode 100644 index 000000000..48d4f6761 --- /dev/null +++ b/src/discord/monitor/provider.group-policy.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +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); + }); + + it("respects explicit provider policy", () => { + const resolved = __testing.resolveDiscordRuntimeGroupPolicy({ + providerConfigPresent: false, + groupPolicy: "disabled", + }); + 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 ff16a2621..828b35759 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -21,6 +21,12 @@ import { } from "../../config/commands.js"; import type { OpenClawConfig, ReplyToMode } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; +import { + GROUP_POLICY_BLOCKED_LABEL, + resolveOpenProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, +} from "../../config/runtime-group-policy.js"; import { danger, logVerbose, shouldLogVerbose, warn } from "../../globals.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { createDiscordRetryRunner } from "../../infra/retry-policy.js"; @@ -245,27 +251,29 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime(); - const discordCfg = account.config; + const rawDiscordCfg = account.config; const discordRootThreadBindings = cfg.channels?.discord?.threadBindings; const discordAccountThreadBindings = cfg.channels?.discord?.accounts?.[account.accountId]?.threadBindings; - const discordRestFetch = resolveDiscordRestFetch(discordCfg.proxy, runtime); - const dmConfig = discordCfg.dm; - let guildEntries = discordCfg.guilds; - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = discordCfg.groupPolicy ?? defaultGroupPolicy ?? "open"; - if ( - discordCfg.groupPolicy === undefined && - discordCfg.guilds === undefined && - defaultGroupPolicy === undefined && - groupPolicy === "open" - ) { - runtime.log?.( - warn( - 'discord: groupPolicy defaults to "open" when channels.discord is missing; set channels.discord.groupPolicy (or channels.defaults.groupPolicy) or add channels.discord.guilds to restrict access.', - ), - ); - } + const discordRestFetch = resolveDiscordRestFetch(rawDiscordCfg.proxy, runtime); + const dmConfig = rawDiscordCfg.dm; + let guildEntries = rawDiscordCfg.guilds; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const providerConfigPresent = cfg.channels?.discord !== undefined; + const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent, + groupPolicy: rawDiscordCfg.groupPolicy, + defaultGroupPolicy, + }); + const discordCfg = + rawDiscordCfg.groupPolicy === groupPolicy ? rawDiscordCfg : { ...rawDiscordCfg, groupPolicy }; + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "discord", + accountId: account.accountId, + blockedLabel: GROUP_POLICY_BLOCKED_LABEL.guild, + log: (message) => runtime.log?.(warn(message)), + }); let allowFrom = discordCfg.allowFrom ?? dmConfig?.allowFrom; const mediaMaxBytes = (opts.mediaMaxMb ?? discordCfg.mediaMaxMb ?? 8) * 1024 * 1024; const textLimit = resolveTextChunkLimit(cfg, "discord", account.accountId, { @@ -622,6 +630,8 @@ async function clearDiscordNativeCommands(params: { export const __testing = { createDiscordGatewayPlugin, dedupeSkillCommandsForDiscord, + resolveDiscordRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, resolveDiscordRestFetch, resolveThreadBindingsEnabled, }; diff --git a/src/discord/send.outbound.ts b/src/discord/send.outbound.ts index 64ee07e71..979054b43 100644 --- a/src/discord/send.outbound.ts +++ b/src/discord/send.outbound.ts @@ -62,6 +62,31 @@ type DiscordChannelMessageResult = { channel_id?: string | null; }; +async function sendDiscordThreadTextChunks(params: { + rest: RequestClient; + threadId: string; + chunks: readonly string[]; + request: DiscordClientRequest; + maxLinesPerMessage?: number; + chunkMode: ReturnType; + silent?: boolean; +}): Promise { + for (const chunk of params.chunks) { + await sendDiscordText( + params.rest, + params.threadId, + chunk, + undefined, + params.request, + params.maxLinesPerMessage, + undefined, + undefined, + params.chunkMode, + params.silent, + ); + } +} + /** Discord thread names are capped at 100 characters. */ const DISCORD_THREAD_NAME_LIMIT = 100; @@ -194,35 +219,25 @@ export async function sendMessageDiscord( chunkMode, opts.silent, ); - for (const chunk of afterMediaChunks) { - await sendDiscordText( - rest, - threadId, - chunk, - undefined, - request, - accountInfo.config.maxLinesPerMessage, - undefined, - undefined, - chunkMode, - opts.silent, - ); - } + await sendDiscordThreadTextChunks({ + rest, + threadId, + chunks: afterMediaChunks, + request, + maxLinesPerMessage: accountInfo.config.maxLinesPerMessage, + chunkMode, + silent: opts.silent, + }); } else { - for (const chunk of remainingChunks) { - await sendDiscordText( - rest, - threadId, - chunk, - undefined, - request, - accountInfo.config.maxLinesPerMessage, - undefined, - undefined, - chunkMode, - opts.silent, - ); - } + await sendDiscordThreadTextChunks({ + rest, + threadId, + chunks: remainingChunks, + request, + maxLinesPerMessage: accountInfo.config.maxLinesPerMessage, + chunkMode, + silent: opts.silent, + }); } } catch (err) { throw await buildDiscordSendError(err, { diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index ab07d3357..5d41c7f4f 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -206,7 +206,13 @@ describe("callGateway url resolution", () => { { label: "keeps legacy admin scopes for explicit CLI callers", call: () => callGatewayCli({ method: "health" }), - expectedScopes: ["operator.admin", "operator.approvals", "operator.pairing"], + expectedScopes: [ + "operator.admin", + "operator.read", + "operator.write", + "operator.approvals", + "operator.pairing", + ], }, ])("scope selection: $label", async ({ call, expectedScopes }) => { setLocalLoopbackGatewayConfig(); @@ -328,6 +334,8 @@ describe("buildGatewayConnectionDetails", () => { expect((thrown as Error).message).toContain("SECURITY ERROR"); expect((thrown as Error).message).toContain("plaintext ws://"); expect((thrown as Error).message).toContain("wss://"); + expect((thrown as Error).message).toContain("Tailscale Serve/Funnel"); + expect((thrown as Error).message).toContain("openclaw doctor --fix"); }); it("allows ws:// for loopback addresses in local mode", () => { diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 5713864a4..ea8ed6cdb 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -149,7 +149,12 @@ export function buildGatewayConnectionDetails( "Both credentials and chat data would be exposed to network interception.", `Source: ${urlSource}`, `Config: ${configPath}`, - "Fix: Use wss:// for the gateway URL, or connect via SSH tunnel to localhost.", + "Fix: Use wss:// for remote gateway URLs.", + "Safe remote access defaults:", + "- keep gateway.bind=loopback and use an SSH tunnel (ssh -N -L 18789:127.0.0.1:18789 user@gateway-host)", + "- or use Tailscale Serve/Funnel for HTTPS remote access", + "Doctor: openclaw doctor --fix", + "Docs: https://docs.openclaw.ai/gateway/remote", ].join("\n"), ); } diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index fac816645..20010db48 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -130,6 +130,9 @@ describe("GatewayClient security checks", () => { message: expect.stringContaining("SECURITY ERROR"), }), ); + const error = onConnectError.mock.calls[0]?.[0] as Error; + expect(error.message).toContain("openclaw doctor --fix"); + expect(error.message).toContain("Tailscale Serve/Funnel"); expect(wsInstances.length).toBe(0); // No WebSocket created client.stop(); }); @@ -149,6 +152,8 @@ describe("GatewayClient security checks", () => { message: expect.stringContaining("SECURITY ERROR"), }), ); + const error = onConnectError.mock.calls[0]?.[0] as Error; + expect(error.message).toContain("openclaw doctor --fix"); expect(wsInstances.length).toBe(0); // No WebSocket created client.stop(); }); diff --git a/src/gateway/client.ts b/src/gateway/client.ts index 4e957c6e0..5cfe52eb8 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -126,7 +126,9 @@ export class GatewayClient { const error = new Error( `SECURITY ERROR: Cannot connect to "${displayHost}" over plaintext ws://. ` + "Both credentials and chat data would be exposed to network interception. " + - "Use wss:// for the gateway URL, or connect via SSH tunnel to localhost.", + "Use wss:// for remote URLs. Safe defaults: keep gateway.bind=loopback and connect via SSH tunnel " + + "(ssh -N -L 18789:127.0.0.1:18789 user@gateway-host), or use Tailscale Serve/Funnel. " + + "Run `openclaw doctor --fix` for guidance.", ); this.opts.onConnectError?.(error); return; diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index 1fd9377ea..20629c3d1 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -13,6 +13,8 @@ export type OperatorScope = export const CLI_DEFAULT_OPERATOR_SCOPES: OperatorScope[] = [ ADMIN_SCOPE, + READ_SCOPE, + WRITE_SCOPE, APPROVALS_SCOPE, PAIRING_SCOPE, ]; diff --git a/src/gateway/openai-http.test.ts b/src/gateway/openai-http.test.ts index 36c9cadfc..e8571e88e 100644 --- a/src/gateway/openai-http.test.ts +++ b/src/gateway/openai-http.test.ts @@ -334,6 +334,21 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { expect(msg.content).toBe("hello"); } + { + agentCommand.mockClear(); + agentCommand.mockResolvedValueOnce({ payloads: [{ text: "" }] } as never); + const res = await postChatCompletions(port, { + stream: false, + model: "openclaw", + messages: [{ role: "user", content: "hi" }], + }); + expect(res.status).toBe(200); + const json = (await res.json()) as Record; + const choice0 = (json.choices as Array>)[0] ?? {}; + const msg = (choice0.message as Record | undefined) ?? {}; + expect(msg.content).toBe("No response from OpenClaw."); + } + { const res = await postChatCompletions(port, { model: "openclaw", @@ -475,6 +490,31 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { expect(fallbackText).toContain("[DONE]"); expect(fallbackText).toContain("hello"); } + + { + agentCommand.mockClear(); + agentCommand.mockRejectedValueOnce(new Error("boom")); + + const errorRes = await postChatCompletions(port, { + stream: true, + model: "openclaw", + messages: [{ role: "user", content: "hi" }], + }); + expect(errorRes.status).toBe(200); + const errorText = await errorRes.text(); + const errorData = parseSseDataLines(errorText); + expect(errorData[errorData.length - 1]).toBe("[DONE]"); + + const errorChunks = errorData + .filter((d) => d !== "[DONE]") + .map((d) => JSON.parse(d) as Record); + const stopChoice = errorChunks + .flatMap((c) => (c.choices as Array> | undefined) ?? []) + .find((choice) => choice.finish_reason === "stop"); + expect((stopChoice?.delta as Record | undefined)?.content).toBe( + "Error: internal error", + ); + } } finally { // shared server } diff --git a/src/gateway/openai-http.ts b/src/gateway/openai-http.ts index 354d389f7..8a6168667 100644 --- a/src/gateway/openai-http.ts +++ b/src/gateway/openai-http.ts @@ -41,6 +41,51 @@ function writeSse(res: ServerResponse, data: unknown) { res.write(`data: ${JSON.stringify(data)}\n\n`); } +function buildAgentCommandInput(params: { + prompt: { message: string; extraSystemPrompt?: string }; + sessionKey: string; + runId: string; +}) { + return { + message: params.prompt.message, + extraSystemPrompt: params.prompt.extraSystemPrompt, + sessionKey: params.sessionKey, + runId: params.runId, + deliver: false as const, + messageChannel: "webchat" as const, + bestEffortDeliver: false as const, + }; +} + +function writeAssistantRoleChunk(res: ServerResponse, params: { runId: string; model: string }) { + writeSse(res, { + id: params.runId, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: params.model, + choices: [{ index: 0, delta: { role: "assistant" } }], + }); +} + +function writeAssistantContentChunk( + res: ServerResponse, + params: { runId: string; model: string; content: string; finishReason: "stop" | null }, +) { + writeSse(res, { + id: params.runId, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: params.model, + choices: [ + { + index: 0, + delta: { content: params.content }, + finish_reason: params.finishReason, + }, + ], + }); +} + function asMessages(val: unknown): OpenAiChatMessage[] { return Array.isArray(val) ? (val as OpenAiChatMessage[]) : []; } @@ -194,22 +239,15 @@ export async function handleOpenAiHttpRequest( const runId = `chatcmpl_${randomUUID()}`; const deps = createDefaultDeps(); + const commandInput = buildAgentCommandInput({ + prompt, + sessionKey, + runId, + }); if (!stream) { try { - const result = await agentCommand( - { - message: prompt.message, - extraSystemPrompt: prompt.extraSystemPrompt, - sessionKey, - runId, - deliver: false, - messageChannel: "webchat", - bestEffortDeliver: false, - }, - defaultRuntime, - deps, - ); + const result = await agentCommand(commandInput, defaultRuntime, deps); const content = resolveAgentResponseText(result); @@ -258,28 +296,15 @@ export async function handleOpenAiHttpRequest( if (!wroteRole) { wroteRole = true; - writeSse(res, { - id: runId, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model, - choices: [{ index: 0, delta: { role: "assistant" } }], - }); + writeAssistantRoleChunk(res, { runId, model }); } sawAssistantDelta = true; - writeSse(res, { - id: runId, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), + writeAssistantContentChunk(res, { + runId, model, - choices: [ - { - index: 0, - delta: { content }, - finish_reason: null, - }, - ], + content, + finishReason: null, }); return; } @@ -302,19 +327,7 @@ export async function handleOpenAiHttpRequest( void (async () => { try { - const result = await agentCommand( - { - message: prompt.message, - extraSystemPrompt: prompt.extraSystemPrompt, - sessionKey, - runId, - deliver: false, - messageChannel: "webchat", - bestEffortDeliver: false, - }, - defaultRuntime, - deps, - ); + const result = await agentCommand(commandInput, defaultRuntime, deps); if (closed) { return; @@ -323,30 +336,17 @@ export async function handleOpenAiHttpRequest( if (!sawAssistantDelta) { if (!wroteRole) { wroteRole = true; - writeSse(res, { - id: runId, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model, - choices: [{ index: 0, delta: { role: "assistant" } }], - }); + writeAssistantRoleChunk(res, { runId, model }); } const content = resolveAgentResponseText(result); sawAssistantDelta = true; - writeSse(res, { - id: runId, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), + writeAssistantContentChunk(res, { + runId, model, - choices: [ - { - index: 0, - delta: { content }, - finish_reason: null, - }, - ], + content, + finishReason: null, }); } } catch (err) { @@ -354,18 +354,11 @@ export async function handleOpenAiHttpRequest( if (closed) { return; } - writeSse(res, { - id: runId, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), + writeAssistantContentChunk(res, { + runId, model, - choices: [ - { - index: 0, - delta: { content: "Error: internal error" }, - finish_reason: "stop", - }, - ], + content: "Error: internal error", + finishReason: "stop", }); emitAgentEvent({ runId, diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 896a1ff0c..1f1e0df19 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -217,7 +217,7 @@ export const agentHandlers: GatewayRequestHandlers = { } const normalizedAttachments = normalizeRpcAttachmentsToChatAttachments(request.attachments); - let message = request.message.trim(); + let message = (request.message ?? "").trim(); let images: Array<{ type: "image"; data: string; mimeType: string }> = []; if (normalizedAttachments.length > 0) { try { @@ -695,7 +695,7 @@ export const agentHandlers: GatewayRequestHandlers = { return; } const p = params; - const runId = p.runId.trim(); + const runId = (p.runId ?? "").trim(); const timeoutMs = typeof p.timeoutMs === "number" && Number.isFinite(p.timeoutMs) ? Math.max(0, Math.floor(p.timeoutMs)) diff --git a/src/gateway/server-methods/chat.inject.parentid.test.ts b/src/gateway/server-methods/chat.inject.parentid.test.ts index 2d04e1cb9..b25cbc3fb 100644 --- a/src/gateway/server-methods/chat.inject.parentid.test.ts +++ b/src/gateway/server-methods/chat.inject.parentid.test.ts @@ -1,41 +1,28 @@ import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent"; import { describe, expect, it, vi } from "vitest"; +import { createMockSessionEntry, createTranscriptFixtureSync } from "./chat.test-helpers.js"; import type { GatewayRequestContext } from "./types.js"; // Guardrail: Ensure gateway "injected" assistant transcript messages are appended via SessionManager, // so they are attached to the current leaf with a `parentId` and do not sever compaction history. describe("gateway chat.inject transcript writes", () => { it("appends a Pi session entry that includes parentId", async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-chat-inject-")); - const transcriptPath = path.join(dir, "sess.jsonl"); - - // Minimal Pi session header so SessionManager can open/append safely. - fs.writeFileSync( - transcriptPath, - `${JSON.stringify({ - type: "session", - version: CURRENT_SESSION_VERSION, - id: "sess-1", - timestamp: new Date(0).toISOString(), - cwd: "/tmp", - })}\n`, - "utf-8", - ); + const sessionId = "sess-1"; + const { transcriptPath } = createTranscriptFixtureSync({ + prefix: "openclaw-chat-inject-", + sessionId, + }); vi.doMock("../session-utils.js", async (importOriginal) => { const original = await importOriginal(); return { ...original, - loadSessionEntry: () => ({ - storePath: path.join(dir, "sessions.json"), - entry: { - sessionId: "sess-1", - sessionFile: transcriptPath, - }, - }), + loadSessionEntry: () => + createMockSessionEntry({ + transcriptPath, + sessionId, + canonicalKey: "k1", + }), }; }); diff --git a/src/gateway/server-methods/chat.test-helpers.ts b/src/gateway/server-methods/chat.test-helpers.ts new file mode 100644 index 000000000..c8a772dbf --- /dev/null +++ b/src/gateway/server-methods/chat.test-helpers.ts @@ -0,0 +1,42 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent"; + +export function createTranscriptFixtureSync(params: { + prefix: string; + sessionId: string; + fileName?: string; +}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), params.prefix)); + const transcriptPath = path.join(dir, params.fileName ?? "sess.jsonl"); + fs.writeFileSync( + transcriptPath, + `${JSON.stringify({ + type: "session", + version: CURRENT_SESSION_VERSION, + id: params.sessionId, + timestamp: new Date(0).toISOString(), + cwd: "/tmp", + })}\n`, + "utf-8", + ); + return { dir, transcriptPath }; +} + +export function createMockSessionEntry(params: { + transcriptPath: string; + sessionId: string; + canonicalKey?: string; + cfg?: Record; +}) { + return { + cfg: params.cfg ?? {}, + storePath: path.join(path.dirname(params.transcriptPath), "sessions.json"), + entry: { + sessionId: params.sessionId, + sessionFile: params.transcriptPath, + }, + canonicalKey: params.canonicalKey ?? "main", + }; +} diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 088f791d6..c26050655 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -10,7 +10,10 @@ import type { MsgContext } from "../../auto-reply/templating.js"; import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; import { resolveSessionFilePath } from "../../config/sessions.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; -import { stripInlineDirectiveTagsForDisplay } from "../../utils/directive-tags.js"; +import { + stripInlineDirectiveTagsForDisplay, + stripInlineDirectiveTagsFromMessageForDisplay, +} from "../../utils/directive-tags.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; import { abortChatRunById, @@ -527,25 +530,6 @@ function nextChatSeq(context: { agentRunSeq: Map }, runId: strin return next; } -function stripMessageDirectiveTags( - message: Record | undefined, -): Record | undefined { - if (!message) { - return message; - } - const content = message.content; - if (!Array.isArray(content)) { - return message; - } - const cleaned = content.map((part: Record) => { - if (part.type === "text" && typeof part.text === "string") { - return { ...part, text: stripInlineDirectiveTagsForDisplay(part.text).text }; - } - return part; - }); - return { ...message, content: cleaned }; -} - function broadcastChatFinal(params: { context: Pick; runId: string; @@ -558,7 +542,7 @@ function broadcastChatFinal(params: { sessionKey: params.sessionKey, seq, state: "final" as const, - message: stripMessageDirectiveTags(params.message), + message: stripInlineDirectiveTagsFromMessageForDisplay(params.message), }; params.context.broadcast("chat", payload); params.context.nodeSendToSession(params.sessionKey, "chat", payload); @@ -1089,7 +1073,7 @@ export const chatHandlers: GatewayRequestHandlers = { sessionKey: rawSessionKey, seq: 0, state: "final" as const, - message: stripMessageDirectiveTags(appended.message), + message: stripInlineDirectiveTagsFromMessageForDisplay(appended.message), }; context.broadcast("chat", chatPayload); context.nodeSendToSession(rawSessionKey, "chat", chatPayload); diff --git a/src/gateway/server-node-events.test.ts b/src/gateway/server-node-events.test.ts index a4e3539e8..a7c0b1057 100644 --- a/src/gateway/server-node-events.test.ts +++ b/src/gateway/server-node-events.test.ts @@ -52,6 +52,7 @@ vi.mock("./session-utils.js", () => ({ import type { CliDeps } from "../cli/deps.js"; import { agentCommand } from "../commands/agent.js"; import type { HealthSummary } from "../commands/health.js"; +import { loadConfig } from "../config/config.js"; import { updateSessionStore } from "../config/sessions.js"; import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; @@ -61,6 +62,7 @@ import { loadSessionEntry } from "./session-utils.js"; const enqueueSystemEventMock = vi.mocked(enqueueSystemEvent); const requestHeartbeatNowMock = vi.mocked(requestHeartbeatNow); +const loadConfigMock = vi.mocked(loadConfig); const agentCommandMock = vi.mocked(agentCommand); const updateSessionStoreMock = vi.mocked(updateSessionStore); const loadSessionEntryMock = vi.mocked(loadSessionEntry); @@ -185,6 +187,65 @@ describe("node exec events", () => { ); expect(requestHeartbeatNowMock).toHaveBeenCalledWith({ reason: "exec-event" }); }); + + it("suppresses exec.started when notifyOnExit is false", async () => { + loadConfigMock.mockReturnValueOnce({ + session: { mainKey: "agent:main:main" }, + tools: { exec: { notifyOnExit: false } }, + } as ReturnType); + const ctx = buildCtx(); + await handleNodeEvent(ctx, "node-1", { + event: "exec.started", + payloadJSON: JSON.stringify({ + sessionKey: "agent:main:main", + runId: "run-silent-1", + command: "ls -la", + }), + }); + + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + expect(requestHeartbeatNowMock).not.toHaveBeenCalled(); + }); + + it("suppresses exec.finished when notifyOnExit is false", async () => { + loadConfigMock.mockReturnValueOnce({ + session: { mainKey: "agent:main:main" }, + tools: { exec: { notifyOnExit: false } }, + } as ReturnType); + const ctx = buildCtx(); + await handleNodeEvent(ctx, "node-2", { + event: "exec.finished", + payloadJSON: JSON.stringify({ + runId: "run-silent-2", + exitCode: 0, + timedOut: false, + output: "some output", + }), + }); + + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + expect(requestHeartbeatNowMock).not.toHaveBeenCalled(); + }); + + it("suppresses exec.denied when notifyOnExit is false", async () => { + loadConfigMock.mockReturnValueOnce({ + session: { mainKey: "agent:main:main" }, + tools: { exec: { notifyOnExit: false } }, + } as ReturnType); + const ctx = buildCtx(); + await handleNodeEvent(ctx, "node-3", { + event: "exec.denied", + payloadJSON: JSON.stringify({ + sessionKey: "agent:demo:main", + runId: "run-silent-3", + command: "rm -rf /", + reason: "allowlist-miss", + }), + }); + + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + expect(requestHeartbeatNowMock).not.toHaveBeenCalled(); + }); }); describe("voice transcript events", () => { diff --git a/src/gateway/server-node-events.ts b/src/gateway/server-node-events.ts index 202bd4862..0878134f4 100644 --- a/src/gateway/server-node-events.ts +++ b/src/gateway/server-node-events.ts @@ -471,6 +471,15 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt if (!sessionKey) { return; } + + // Respect tools.exec.notifyOnExit setting (default: true) + // When false, skip system event notifications for node exec events. + const cfg = loadConfig(); + const notifyOnExit = cfg.tools?.exec?.notifyOnExit !== false; + if (!notifyOnExit) { + return; + } + const runId = typeof obj.runId === "string" ? obj.runId.trim() : ""; const command = typeof obj.command === "string" ? obj.command.trim() : ""; const exitCode = diff --git a/src/gateway/server-startup-log.test.ts b/src/gateway/server-startup-log.test.ts index 04648ddeb..60b820da2 100644 --- a/src/gateway/server-startup-log.test.ts +++ b/src/gateway/server-startup-log.test.ts @@ -42,4 +42,25 @@ describe("gateway startup log", () => { expect(warn).not.toHaveBeenCalled(); }); + + it("logs all listen endpoints on a single line", () => { + const info = vi.fn(); + const warn = vi.fn(); + + logGatewayStartup({ + cfg: {}, + bindHost: "127.0.0.1", + bindHosts: ["127.0.0.1", "::1"], + port: 18789, + log: { info, warn }, + isNixMode: false, + }); + + const listenMessages = info.mock.calls + .map((call) => call[0]) + .filter((message) => message.startsWith("listening on ")); + expect(listenMessages).toEqual([ + `listening on ws://127.0.0.1:18789, ws://[::1]:18789 (PID ${process.pid})`, + ]); + }); }); diff --git a/src/gateway/server-startup-log.ts b/src/gateway/server-startup-log.ts index 0a95bc68e..594ac23ba 100644 --- a/src/gateway/server-startup-log.ts +++ b/src/gateway/server-startup-log.ts @@ -27,13 +27,8 @@ export function logGatewayStartup(params: { const formatHost = (host: string) => (host.includes(":") ? `[${host}]` : host); const hosts = params.bindHosts && params.bindHosts.length > 0 ? params.bindHosts : [params.bindHost]; - const primaryHost = hosts[0] ?? params.bindHost; - params.log.info( - `listening on ${scheme}://${formatHost(primaryHost)}:${params.port} (PID ${process.pid})`, - ); - for (const host of hosts.slice(1)) { - params.log.info(`listening on ${scheme}://${formatHost(host)}:${params.port}`); - } + const listenEndpoints = hosts.map((host) => `${scheme}://${formatHost(host)}:${params.port}`); + params.log.info(`listening on ${listenEndpoints.join(", ")} (PID ${process.pid})`); params.log.info(`log file: ${getResolvedLoggerSettings().file}`); if (params.isNixMode) { params.log.info("gateway: running in Nix mode (config managed externally)"); diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts index 20680cb62..23b4b29f3 100644 --- a/src/gateway/server.auth.test.ts +++ b/src/gateway/server.auth.test.ts @@ -873,7 +873,13 @@ describe("gateway server auth/connect", () => { const { randomUUID } = await import("node:crypto"); const os = await import("node:os"); const path = await import("node:path"); - const scopes = ["operator.admin", "operator.approvals", "operator.pairing"]; + const scopes = [ + "operator.admin", + "operator.read", + "operator.write", + "operator.approvals", + "operator.pairing", + ]; const { device } = await createSignedDevice({ token: "secret", scopes, diff --git a/src/gateway/server.models-voicewake-misc.test.ts b/src/gateway/server.models-voicewake-misc.test.ts index f5a33b42f..5554fd10f 100644 --- a/src/gateway/server.models-voicewake-misc.test.ts +++ b/src/gateway/server.models-voicewake-misc.test.ts @@ -82,7 +82,106 @@ const whatsappRegistry = createRegistry([ ]); const emptyRegistry = createRegistry([]); +type ModelCatalogRpcEntry = { + id: string; + name: string; + provider: string; + contextWindow?: number; +}; + +type PiCatalogFixtureEntry = { + id: string; + provider: string; + name?: string; + contextWindow?: number; +}; + +const buildPiCatalogFixture = (): PiCatalogFixtureEntry[] => [ + { id: "gpt-test-z", provider: "openai", contextWindow: 0 }, + { + id: "gpt-test-a", + name: "A-Model", + provider: "openai", + contextWindow: 8000, + }, + { + id: "claude-test-b", + name: "B-Model", + provider: "anthropic", + contextWindow: 1000, + }, + { + id: "claude-test-a", + name: "A-Model", + provider: "anthropic", + contextWindow: 200_000, + }, +]; + +const expectedSortedCatalog = (): ModelCatalogRpcEntry[] => [ + { + id: "claude-test-a", + name: "A-Model", + provider: "anthropic", + contextWindow: 200_000, + }, + { + id: "claude-test-b", + name: "B-Model", + provider: "anthropic", + contextWindow: 1000, + }, + { + id: "gpt-test-a", + name: "A-Model", + provider: "openai", + contextWindow: 8000, + }, + { + id: "gpt-test-z", + name: "gpt-test-z", + provider: "openai", + }, +]; + describe("gateway server models + voicewake", () => { + const listModels = async () => rpcReq<{ models: ModelCatalogRpcEntry[] }>(ws, "models.list"); + + const seedPiCatalog = () => { + piSdkMock.enabled = true; + piSdkMock.models = buildPiCatalogFixture(); + }; + + const withModelsConfig = async (config: unknown, run: () => Promise): Promise => { + const configPath = process.env.OPENCLAW_CONFIG_PATH; + if (!configPath) { + throw new Error("Missing OPENCLAW_CONFIG_PATH"); + } + let previousConfig: string | undefined; + try { + previousConfig = await fs.readFile(configPath, "utf-8"); + } catch (err) { + const code = (err as NodeJS.ErrnoException | undefined)?.code; + if (code !== "ENOENT") { + throw err; + } + } + + try { + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf-8"); + clearConfigCache(); + return await run(); + } finally { + if (previousConfig === undefined) { + await fs.rm(configPath, { force: true }); + } else { + await fs.writeFile(configPath, previousConfig, "utf-8"); + } + clearConfigCache(); + } + }; + const withTempHome = async (fn: (homeDir: string) => Promise): Promise => { const tempHome = await createTempHomeEnv("openclaw-home-"); try { @@ -180,171 +279,75 @@ describe("gateway server models + voicewake", () => { }); test("models.list returns model catalog", async () => { - piSdkMock.enabled = true; - piSdkMock.models = [ - { id: "gpt-test-z", provider: "openai", contextWindow: 0 }, - { - id: "gpt-test-a", - name: "A-Model", - provider: "openai", - contextWindow: 8000, - }, - { - id: "claude-test-b", - name: "B-Model", - provider: "anthropic", - contextWindow: 1000, - }, - { - id: "claude-test-a", - name: "A-Model", - provider: "anthropic", - contextWindow: 200_000, - }, - ]; + seedPiCatalog(); - const res1 = await rpcReq<{ - models: Array<{ - id: string; - name: string; - provider: string; - contextWindow?: number; - }>; - }>(ws, "models.list"); - - const res2 = await rpcReq<{ - models: Array<{ - id: string; - name: string; - provider: string; - contextWindow?: number; - }>; - }>(ws, "models.list"); + const res1 = await listModels(); + const res2 = await listModels(); expect(res1.ok).toBe(true); expect(res2.ok).toBe(true); const models = res1.payload?.models ?? []; - expect(models).toEqual([ - { - id: "claude-test-a", - name: "A-Model", - provider: "anthropic", - contextWindow: 200_000, - }, - { - id: "claude-test-b", - name: "B-Model", - provider: "anthropic", - contextWindow: 1000, - }, - { - id: "gpt-test-a", - name: "A-Model", - provider: "openai", - contextWindow: 8000, - }, - { - id: "gpt-test-z", - name: "gpt-test-z", - provider: "openai", - }, - ]); + expect(models).toEqual(expectedSortedCatalog()); expect(piSdkMock.discoverCalls).toBe(1); }); test("models.list filters to allowlisted configured models by default", async () => { - const configPath = process.env.OPENCLAW_CONFIG_PATH; - if (!configPath) { - throw new Error("Missing OPENCLAW_CONFIG_PATH"); - } - let previousConfig: string | undefined; - try { - previousConfig = await fs.readFile(configPath, "utf-8"); - } catch (err) { - const code = (err as NodeJS.ErrnoException | undefined)?.code; - if (code !== "ENOENT") { - throw err; - } - } - try { - await fs.mkdir(path.dirname(configPath), { recursive: true }); - await fs.writeFile( - configPath, - JSON.stringify( - { - agents: { - defaults: { - model: { primary: "openai/gpt-test-z" }, - models: { - "openai/gpt-test-z": {}, - "anthropic/claude-test-a": {}, - }, - }, + await withModelsConfig( + { + agents: { + defaults: { + model: { primary: "openai/gpt-test-z" }, + models: { + "openai/gpt-test-z": {}, + "anthropic/claude-test-a": {}, }, }, - null, - 2, - ), - "utf-8", - ); - clearConfigCache(); + }, + }, + async () => { + seedPiCatalog(); + const res = await listModels(); - piSdkMock.enabled = true; - piSdkMock.models = [ - { id: "gpt-test-z", provider: "openai", contextWindow: 0 }, - { - id: "gpt-test-a", - name: "A-Model", - provider: "openai", - contextWindow: 8000, - }, - { - id: "claude-test-b", - name: "B-Model", - provider: "anthropic", - contextWindow: 1000, - }, - { - id: "claude-test-a", - name: "A-Model", - provider: "anthropic", - contextWindow: 200_000, - }, - ]; + expect(res.ok).toBe(true); + expect(res.payload?.models).toEqual([ + { + id: "claude-test-a", + name: "A-Model", + provider: "anthropic", + contextWindow: 200_000, + }, + { + id: "gpt-test-z", + name: "gpt-test-z", + provider: "openai", + }, + ]); + }, + ); + }); - const res = await rpcReq<{ - models: Array<{ - id: string; - name: string; - provider: string; - contextWindow?: number; - }>; - }>(ws, "models.list"); + test("models.list falls back to full catalog when allowlist has no catalog match", async () => { + await withModelsConfig( + { + agents: { + defaults: { + model: { primary: "openai/not-in-catalog" }, + models: { + "openai/not-in-catalog": {}, + }, + }, + }, + }, + async () => { + seedPiCatalog(); + const res = await listModels(); - expect(res.ok).toBe(true); - expect(res.payload?.models).toEqual([ - { - id: "claude-test-a", - name: "A-Model", - provider: "anthropic", - contextWindow: 200_000, - }, - { - id: "gpt-test-z", - name: "gpt-test-z", - provider: "openai", - }, - ]); - } finally { - if (previousConfig === undefined) { - await fs.rm(configPath, { force: true }); - } else { - await fs.writeFile(configPath, previousConfig, "utf-8"); - } - clearConfigCache(); - } + expect(res.ok).toBe(true); + expect(res.payload?.models).toEqual(expectedSortedCatalog()); + }, + ); }); test("models.list rejects unknown params", async () => { diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 5da23cee6..fc4decaa6 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -430,7 +430,7 @@ export function resolveSessionStoreKey(params: { cfg: OpenClawConfig; sessionKey: string; }): string { - const raw = params.sessionKey.trim(); + const raw = (params.sessionKey ?? "").trim(); if (!raw) { return raw; } diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index 375ada6ac..703935954 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -16,8 +16,13 @@ import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.j import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; import { recordInboundSession } from "../../channels/session.js"; import { loadConfig } from "../../config/config.js"; +import { + resolveOpenProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, +} from "../../config/runtime-group-policy.js"; import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js"; -import { danger, logVerbose, shouldLogVerbose } from "../../globals.js"; +import { danger, logVerbose, shouldLogVerbose, warn } from "../../globals.js"; import { normalizeScpRemoteHost } from "../../infra/scp-host.js"; import { waitForTransportReady } from "../../infra/transport-ready.js"; import { mediaKindFromMime } from "../../media/constants.js"; @@ -143,8 +148,18 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P imessageCfg.groupAllowFrom ?? (imessageCfg.allowFrom && imessageCfg.allowFrom.length > 0 ? imessageCfg.allowFrom : []), ); - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = imessageCfg.groupPolicy ?? defaultGroupPolicy ?? "open"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.imessage !== undefined, + groupPolicy: imessageCfg.groupPolicy, + defaultGroupPolicy, + }); + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "imessage", + accountId: accountInfo.accountId, + log: (message) => runtime.log?.(warn(message)), + }); const dmPolicy = imessageCfg.dmPolicy ?? "pairing"; const includeAttachments = opts.includeAttachments ?? imessageCfg.includeAttachments ?? false; const mediaMaxBytes = (opts.mediaMaxMb ?? imessageCfg.mediaMaxMb ?? 16) * 1024 * 1024; @@ -508,3 +523,8 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P await client.stop(); } } + +export const __testing = { + resolveIMessageRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, +}; diff --git a/src/imessage/monitor/provider.group-policy.test.ts b/src/imessage/monitor/provider.group-policy.test.ts new file mode 100644 index 000000000..c28d7c10b --- /dev/null +++ b/src/imessage/monitor/provider.group-policy.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +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); + }); +}); diff --git a/src/infra/exec-approvals-allowlist.ts b/src/infra/exec-approvals-allowlist.ts index 147905522..c039c6fc0 100644 --- a/src/infra/exec-approvals-allowlist.ts +++ b/src/infra/exec-approvals-allowlist.ts @@ -11,7 +11,6 @@ import { } from "./exec-approvals-analysis.js"; import type { ExecAllowlistEntry } from "./exec-approvals.js"; import { - SAFE_BIN_GENERIC_PROFILE, SAFE_BIN_PROFILES, type SafeBinProfile, validateSafeBinArgv, @@ -41,7 +40,6 @@ export function isSafeBinUsage(params: { platform?: string | null; trustedSafeBinDirs?: ReadonlySet; safeBinProfiles?: Readonly>; - safeBinGenericProfile?: SafeBinProfile; isTrustedSafeBinPathFn?: typeof isTrustedSafeBinPath; }): boolean { // Windows host exec uses PowerShell, which has different parsing/expansion rules. @@ -75,8 +73,10 @@ export function isSafeBinUsage(params: { } const argv = params.argv.slice(1); const safeBinProfiles = params.safeBinProfiles ?? SAFE_BIN_PROFILES; - const genericSafeBinProfile = params.safeBinGenericProfile ?? SAFE_BIN_GENERIC_PROFILE; - const profile = safeBinProfiles[execName] ?? genericSafeBinProfile; + const profile = safeBinProfiles[execName]; + if (!profile) { + return false; + } return validateSafeBinArgv(argv, profile); } @@ -93,6 +93,7 @@ function evaluateSegments( params: { allowlist: ExecAllowlistEntry[]; safeBins: Set; + safeBinProfiles?: Readonly>; cwd?: string; platform?: string | null; trustedSafeBinDirs?: ReadonlySet; @@ -122,6 +123,7 @@ function evaluateSegments( argv: segment.argv, resolution: segment.resolution, safeBins: params.safeBins, + safeBinProfiles: params.safeBinProfiles, platform: params.platform, trustedSafeBinDirs: params.trustedSafeBinDirs, }); @@ -147,6 +149,7 @@ export function evaluateExecAllowlist(params: { analysis: ExecCommandAnalysis; allowlist: ExecAllowlistEntry[]; safeBins: Set; + safeBinProfiles?: Readonly>; cwd?: string; platform?: string | null; trustedSafeBinDirs?: ReadonlySet; @@ -165,6 +168,7 @@ export function evaluateExecAllowlist(params: { const result = evaluateSegments(chainSegments, { allowlist: params.allowlist, safeBins: params.safeBins, + safeBinProfiles: params.safeBinProfiles, cwd: params.cwd, platform: params.platform, trustedSafeBinDirs: params.trustedSafeBinDirs, @@ -184,6 +188,7 @@ export function evaluateExecAllowlist(params: { const result = evaluateSegments(params.analysis.segments, { allowlist: params.allowlist, safeBins: params.safeBins, + safeBinProfiles: params.safeBinProfiles, cwd: params.cwd, platform: params.platform, trustedSafeBinDirs: params.trustedSafeBinDirs, @@ -354,6 +359,7 @@ export function evaluateShellAllowlist(params: { command: string; allowlist: ExecAllowlistEntry[]; safeBins: Set; + safeBinProfiles?: Readonly>; cwd?: string; env?: NodeJS.ProcessEnv; trustedSafeBinDirs?: ReadonlySet; @@ -384,6 +390,7 @@ export function evaluateShellAllowlist(params: { analysis, allowlist: params.allowlist, safeBins: params.safeBins, + safeBinProfiles: params.safeBinProfiles, cwd: params.cwd, platform: params.platform, trustedSafeBinDirs: params.trustedSafeBinDirs, @@ -419,6 +426,7 @@ export function evaluateShellAllowlist(params: { analysis, allowlist: params.allowlist, safeBins: params.safeBins, + safeBinProfiles: params.safeBinProfiles, cwd: params.cwd, platform: params.platform, trustedSafeBinDirs: params.trustedSafeBinDirs, diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts index bd2c0db3f..5afb0e7be 100644 --- a/src/infra/exec-approvals.test.ts +++ b/src/infra/exec-approvals.test.ts @@ -29,7 +29,11 @@ import { type ExecAllowlistEntry, type ExecApprovalsFile, } from "./exec-approvals.js"; -import { SAFE_BIN_PROFILE_FIXTURES, SAFE_BIN_PROFILES } from "./exec-safe-bin-policy.js"; +import { + SAFE_BIN_PROFILE_FIXTURES, + SAFE_BIN_PROFILES, + resolveSafeBinProfiles, +} from "./exec-safe-bin-policy.js"; function makePathEnv(binDir: string): NodeJS.ProcessEnv { if (process.platform !== "win32") { @@ -798,6 +802,53 @@ describe("exec approvals safe bins", () => { expect(defaults.has("grep")).toBe(false); }); + it("does not auto-allow unprofiled safe-bin entries", () => { + if (process.platform === "win32") { + return; + } + const result = evaluateShellAllowlist({ + command: "python3 -c \"print('owned')\"", + allowlist: [], + safeBins: normalizeSafeBins(["python3"]), + cwd: "/tmp", + }); + expect(result.analysisOk).toBe(true); + expect(result.allowlistSatisfied).toBe(false); + }); + + it("allows caller-defined custom safe-bin profiles", () => { + if (process.platform === "win32") { + return; + } + const safeBinProfiles = resolveSafeBinProfiles({ + echo: { + maxPositional: 1, + }, + }); + const allow = isSafeBinUsage({ + argv: ["echo", "hello"], + resolution: { + rawExecutable: "echo", + resolvedPath: "/bin/echo", + executableName: "echo", + }, + safeBins: normalizeSafeBins(["echo"]), + safeBinProfiles, + }); + const deny = isSafeBinUsage({ + argv: ["echo", "hello", "world"], + resolution: { + rawExecutable: "echo", + resolvedPath: "/bin/echo", + executableName: "echo", + }, + safeBins: normalizeSafeBins(["echo"]), + safeBinProfiles, + }); + expect(allow).toBe(true); + expect(deny).toBe(false); + }); + it("blocks sort output flags independent of file existence", () => { if (process.platform === "win32") { return; diff --git a/src/infra/exec-safe-bin-policy.ts b/src/infra/exec-safe-bin-policy.ts index fc40f9b9b..878c0a55e 100644 --- a/src/infra/exec-safe-bin-policy.ts +++ b/src/infra/exec-safe-bin-policy.ts @@ -37,6 +37,8 @@ export type SafeBinProfileFixture = { deniedFlags?: readonly string[]; }; +export type SafeBinProfileFixtures = Readonly>; + const NO_FLAGS: ReadonlySet = new Set(); const toFlagSet = (flags?: readonly string[]): ReadonlySet => { @@ -63,8 +65,6 @@ function compileSafeBinProfiles( ) as Record; } -export const SAFE_BIN_GENERIC_PROFILE_FIXTURE: SafeBinProfileFixture = {}; - export const SAFE_BIN_PROFILE_FIXTURES: Record = { jq: { maxPositional: 1, @@ -184,11 +184,81 @@ export const SAFE_BIN_PROFILE_FIXTURES: Record = }, }; -export const SAFE_BIN_GENERIC_PROFILE = compileSafeBinProfile(SAFE_BIN_GENERIC_PROFILE_FIXTURE); - export const SAFE_BIN_PROFILES: Record = compileSafeBinProfiles(SAFE_BIN_PROFILE_FIXTURES); +function normalizeSafeBinProfileName(raw: string): string | null { + const name = raw.trim().toLowerCase(); + return name.length > 0 ? name : null; +} + +function normalizeFixtureLimit(raw: number | undefined): number | undefined { + if (typeof raw !== "number" || !Number.isFinite(raw)) { + return undefined; + } + const next = Math.trunc(raw); + return next >= 0 ? next : undefined; +} + +function normalizeFixtureFlags( + flags: readonly string[] | undefined, +): readonly string[] | undefined { + if (!Array.isArray(flags) || flags.length === 0) { + return undefined; + } + const normalized = Array.from( + new Set(flags.map((flag) => flag.trim()).filter((flag) => flag.length > 0)), + ).toSorted((a, b) => a.localeCompare(b)); + return normalized.length > 0 ? normalized : undefined; +} + +function normalizeSafeBinProfileFixture(fixture: SafeBinProfileFixture): SafeBinProfileFixture { + const minPositional = normalizeFixtureLimit(fixture.minPositional); + const maxPositionalRaw = normalizeFixtureLimit(fixture.maxPositional); + const maxPositional = + minPositional !== undefined && + maxPositionalRaw !== undefined && + maxPositionalRaw < minPositional + ? minPositional + : maxPositionalRaw; + return { + minPositional, + maxPositional, + allowedValueFlags: normalizeFixtureFlags(fixture.allowedValueFlags), + deniedFlags: normalizeFixtureFlags(fixture.deniedFlags), + }; +} + +export function normalizeSafeBinProfileFixtures( + fixtures?: SafeBinProfileFixtures | null, +): Record { + const normalized: Record = {}; + if (!fixtures) { + return normalized; + } + for (const [rawName, fixture] of Object.entries(fixtures)) { + const name = normalizeSafeBinProfileName(rawName); + if (!name) { + continue; + } + normalized[name] = normalizeSafeBinProfileFixture(fixture); + } + return normalized; +} + +export function resolveSafeBinProfiles( + fixtures?: SafeBinProfileFixtures | null, +): Record { + const normalizedFixtures = normalizeSafeBinProfileFixtures(fixtures); + if (Object.keys(normalizedFixtures).length === 0) { + return SAFE_BIN_PROFILES; + } + return { + ...SAFE_BIN_PROFILES, + ...compileSafeBinProfiles(normalizedFixtures), + }; +} + export function resolveSafeBinDeniedFlags( fixtures: Readonly> = SAFE_BIN_PROFILE_FIXTURES, ): Record { diff --git a/src/infra/exec-safe-bin-runtime-policy.test.ts b/src/infra/exec-safe-bin-runtime-policy.test.ts new file mode 100644 index 000000000..ef7f45049 --- /dev/null +++ b/src/infra/exec-safe-bin-runtime-policy.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { + isInterpreterLikeSafeBin, + listInterpreterLikeSafeBins, + resolveExecSafeBinRuntimePolicy, + resolveMergedSafeBinProfileFixtures, +} from "./exec-safe-bin-runtime-policy.js"; + +describe("exec safe-bin runtime policy", () => { + const interpreterCases: Array<{ bin: string; expected: boolean }> = [ + { bin: "python3", expected: true }, + { bin: "python3.12", expected: true }, + { bin: "node", expected: true }, + { bin: "node20", expected: true }, + { bin: "ruby3.2", expected: true }, + { bin: "bash", expected: true }, + { bin: "myfilter", expected: false }, + { bin: "jq", expected: false }, + ]; + + for (const testCase of interpreterCases) { + it(`classifies interpreter-like safe bin '${testCase.bin}'`, () => { + expect(isInterpreterLikeSafeBin(testCase.bin)).toBe(testCase.expected); + }); + } + + it("lists interpreter-like bins from a mixed set", () => { + expect(listInterpreterLikeSafeBins(["jq", "python3", "myfilter", "node"])).toEqual([ + "node", + "python3", + ]); + }); + + it("merges and normalizes safe-bin profile fixtures", () => { + const merged = resolveMergedSafeBinProfileFixtures({ + global: { + safeBinProfiles: { + " MyFilter ": { + deniedFlags: ["--file", " --file ", ""], + }, + }, + }, + local: { + safeBinProfiles: { + myfilter: { + maxPositional: 0, + }, + }, + }, + }); + expect(merged).toEqual({ + myfilter: { + maxPositional: 0, + }, + }); + }); + + it("computes unprofiled interpreter entries separately from custom profiled bins", () => { + const policy = resolveExecSafeBinRuntimePolicy({ + local: { + safeBins: ["python3", "myfilter"], + safeBinProfiles: { + myfilter: { maxPositional: 0 }, + }, + }, + }); + + expect(policy.safeBins.has("python3")).toBe(true); + expect(policy.safeBins.has("myfilter")).toBe(true); + expect(policy.unprofiledSafeBins).toEqual(["python3"]); + expect(policy.unprofiledInterpreterSafeBins).toEqual(["python3"]); + }); +}); diff --git a/src/infra/exec-safe-bin-runtime-policy.ts b/src/infra/exec-safe-bin-runtime-policy.ts new file mode 100644 index 000000000..930206a70 --- /dev/null +++ b/src/infra/exec-safe-bin-runtime-policy.ts @@ -0,0 +1,127 @@ +import { resolveSafeBins } from "./exec-approvals-allowlist.js"; +import { + normalizeSafeBinProfileFixtures, + resolveSafeBinProfiles, + type SafeBinProfile, + type SafeBinProfileFixture, + type SafeBinProfileFixtures, +} from "./exec-safe-bin-policy.js"; +import { getTrustedSafeBinDirs } from "./exec-safe-bin-trust.js"; + +export type ExecSafeBinConfigScope = { + safeBins?: string[] | null; + safeBinProfiles?: SafeBinProfileFixtures | null; +}; + +const INTERPRETER_LIKE_SAFE_BINS = new Set([ + "ash", + "bash", + "bun", + "cmd", + "cmd.exe", + "cscript", + "dash", + "deno", + "fish", + "ksh", + "lua", + "node", + "nodejs", + "perl", + "php", + "powershell", + "powershell.exe", + "pypy", + "pwsh", + "pwsh.exe", + "python", + "python2", + "python3", + "ruby", + "sh", + "wscript", + "zsh", +]); + +const INTERPRETER_LIKE_PATTERNS = [ + /^python\d+(?:\.\d+)?$/, + /^ruby\d+(?:\.\d+)?$/, + /^perl\d+(?:\.\d+)?$/, + /^php\d+(?:\.\d+)?$/, + /^node\d+(?:\.\d+)?$/, +]; + +function normalizeSafeBinName(raw: string): string { + const trimmed = raw.trim().toLowerCase(); + if (!trimmed) { + return ""; + } + const tail = trimmed.split(/[\\/]/).at(-1); + return tail ?? trimmed; +} + +export function isInterpreterLikeSafeBin(raw: string): boolean { + const normalized = normalizeSafeBinName(raw); + if (!normalized) { + return false; + } + if (INTERPRETER_LIKE_SAFE_BINS.has(normalized)) { + return true; + } + return INTERPRETER_LIKE_PATTERNS.some((pattern) => pattern.test(normalized)); +} + +export function listInterpreterLikeSafeBins(entries: Iterable): string[] { + return Array.from(entries) + .map((entry) => normalizeSafeBinName(entry)) + .filter((entry) => entry.length > 0 && isInterpreterLikeSafeBin(entry)) + .toSorted(); +} + +export function resolveMergedSafeBinProfileFixtures(params: { + global?: ExecSafeBinConfigScope | null; + local?: ExecSafeBinConfigScope | null; +}): Record | undefined { + const global = normalizeSafeBinProfileFixtures(params.global?.safeBinProfiles); + const local = normalizeSafeBinProfileFixtures(params.local?.safeBinProfiles); + if (Object.keys(global).length === 0 && Object.keys(local).length === 0) { + return undefined; + } + return { + ...global, + ...local, + }; +} + +export function resolveExecSafeBinRuntimePolicy(params: { + global?: ExecSafeBinConfigScope | null; + local?: ExecSafeBinConfigScope | null; + pathEnv?: string | null; +}): { + safeBins: Set; + safeBinProfiles: Readonly>; + trustedSafeBinDirs: ReadonlySet; + unprofiledSafeBins: string[]; + unprofiledInterpreterSafeBins: string[]; +} { + const safeBins = resolveSafeBins(params.local?.safeBins ?? params.global?.safeBins); + const safeBinProfiles = resolveSafeBinProfiles( + resolveMergedSafeBinProfileFixtures({ + global: params.global, + local: params.local, + }), + ); + const unprofiledSafeBins = Array.from(safeBins) + .filter((entry) => !safeBinProfiles[entry]) + .toSorted(); + const trustedSafeBinDirs = params.pathEnv + ? getTrustedSafeBinDirs({ pathEnv: params.pathEnv }) + : getTrustedSafeBinDirs(); + return { + safeBins, + safeBinProfiles, + trustedSafeBinDirs, + unprofiledSafeBins, + unprofiledInterpreterSafeBins: listInterpreterLikeSafeBins(unprofiledSafeBins), + }; +} diff --git a/src/infra/host-env-security-policy.json b/src/infra/host-env-security-policy.json index 341af1c5d..8b3ec80d5 100644 --- a/src/infra/host-env-security-policy.json +++ b/src/infra/host-env-security-policy.json @@ -11,6 +11,8 @@ "BASH_ENV", "ENV", "SHELL", + "SHELLOPTS", + "PS4", "GCONV_PATH", "IFS", "SSLKEYLOGFILE" diff --git a/src/infra/host-env-security.test.ts b/src/infra/host-env-security.test.ts index df1ccd874..47ef53a6b 100644 --- a/src/infra/host-env-security.test.ts +++ b/src/infra/host-env-security.test.ts @@ -1,9 +1,14 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import { isDangerousHostEnvOverrideVarName, isDangerousHostEnvVarName, normalizeEnvVarKey, sanitizeHostExecEnv, + sanitizeSystemRunEnvOverrides, } from "./host-env-security.js"; describe("isDangerousHostEnvVarName", () => { @@ -11,6 +16,8 @@ describe("isDangerousHostEnvVarName", () => { expect(isDangerousHostEnvVarName("BASH_ENV")).toBe(true); expect(isDangerousHostEnvVarName("bash_env")).toBe(true); expect(isDangerousHostEnvVarName("SHELL")).toBe(true); + expect(isDangerousHostEnvVarName("SHELLOPTS")).toBe(true); + expect(isDangerousHostEnvVarName("ps4")).toBe(true); expect(isDangerousHostEnvVarName("DYLD_INSERT_LIBRARIES")).toBe(true); expect(isDangerousHostEnvVarName("ld_preload")).toBe(true); expect(isDangerousHostEnvVarName("BASH_FUNC_echo%%")).toBe(true); @@ -48,17 +55,37 @@ describe("sanitizeHostExecEnv", () => { HOME: "/tmp/evil-home", ZDOTDIR: "/tmp/evil-zdotdir", BASH_ENV: "/tmp/pwn.sh", + SHELLOPTS: "xtrace", + PS4: "$(touch /tmp/pwned)", SAFE: "ok", }, }); expect(env.PATH).toBe("/usr/bin:/bin"); expect(env.BASH_ENV).toBeUndefined(); + expect(env.SHELLOPTS).toBeUndefined(); + expect(env.PS4).toBeUndefined(); expect(env.SAFE).toBe("ok"); expect(env.HOME).toBe("/tmp/trusted-home"); expect(env.ZDOTDIR).toBe("/tmp/trusted-zdotdir"); }); + it("drops dangerous inherited shell trace keys", () => { + const env = sanitizeHostExecEnv({ + baseEnv: { + PATH: "/usr/bin:/bin", + SHELLOPTS: "xtrace", + PS4: "$(touch /tmp/pwned)", + OK: "1", + }, + }); + + expect(env.PATH).toBe("/usr/bin:/bin"); + expect(env.OK).toBe("1"); + expect(env.SHELLOPTS).toBeUndefined(); + expect(env.PS4).toBeUndefined(); + }); + it("drops non-portable env key names", () => { const env = sanitizeHostExecEnv({ baseEnv: { @@ -94,3 +121,72 @@ describe("normalizeEnvVarKey", () => { expect(normalizeEnvVarKey(" ")).toBeNull(); }); }); + +describe("sanitizeSystemRunEnvOverrides", () => { + it("keeps overrides for non-shell commands", () => { + const overrides = sanitizeSystemRunEnvOverrides({ + shellWrapper: false, + overrides: { + OPENCLAW_TEST: "1", + TOKEN: "abc", + }, + }); + expect(overrides).toEqual({ + OPENCLAW_TEST: "1", + TOKEN: "abc", + }); + }); + + it("drops non-allowlisted overrides for shell wrappers", () => { + const overrides = sanitizeSystemRunEnvOverrides({ + shellWrapper: true, + overrides: { + OPENCLAW_TEST: "1", + TOKEN: "abc", + LANG: "C", + LC_ALL: "C", + }, + }); + expect(overrides).toEqual({ + LANG: "C", + LC_ALL: "C", + }); + }); +}); + +describe("shell wrapper exploit regression", () => { + it("blocks SHELLOPTS/PS4 chain after sanitization", async () => { + const bashPath = "/bin/bash"; + if (process.platform === "win32" || !fs.existsSync(bashPath)) { + return; + } + const marker = path.join(os.tmpdir(), `openclaw-ps4-marker-${process.pid}-${Date.now()}`); + try { + fs.unlinkSync(marker); + } catch { + // no-op + } + + const filteredOverrides = sanitizeSystemRunEnvOverrides({ + shellWrapper: true, + overrides: { + SHELLOPTS: "xtrace", + PS4: `$(touch ${marker})`, + }, + }); + const env = sanitizeHostExecEnv({ + overrides: filteredOverrides, + baseEnv: { + PATH: process.env.PATH ?? "/usr/bin:/bin", + }, + }); + + await new Promise((resolve, reject) => { + const child = spawn(bashPath, ["-lc", "echo SAFE"], { env, stdio: "ignore" }); + child.once("error", reject); + child.once("close", () => resolve()); + }); + + expect(fs.existsSync(marker)).toBe(false); + }); +}); diff --git a/src/infra/host-env-security.ts b/src/infra/host-env-security.ts index b1d869cf9..79ccd1f0a 100644 --- a/src/infra/host-env-security.ts +++ b/src/infra/host-env-security.ts @@ -19,10 +19,23 @@ export const HOST_DANGEROUS_ENV_PREFIXES: readonly string[] = Object.freeze( export const HOST_DANGEROUS_OVERRIDE_ENV_KEY_VALUES: readonly string[] = Object.freeze( (HOST_ENV_SECURITY_POLICY.blockedOverrideKeys ?? []).map((key) => key.toUpperCase()), ); +export const HOST_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEY_VALUES: readonly string[] = Object.freeze([ + "TERM", + "LANG", + "LC_ALL", + "LC_CTYPE", + "LC_MESSAGES", + "COLORTERM", + "NO_COLOR", + "FORCE_COLOR", +]); export const HOST_DANGEROUS_ENV_KEYS = new Set(HOST_DANGEROUS_ENV_KEY_VALUES); export const HOST_DANGEROUS_OVERRIDE_ENV_KEYS = new Set( HOST_DANGEROUS_OVERRIDE_ENV_KEY_VALUES, ); +export const HOST_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEYS = new Set( + HOST_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEY_VALUES, +); export function normalizeEnvVarKey( rawKey: string, @@ -105,3 +118,31 @@ export function sanitizeHostExecEnv(params?: { return merged; } + +export function sanitizeSystemRunEnvOverrides(params?: { + overrides?: Record | null; + shellWrapper?: boolean; +}): Record | undefined { + const overrides = params?.overrides ?? undefined; + if (!overrides) { + return undefined; + } + if (!params?.shellWrapper) { + return overrides; + } + const filtered: Record = {}; + for (const [rawKey, value] of Object.entries(overrides)) { + if (typeof value !== "string") { + continue; + } + const key = normalizeEnvVarKey(rawKey, { portable: true }); + if (!key) { + continue; + } + if (!HOST_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEYS.has(key.toUpperCase())) { + continue; + } + filtered[key] = value; + } + return Object.keys(filtered).length > 0 ? filtered : undefined; +} diff --git a/src/line/bot-handlers.ts b/src/line/bot-handlers.ts index 206a4d185..e6b30f42d 100644 --- a/src/line/bot-handlers.ts +++ b/src/line/bot-handlers.ts @@ -8,6 +8,11 @@ import type { PostbackEvent, } from "@line/bot-sdk"; import type { OpenClawConfig } from "../config/config.js"; +import { + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, +} from "../config/runtime-group-policy.js"; import { danger, logVerbose } from "../globals.js"; import { resolvePairingIdLabel } from "../pairing/pairing-labels.js"; import { buildPairingReply } from "../pairing/pairing-messages.js"; @@ -132,8 +137,19 @@ async function shouldProcessLineEvent( storeAllowFrom, dmPolicy, }); - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy, providerMissingFallbackApplied } = + resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.line !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "line", + accountId: account.accountId, + log: (message) => logVerbose(message), + }); if (isGroup) { if (groupConfig?.enabled === false) { diff --git a/src/line/send.test.ts b/src/line/send.test.ts index 317ab3084..016959259 100644 --- a/src/line/send.test.ts +++ b/src/line/send.test.ts @@ -1,11 +1,228 @@ -import { describe, expect, it } from "vitest"; -import { createQuickReplyItems } from "./send.js"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -describe("createQuickReplyItems", () => { - it("limits items to 13 (LINE maximum)", () => { - const labels = Array.from({ length: 20 }, (_, i) => `Option ${i + 1}`); - const quickReply = createQuickReplyItems(labels); +const { + pushMessageMock, + replyMessageMock, + showLoadingAnimationMock, + getProfileMock, + MessagingApiClientMock, + loadConfigMock, + resolveLineAccountMock, + resolveLineChannelAccessTokenMock, + recordChannelActivityMock, + logVerboseMock, +} = vi.hoisted(() => { + const pushMessageMock = vi.fn(); + const replyMessageMock = vi.fn(); + const showLoadingAnimationMock = vi.fn(); + const getProfileMock = vi.fn(); + const MessagingApiClientMock = vi.fn(function () { + return { + pushMessage: pushMessageMock, + replyMessage: replyMessageMock, + showLoadingAnimation: showLoadingAnimationMock, + getProfile: getProfileMock, + }; + }); + const loadConfigMock = vi.fn(() => ({})); + const resolveLineAccountMock = vi.fn(() => ({ accountId: "default" })); + const resolveLineChannelAccessTokenMock = vi.fn(() => "line-token"); + const recordChannelActivityMock = vi.fn(); + const logVerboseMock = vi.fn(); + return { + pushMessageMock, + replyMessageMock, + showLoadingAnimationMock, + getProfileMock, + MessagingApiClientMock, + loadConfigMock, + resolveLineAccountMock, + resolveLineChannelAccessTokenMock, + recordChannelActivityMock, + logVerboseMock, + }; +}); + +vi.mock("@line/bot-sdk", () => ({ + messagingApi: { MessagingApiClient: MessagingApiClientMock }, +})); + +vi.mock("../config/config.js", () => ({ + loadConfig: loadConfigMock, +})); + +vi.mock("./accounts.js", () => ({ + resolveLineAccount: resolveLineAccountMock, +})); + +vi.mock("./channel-access-token.js", () => ({ + resolveLineChannelAccessToken: resolveLineChannelAccessTokenMock, +})); + +vi.mock("../infra/channel-activity.js", () => ({ + recordChannelActivity: recordChannelActivityMock, +})); + +vi.mock("../globals.js", () => ({ + logVerbose: logVerboseMock, +})); + +let sendModule: typeof import("./send.js"); + +describe("LINE send helpers", () => { + beforeAll(async () => { + sendModule = await import("./send.js"); + }); + + beforeEach(() => { + pushMessageMock.mockReset(); + replyMessageMock.mockReset(); + showLoadingAnimationMock.mockReset(); + getProfileMock.mockReset(); + MessagingApiClientMock.mockClear(); + loadConfigMock.mockReset(); + resolveLineAccountMock.mockReset(); + resolveLineChannelAccessTokenMock.mockReset(); + recordChannelActivityMock.mockReset(); + logVerboseMock.mockReset(); + + loadConfigMock.mockReturnValue({}); + resolveLineAccountMock.mockReturnValue({ accountId: "default" }); + resolveLineChannelAccessTokenMock.mockReturnValue("line-token"); + pushMessageMock.mockResolvedValue({}); + replyMessageMock.mockResolvedValue({}); + showLoadingAnimationMock.mockResolvedValue({}); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("limits quick reply items to 13", () => { + const labels = Array.from({ length: 20 }, (_, index) => `Option ${index + 1}`); + const quickReply = sendModule.createQuickReplyItems(labels); expect(quickReply.items).toHaveLength(13); }); + + it("pushes images via normalized LINE target", async () => { + const result = await sendModule.pushImageMessage( + "line:user:U123", + "https://example.com/original.jpg", + undefined, + { verbose: true }, + ); + + expect(pushMessageMock).toHaveBeenCalledWith({ + to: "U123", + messages: [ + { + type: "image", + originalContentUrl: "https://example.com/original.jpg", + previewImageUrl: "https://example.com/original.jpg", + }, + ], + }); + expect(recordChannelActivityMock).toHaveBeenCalledWith({ + channel: "line", + accountId: "default", + direction: "outbound", + }); + expect(logVerboseMock).toHaveBeenCalledWith("line: pushed image to U123"); + expect(result).toEqual({ messageId: "push", chatId: "U123" }); + }); + + it("replies when reply token is provided", async () => { + const result = await sendModule.sendMessageLine("line:group:C1", "Hello", { + replyToken: "reply-token", + mediaUrl: "https://example.com/media.jpg", + verbose: true, + }); + + expect(replyMessageMock).toHaveBeenCalledTimes(1); + expect(pushMessageMock).not.toHaveBeenCalled(); + expect(replyMessageMock).toHaveBeenCalledWith({ + replyToken: "reply-token", + messages: [ + { + type: "image", + originalContentUrl: "https://example.com/media.jpg", + previewImageUrl: "https://example.com/media.jpg", + }, + { + type: "text", + text: "Hello", + }, + ], + }); + expect(logVerboseMock).toHaveBeenCalledWith("line: replied to C1"); + expect(result).toEqual({ messageId: "reply", chatId: "C1" }); + }); + + it("throws when push messages are empty", async () => { + await expect(sendModule.pushMessagesLine("U123", [])).rejects.toThrow( + "Message must be non-empty for LINE sends", + ); + }); + + it("logs HTTP body when push fails", async () => { + const err = new Error("LINE push failed") as Error & { + status: number; + statusText: string; + body: string; + }; + err.status = 400; + err.statusText = "Bad Request"; + err.body = "invalid flex payload"; + pushMessageMock.mockRejectedValueOnce(err); + + await expect( + sendModule.pushMessagesLine("U999", [{ type: "text", text: "hello" }]), + ).rejects.toThrow("LINE push failed"); + + expect(logVerboseMock).toHaveBeenCalledWith( + "line: push message failed (400 Bad Request): invalid flex payload", + ); + }); + + it("caches profile results by default", async () => { + getProfileMock.mockResolvedValue({ + displayName: "Peter", + pictureUrl: "https://example.com/peter.jpg", + }); + + const first = await sendModule.getUserProfile("U-cache"); + const second = await sendModule.getUserProfile("U-cache"); + + expect(first).toEqual({ + displayName: "Peter", + pictureUrl: "https://example.com/peter.jpg", + }); + expect(second).toEqual(first); + expect(getProfileMock).toHaveBeenCalledTimes(1); + }); + + it("continues when loading animation is unsupported", async () => { + showLoadingAnimationMock.mockRejectedValueOnce(new Error("unsupported")); + + await expect(sendModule.showLoadingAnimation("line:room:R1")).resolves.toBeUndefined(); + + expect(logVerboseMock).toHaveBeenCalledWith( + expect.stringContaining("line: loading animation failed (non-fatal)"), + ); + }); + + it("pushes quick-reply text and caps to 13 buttons", async () => { + await sendModule.pushTextMessageWithQuickReplies( + "U-quick", + "Pick one", + Array.from({ length: 20 }, (_, index) => `Choice ${index + 1}`), + ); + + expect(pushMessageMock).toHaveBeenCalledTimes(1); + const firstCall = pushMessageMock.mock.calls[0] as [ + { messages: Array<{ quickReply?: { items: unknown[] } }> }, + ]; + expect(firstCall[0].messages[0].quickReply?.items).toHaveLength(13); + }); }); diff --git a/src/line/send.ts b/src/line/send.ts index f68df9a29..7b6f4ac93 100644 --- a/src/line/send.ts +++ b/src/line/send.ts @@ -32,6 +32,18 @@ interface LineSendOpts { replyToken?: string; } +type LineClientOpts = Pick; +type LinePushOpts = Pick; + +interface LinePushBehavior { + errorContext?: string; + verboseMessage?: (chatId: string, messageCount: number) => string; +} + +interface LineReplyBehavior { + verboseMessage?: (messageCount: number) => string; +} + function normalizeTarget(to: string): string { const trimmed = to.trim(); if (!trimmed) { @@ -52,7 +64,7 @@ function normalizeTarget(to: string): string { return normalized; } -function createLineMessagingClient(opts: { channelAccessToken?: string; accountId?: string }): { +function createLineMessagingClient(opts: LineClientOpts): { account: ReturnType; client: messagingApi.MessagingApiClient; } { @@ -70,7 +82,7 @@ function createLineMessagingClient(opts: { channelAccessToken?: string; accountI function createLinePushContext( to: string, - opts: { channelAccessToken?: string; accountId?: string }, + opts: LineClientOpts, ): { account: ReturnType; client: messagingApi.MessagingApiClient; @@ -126,23 +138,85 @@ function logLineHttpError(err: unknown, context: string): void { } } +function recordLineOutboundActivity(accountId: string): void { + recordChannelActivity({ + channel: "line", + accountId, + direction: "outbound", + }); +} + +async function pushLineMessages( + to: string, + messages: Message[], + opts: LinePushOpts = {}, + behavior: LinePushBehavior = {}, +): Promise { + if (messages.length === 0) { + throw new Error("Message must be non-empty for LINE sends"); + } + + const { account, client, chatId } = createLinePushContext(to, opts); + const pushRequest = client.pushMessage({ + to: chatId, + messages, + }); + + if (behavior.errorContext) { + const errorContext = behavior.errorContext; + await pushRequest.catch((err) => { + logLineHttpError(err, errorContext); + throw err; + }); + } else { + await pushRequest; + } + + recordLineOutboundActivity(account.accountId); + + if (opts.verbose) { + const logMessage = + behavior.verboseMessage?.(chatId, messages.length) ?? + `line: pushed ${messages.length} messages to ${chatId}`; + logVerbose(logMessage); + } + + return { + messageId: "push", + chatId, + }; +} + +async function replyLineMessages( + replyToken: string, + messages: Message[], + opts: LinePushOpts = {}, + behavior: LineReplyBehavior = {}, +): Promise { + const { account, client } = createLineMessagingClient(opts); + + await client.replyMessage({ + replyToken, + messages, + }); + + recordLineOutboundActivity(account.accountId); + + if (opts.verbose) { + logVerbose( + behavior.verboseMessage?.(messages.length) ?? + `line: replied with ${messages.length} messages`, + ); + } +} + export async function sendMessageLine( to: string, text: string, opts: LineSendOpts = {}, ): Promise { - const cfg = loadConfig(); - const account = resolveLineAccount({ - cfg, - accountId: opts.accountId, - }); - const token = resolveLineChannelAccessToken(opts.channelAccessToken, account); const chatId = normalizeTarget(to); - const client = new messagingApi.MessagingApiClient({ - channelAccessToken: token, - }); - const messages: Message[] = []; // Add media if provided @@ -161,21 +235,10 @@ export async function sendMessageLine( // Use reply if we have a reply token, otherwise push if (opts.replyToken) { - await client.replyMessage({ - replyToken: opts.replyToken, - messages, + await replyLineMessages(opts.replyToken, messages, opts, { + verboseMessage: () => `line: replied to ${chatId}`, }); - recordChannelActivity({ - channel: "line", - accountId: account.accountId, - direction: "outbound", - }); - - if (opts.verbose) { - logVerbose(`line: replied to ${chatId}`); - } - return { messageId: "reply", chatId, @@ -183,25 +246,9 @@ export async function sendMessageLine( } // Push message (for proactive messaging) - await client.pushMessage({ - to: chatId, - messages, + return pushLineMessages(chatId, messages, opts, { + verboseMessage: (resolvedChatId) => `line: pushed message to ${resolvedChatId}`, }); - - recordChannelActivity({ - channel: "line", - accountId: account.accountId, - direction: "outbound", - }); - - if (opts.verbose) { - logVerbose(`line: pushed message to ${chatId}`); - } - - return { - messageId: "push", - chatId, - }; } export async function pushMessageLine( @@ -216,61 +263,19 @@ export async function pushMessageLine( export async function replyMessageLine( replyToken: string, messages: Message[], - opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {}, + opts: LinePushOpts = {}, ): Promise { - const { account, client } = createLineMessagingClient(opts); - - await client.replyMessage({ - replyToken, - messages, - }); - - recordChannelActivity({ - channel: "line", - accountId: account.accountId, - direction: "outbound", - }); - - if (opts.verbose) { - logVerbose(`line: replied with ${messages.length} messages`); - } + await replyLineMessages(replyToken, messages, opts); } export async function pushMessagesLine( to: string, messages: Message[], - opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {}, + opts: LinePushOpts = {}, ): Promise { - if (messages.length === 0) { - throw new Error("Message must be non-empty for LINE sends"); - } - - const { account, client, chatId } = createLinePushContext(to, opts); - - await client - .pushMessage({ - to: chatId, - messages, - }) - .catch((err) => { - logLineHttpError(err, "push message"); - throw err; - }); - - recordChannelActivity({ - channel: "line", - accountId: account.accountId, - direction: "outbound", + return pushLineMessages(to, messages, opts, { + errorContext: "push message", }); - - if (opts.verbose) { - logVerbose(`line: pushed ${messages.length} messages to ${chatId}`); - } - - return { - messageId: "push", - chatId, - }; } export function createFlexMessage( @@ -291,31 +296,11 @@ export async function pushImageMessage( to: string, originalContentUrl: string, previewImageUrl?: string, - opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {}, + opts: LinePushOpts = {}, ): Promise { - const { account, client, chatId } = createLinePushContext(to, opts); - - const imageMessage = createImageMessage(originalContentUrl, previewImageUrl); - - await client.pushMessage({ - to: chatId, - messages: [imageMessage], + return pushLineMessages(to, [createImageMessage(originalContentUrl, previewImageUrl)], opts, { + verboseMessage: (chatId) => `line: pushed image to ${chatId}`, }); - - recordChannelActivity({ - channel: "line", - accountId: account.accountId, - direction: "outbound", - }); - - if (opts.verbose) { - logVerbose(`line: pushed image to ${chatId}`); - } - - return { - messageId: "push", - chatId, - }; } /** @@ -329,31 +314,11 @@ export async function pushLocationMessage( latitude: number; longitude: number; }, - opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {}, + opts: LinePushOpts = {}, ): Promise { - const { account, client, chatId } = createLinePushContext(to, opts); - - const locationMessage = createLocationMessage(location); - - await client.pushMessage({ - to: chatId, - messages: [locationMessage], + return pushLineMessages(to, [createLocationMessage(location)], opts, { + verboseMessage: (chatId) => `line: pushed location to ${chatId}`, }); - - recordChannelActivity({ - channel: "line", - accountId: account.accountId, - direction: "outbound", - }); - - if (opts.verbose) { - logVerbose(`line: pushed location to ${chatId}`); - } - - return { - messageId: "push", - chatId, - }; } /** @@ -363,40 +328,18 @@ export async function pushFlexMessage( to: string, altText: string, contents: FlexContainer, - opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {}, + opts: LinePushOpts = {}, ): Promise { - const { account, client, chatId } = createLinePushContext(to, opts); - const flexMessage: FlexMessage = { type: "flex", altText: altText.slice(0, 400), // LINE limit contents, }; - await client - .pushMessage({ - to: chatId, - messages: [flexMessage], - }) - .catch((err) => { - logLineHttpError(err, "push flex message"); - throw err; - }); - - recordChannelActivity({ - channel: "line", - accountId: account.accountId, - direction: "outbound", + return pushLineMessages(to, [flexMessage], opts, { + errorContext: "push flex message", + verboseMessage: (chatId) => `line: pushed flex message to ${chatId}`, }); - - if (opts.verbose) { - logVerbose(`line: pushed flex message to ${chatId}`); - } - - return { - messageId: "push", - chatId, - }; } /** @@ -405,29 +348,11 @@ export async function pushFlexMessage( export async function pushTemplateMessage( to: string, template: TemplateMessage, - opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {}, + opts: LinePushOpts = {}, ): Promise { - const { account, client, chatId } = createLinePushContext(to, opts); - - await client.pushMessage({ - to: chatId, - messages: [template], + return pushLineMessages(to, [template], opts, { + verboseMessage: (chatId) => `line: pushed template message to ${chatId}`, }); - - recordChannelActivity({ - channel: "line", - accountId: account.accountId, - direction: "outbound", - }); - - if (opts.verbose) { - logVerbose(`line: pushed template message to ${chatId}`); - } - - return { - messageId: "push", - chatId, - }; } /** @@ -437,31 +362,13 @@ export async function pushTextMessageWithQuickReplies( to: string, text: string, quickReplyLabels: string[], - opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {}, + opts: LinePushOpts = {}, ): Promise { - const { account, client, chatId } = createLinePushContext(to, opts); - const message = createTextMessageWithQuickReplies(text, quickReplyLabels); - await client.pushMessage({ - to: chatId, - messages: [message], + return pushLineMessages(to, [message], opts, { + verboseMessage: (chatId) => `line: pushed message with quick replies to ${chatId}`, }); - - recordChannelActivity({ - channel: "line", - accountId: account.accountId, - direction: "outbound", - }); - - if (opts.verbose) { - logVerbose(`line: pushed message with quick replies to ${chatId}`); - } - - return { - messageId: "push", - chatId, - }; } /** @@ -500,16 +407,7 @@ export async function showLoadingAnimation( chatId: string, opts: { channelAccessToken?: string; accountId?: string; loadingSeconds?: number } = {}, ): Promise { - const cfg = loadConfig(); - const account = resolveLineAccount({ - cfg, - accountId: opts.accountId, - }); - const token = resolveLineChannelAccessToken(opts.channelAccessToken, account); - - const client = new messagingApi.MessagingApiClient({ - channelAccessToken: token, - }); + const { client } = createLineMessagingClient(opts); try { await client.showLoadingAnimation({ @@ -540,16 +438,7 @@ export async function getUserProfile( } } - const cfg = loadConfig(); - const account = resolveLineAccount({ - cfg, - accountId: opts.accountId, - }); - const token = resolveLineChannelAccessToken(opts.channelAccessToken, account); - - const client = new messagingApi.MessagingApiClient({ - channelAccessToken: token, - }); + const { client } = createLineMessagingClient(opts); try { const profile = await client.getProfile(userId); diff --git a/src/node-host/invoke-system-run.ts b/src/node-host/invoke-system-run.ts index c22a65b51..92f3b632b 100644 --- a/src/node-host/invoke-system-run.ts +++ b/src/node-host/invoke-system-run.ts @@ -11,14 +11,14 @@ import { requiresExecApproval, resolveAllowAlwaysPatterns, resolveExecApprovals, - resolveSafeBins, type ExecAllowlistEntry, type ExecAsk, type ExecCommandSegment, type ExecSecurity, } from "../infra/exec-approvals.js"; import type { ExecHostRequest, ExecHostResponse, ExecHostRunResult } from "../infra/exec-host.js"; -import { getTrustedSafeBinDirs } from "../infra/exec-safe-bin-trust.js"; +import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js"; +import { sanitizeSystemRunEnvOverrides } from "../infra/host-env-security.js"; import { resolveSystemRunCommand } from "../infra/system-run-command.js"; import type { ExecEventPayload, @@ -109,9 +109,15 @@ export async function handleSystemRunInvoke(opts: { const autoAllowSkills = approvals.agent.autoAllowSkills; const sessionKey = opts.params.sessionKey?.trim() || "node"; const runId = opts.params.runId?.trim() || crypto.randomUUID(); - const env = opts.sanitizeEnv(opts.params.env ?? undefined); - const safeBins = resolveSafeBins(agentExec?.safeBins ?? cfg.tools?.exec?.safeBins); - const trustedSafeBinDirs = getTrustedSafeBinDirs(); + const envOverrides = sanitizeSystemRunEnvOverrides({ + overrides: opts.params.env ?? undefined, + shellWrapper: shellCommand !== null, + }); + const env = opts.sanitizeEnv(envOverrides); + const { safeBins, safeBinProfiles, trustedSafeBinDirs } = resolveExecSafeBinRuntimePolicy({ + global: cfg.tools?.exec, + local: agentExec, + }); const bins = autoAllowSkills ? await opts.skillBins.current() : new Set(); let analysisOk = false; let allowlistMatches: ExecAllowlistEntry[] = []; @@ -122,6 +128,7 @@ export async function handleSystemRunInvoke(opts: { command: shellCommand, allowlist: approvals.allowlist, safeBins, + safeBinProfiles, cwd: opts.params.cwd ?? undefined, env, trustedSafeBinDirs, @@ -140,6 +147,7 @@ export async function handleSystemRunInvoke(opts: { analysis, allowlist: approvals.allowlist, safeBins, + safeBinProfiles, cwd: opts.params.cwd ?? undefined, trustedSafeBinDirs, skillBins: bins, @@ -171,7 +179,7 @@ export async function handleSystemRunInvoke(opts: { command: argv, rawCommand: rawCommand || shellCommand || null, cwd: opts.params.cwd ?? null, - env: opts.params.env ?? null, + env: envOverrides ?? null, timeoutMs: opts.params.timeoutMs ?? null, needsScreenRecording: opts.params.needsScreenRecording ?? null, agentId: agentId ?? null, diff --git a/src/node-host/invoke.sanitize-env.test.ts b/src/node-host/invoke.sanitize-env.test.ts index fe9143219..dfa44ccd0 100644 --- a/src/node-host/invoke.sanitize-env.test.ts +++ b/src/node-host/invoke.sanitize-env.test.ts @@ -12,18 +12,25 @@ describe("node-host sanitizeEnv", () => { }); it("blocks dangerous env keys/prefixes", () => { - withEnv({ PYTHONPATH: undefined, LD_PRELOAD: undefined, BASH_ENV: undefined }, () => { - const env = sanitizeEnv({ - PYTHONPATH: "/tmp/pwn", - LD_PRELOAD: "/tmp/pwn.so", - BASH_ENV: "/tmp/pwn.sh", - FOO: "bar", - }); - expect(env.FOO).toBe("bar"); - expect(env.PYTHONPATH).toBeUndefined(); - expect(env.LD_PRELOAD).toBeUndefined(); - expect(env.BASH_ENV).toBeUndefined(); - }); + withEnv( + { PYTHONPATH: undefined, LD_PRELOAD: undefined, BASH_ENV: undefined, SHELLOPTS: undefined }, + () => { + const env = sanitizeEnv({ + PYTHONPATH: "/tmp/pwn", + LD_PRELOAD: "/tmp/pwn.so", + BASH_ENV: "/tmp/pwn.sh", + SHELLOPTS: "xtrace", + PS4: "$(touch /tmp/pwned)", + FOO: "bar", + }); + expect(env.FOO).toBe("bar"); + expect(env.PYTHONPATH).toBeUndefined(); + expect(env.LD_PRELOAD).toBeUndefined(); + expect(env.BASH_ENV).toBeUndefined(); + expect(env.SHELLOPTS).toBeUndefined(); + expect(env.PS4).toBeUndefined(); + }, + ); }); it("blocks dangerous override-only env keys", () => { diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index a3f58c034..01e890c8b 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -132,6 +132,19 @@ export type { MSTeamsReplyStyle, MSTeamsTeamConfig, } from "../config/types.js"; +export { + GROUP_POLICY_BLOCKED_LABEL, + resetMissingProviderGroupPolicyFallbackWarningsForTesting, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + resolveOpenProviderRuntimeGroupPolicy, + resolveRuntimeGroupPolicy, + type GroupPolicyDefaultsConfig, + type RuntimeGroupPolicyResolution, + type RuntimeGroupPolicyParams, + type ResolveProviderRuntimeGroupPolicyParams, + warnMissingProviderGroupPolicyFallbackOnce, +} from "../config/runtime-group-policy.js"; export { DiscordConfigSchema, GoogleChatConfigSchema, diff --git a/src/process/child-process-bridge.test.ts b/src/process/child-process-bridge.test.ts index 771b62965..04ef5715c 100644 --- a/src/process/child-process-bridge.test.ts +++ b/src/process/child-process-bridge.test.ts @@ -4,8 +4,8 @@ import process from "node:process"; import { afterEach, describe, expect, it } from "vitest"; import { attachChildProcessBridge } from "./child-process-bridge.js"; -const CHILD_READY_TIMEOUT_MS = 2_000; -const CHILD_EXIT_TIMEOUT_MS = 3_000; +const CHILD_READY_TIMEOUT_MS = 10_000; +const CHILD_EXIT_TIMEOUT_MS = 10_000; function waitForLine( stream: NodeJS.ReadableStream, diff --git a/src/process/test-timeouts.ts b/src/process/test-timeouts.ts new file mode 100644 index 000000000..d1721d5bf --- /dev/null +++ b/src/process/test-timeouts.ts @@ -0,0 +1,20 @@ +export const PROCESS_TEST_TIMEOUT_MS = { + tiny: 25, + short: 100, + standard: 3_000, + medium: 5_000, + long: 10_000, + extraLong: 15_000, +} as const; + +export const PROCESS_TEST_SCRIPT_DELAY_MS = { + silentProcess: 120, + streamingInterval: 1_800, + streamingDuration: 9_000, +} as const; + +export const PROCESS_TEST_NO_OUTPUT_TIMEOUT_MS = { + exec: 120, + supervisor: 100, + streamingAllowance: 6_000, +} as const; diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index c8703341c..45198951a 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -296,6 +296,70 @@ describe("security audit", () => { expect(hasFinding(res, "tools.exec.host_sandbox_no_sandbox_agents", "warn")).toBe(true); }); + it("warns for interpreter safeBins entries without explicit profiles", async () => { + const cfg: OpenClawConfig = { + tools: { + exec: { + safeBins: ["python3"], + }, + }, + agents: { + list: [ + { + id: "ops", + tools: { + exec: { + safeBins: ["node"], + }, + }, + }, + ], + }, + }; + + const res = await audit(cfg); + + expect(hasFinding(res, "tools.exec.safe_bins_interpreter_unprofiled", "warn")).toBe(true); + }); + + it("does not warn for interpreter safeBins when explicit profiles are present", async () => { + const cfg: OpenClawConfig = { + tools: { + exec: { + safeBins: ["python3"], + safeBinProfiles: { + python3: { + maxPositional: 0, + }, + }, + }, + }, + agents: { + list: [ + { + id: "ops", + tools: { + exec: { + safeBins: ["node"], + safeBinProfiles: { + node: { + maxPositional: 0, + }, + }, + }, + }, + }, + ], + }, + }; + + const res = await audit(cfg); + + expect( + res.findings.some((f) => f.checkId === "tools.exec.safe_bins_interpreter_unprofiled"), + ).toBe(false); + }); + it("warns when loopback control UI lacks trusted proxies", async () => { const cfg: OpenClawConfig = { gateway: { @@ -974,6 +1038,20 @@ describe("security audit", () => { }); it("scores X-Real-IP fallback risk by gateway exposure", async () => { + const trustedProxyCfg = (trustedProxies: string[]): OpenClawConfig => ({ + gateway: { + bind: "loopback", + allowRealIpFallback: true, + trustedProxies, + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + }, + }, + }, + }); + const cases: Array<{ name: string; cfg: OpenClawConfig; @@ -1011,36 +1089,22 @@ describe("security audit", () => { }, { name: "loopback trusted-proxy with loopback-only proxies", - cfg: { - gateway: { - bind: "loopback", - allowRealIpFallback: true, - trustedProxies: ["127.0.0.1"], - auth: { - mode: "trusted-proxy", - trustedProxy: { - userHeader: "x-forwarded-user", - }, - }, - }, - }, + cfg: trustedProxyCfg(["127.0.0.1"]), expectedSeverity: "warn", }, { name: "loopback trusted-proxy with non-loopback proxy range", - cfg: { - gateway: { - bind: "loopback", - allowRealIpFallback: true, - trustedProxies: ["127.0.0.1", "10.0.0.0/8"], - auth: { - mode: "trusted-proxy", - trustedProxy: { - userHeader: "x-forwarded-user", - }, - }, - }, - }, + cfg: trustedProxyCfg(["127.0.0.1", "10.0.0.0/8"]), + expectedSeverity: "critical", + }, + { + name: "loopback trusted-proxy with 127.0.0.2", + cfg: trustedProxyCfg(["127.0.0.2"]), + expectedSeverity: "critical", + }, + { + name: "loopback trusted-proxy with 127.0.0.0/8 range", + cfg: trustedProxyCfg(["127.0.0.0/8"]), expectedSeverity: "critical", }, ]; diff --git a/src/security/audit.ts b/src/security/audit.ts index c02191cf3..fdb3be9ce 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -9,9 +9,12 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; import { buildGatewayConnectionDetails } from "../gateway/call.js"; -import { isLoopbackAddress } from "../gateway/net.js"; import { resolveGatewayProbeAuth } from "../gateway/probe-auth.js"; import { probeGateway } from "../gateway/probe.js"; +import { + listInterpreterLikeSafeBins, + resolveMergedSafeBinProfileFixtures, +} from "../infra/exec-safe-bin-runtime-policy.js"; import { collectChannelSecurityFindings } from "./audit-channel.js"; import { collectAttackSurfaceSummaryFindings, @@ -340,7 +343,7 @@ function collectGatewayConfigFindings( if (allowRealIpFallback) { const hasNonLoopbackTrustedProxy = trustedProxies.some( - (proxy) => !isLoopbackOnlyTrustedProxyEntry(proxy), + (proxy) => !isStrictLoopbackTrustedProxyEntry(proxy), ); const exposed = bind !== "loopback" || (auth.mode === "trusted-proxy" && hasNonLoopbackTrustedProxy); @@ -508,13 +511,15 @@ function collectGatewayConfigFindings( return findings; } -function isLoopbackOnlyTrustedProxyEntry(entry: string): boolean { +// Keep this stricter than isLoopbackAddress on purpose: this check is for +// trust boundaries, so only explicit localhost proxy hops are treated as local. +function isStrictLoopbackTrustedProxyEntry(entry: string): boolean { const candidate = entry.trim(); if (!candidate) { return false; } if (!candidate.includes("/")) { - return isLoopbackAddress(candidate); + return candidate === "127.0.0.1" || candidate.toLowerCase() === "::1"; } const [rawIp, rawPrefix] = candidate.split("/", 2); @@ -527,11 +532,7 @@ function isLoopbackOnlyTrustedProxyEntry(entry: string): boolean { return false; } if (ipVersion === 4) { - if (prefix < 8 || prefix > 32) { - return false; - } - const firstOctet = Number.parseInt(rawIp.trim().split(".")[0] ?? "", 10); - return firstOctet === 127; + return rawIp.trim() === "127.0.0.1" && prefix === 32; } if (ipVersion === 6) { return prefix === 128 && rawIp.trim().toLowerCase() === "::1"; @@ -698,6 +699,65 @@ function collectExecRuntimeFindings(cfg: OpenClawConfig): SecurityAuditFinding[] }); } + const normalizeConfiguredSafeBins = (entries: unknown): string[] => { + if (!Array.isArray(entries)) { + return []; + } + return Array.from( + new Set( + entries + .map((entry) => (typeof entry === "string" ? entry.trim().toLowerCase() : "")) + .filter((entry) => entry.length > 0), + ), + ).toSorted(); + }; + const interpreterHits: string[] = []; + const globalExec = cfg.tools?.exec; + const globalSafeBins = normalizeConfiguredSafeBins(globalExec?.safeBins); + if (globalSafeBins.length > 0) { + const merged = resolveMergedSafeBinProfileFixtures({ global: globalExec }) ?? {}; + const interpreters = listInterpreterLikeSafeBins(globalSafeBins).filter((bin) => !merged[bin]); + if (interpreters.length > 0) { + interpreterHits.push(`- tools.exec.safeBins: ${interpreters.join(", ")}`); + } + } + + for (const entry of agents) { + if (!entry || typeof entry !== "object" || typeof entry.id !== "string") { + continue; + } + const agentExec = entry.tools?.exec; + const agentSafeBins = normalizeConfiguredSafeBins(agentExec?.safeBins); + if (agentSafeBins.length === 0) { + continue; + } + const merged = + resolveMergedSafeBinProfileFixtures({ + global: globalExec, + local: agentExec, + }) ?? {}; + const interpreters = listInterpreterLikeSafeBins(agentSafeBins).filter((bin) => !merged[bin]); + if (interpreters.length === 0) { + continue; + } + interpreterHits.push( + `- agents.list.${entry.id}.tools.exec.safeBins: ${interpreters.join(", ")}`, + ); + } + + if (interpreterHits.length > 0) { + findings.push({ + checkId: "tools.exec.safe_bins_interpreter_unprofiled", + severity: "warn", + title: "safeBins includes interpreter/runtime binaries without explicit profiles", + detail: + `Detected interpreter-like safeBins entries missing explicit profiles:\n${interpreterHits.join("\n")}\n` + + "These entries can turn safeBins into a broad execution surface when used with permissive argv profiles.", + remediation: + "Remove interpreter/runtime bins from safeBins (prefer allowlist entries) or define hardened tools.exec.safeBinProfiles. rules.", + }); + } + return findings; } diff --git a/src/security/weak-random-patterns.test.ts b/src/security/weak-random-patterns.test.ts index fa1d0b342..0bc17d46e 100644 --- a/src/security/weak-random-patterns.test.ts +++ b/src/security/weak-random-patterns.test.ts @@ -1,68 +1,33 @@ -import fs from "node:fs"; +import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { listRuntimeSourceFiles } from "../test-utils/repo-scan.js"; const SCAN_ROOTS = ["src", "extensions"] as const; -const SKIP_DIRS = new Set([".git", "dist", "node_modules"]); -function collectTypeScriptFiles(rootDir: string): string[] { - const out: string[] = []; - const stack = [rootDir]; - while (stack.length > 0) { - const current = stack.pop(); - if (!current) { - continue; - } - for (const entry of fs.readdirSync(current, { withFileTypes: true })) { - const fullPath = path.join(current, entry.name); - if (entry.isDirectory()) { - if (!SKIP_DIRS.has(entry.name)) { - stack.push(fullPath); - } - continue; - } - if (!entry.isFile()) { - continue; - } - if ( - !entry.name.endsWith(".ts") || - entry.name.endsWith(".test.ts") || - entry.name.endsWith(".d.ts") - ) { - continue; - } - out.push(fullPath); - } - } - return out; -} - -function findWeakRandomPatternMatches(repoRoot: string): string[] { +async function findWeakRandomPatternMatches(repoRoot: string): Promise { const matches: string[] = []; - for (const scanRoot of SCAN_ROOTS) { - const root = path.join(repoRoot, scanRoot); - if (!fs.existsSync(root)) { - continue; - } - const files = collectTypeScriptFiles(root); - for (const filePath of files) { - const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/); - for (let idx = 0; idx < lines.length; idx += 1) { - const line = lines[idx] ?? ""; - if (!line.includes("Date.now") || !line.includes("Math.random")) { - continue; - } - matches.push(`${path.relative(repoRoot, filePath)}:${idx + 1}`); + const files = await listRuntimeSourceFiles(repoRoot, { + roots: SCAN_ROOTS, + extensions: [".ts"], + }); + for (const filePath of files) { + const lines = (await fs.readFile(filePath, "utf8")).split(/\r?\n/); + for (let idx = 0; idx < lines.length; idx += 1) { + const line = lines[idx] ?? ""; + if (!line.includes("Date.now") || !line.includes("Math.random")) { + continue; } + matches.push(`${path.relative(repoRoot, filePath)}:${idx + 1}`); } } return matches; } describe("weak random pattern guardrail", () => { - it("rejects Date.now + Math.random token/id patterns in runtime code", () => { + it("rejects Date.now + Math.random token/id patterns in runtime code", async () => { const repoRoot = path.resolve(process.cwd()); - const matches = findWeakRandomPatternMatches(repoRoot); + const matches = await findWeakRandomPatternMatches(repoRoot); expect(matches).toEqual([]); }); }); diff --git a/src/signal/monitor.ts b/src/signal/monitor.ts index 0d4d72ee5..461f38c21 100644 --- a/src/signal/monitor.ts +++ b/src/signal/monitor.ts @@ -3,6 +3,11 @@ import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "../auto-reply/re import type { ReplyPayload } from "../auto-reply/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; +import { + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, +} from "../config/runtime-group-policy.js"; import type { SignalReactionNotificationMode } from "../config/types.js"; import { waitForTransportReady } from "../infra/transport-ready.js"; import { saveMediaBuffer } from "../media/store.js"; @@ -344,8 +349,19 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi ? accountInfo.config.allowFrom : []), ); - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = accountInfo.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy, providerMissingFallbackApplied } = + resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.signal !== undefined, + groupPolicy: accountInfo.config.groupPolicy, + defaultGroupPolicy, + }); + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "signal", + accountId: accountInfo.accountId, + log: (message) => runtime.log?.(message), + }); const reactionMode = accountInfo.config.reactionNotifications ?? "own"; const reactionAllowlist = normalizeAllowList(accountInfo.config.reactionAllowlist); const mediaMaxBytes = (opts.mediaMaxMb ?? accountInfo.config.mediaMaxMb ?? 8) * 1024 * 1024; diff --git a/src/slack/monitor/provider.group-policy.test.ts b/src/slack/monitor/provider.group-policy.test.ts new file mode 100644 index 000000000..29478d13e --- /dev/null +++ b/src/slack/monitor/provider.group-policy.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +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); + }); +}); diff --git a/src/slack/monitor/provider.ts b/src/slack/monitor/provider.ts index 248728751..35003bedf 100644 --- a/src/slack/monitor/provider.ts +++ b/src/slack/monitor/provider.ts @@ -10,6 +10,11 @@ import { summarizeMapping, } from "../../channels/allowlists/resolve-utils.js"; import { loadConfig } from "../../config/config.js"; +import { + resolveOpenProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, +} from "../../config/runtime-group-policy.js"; import type { SessionScope } from "../../config/sessions.js"; import { warn } from "../../globals.js"; import { installRequestBodyLimitGuard } from "../../infra/http-body.js"; @@ -98,20 +103,19 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const groupDmEnabled = dmConfig?.groupEnabled ?? false; const groupDmChannels = dmConfig?.groupChannels; let channelsConfig = slackCfg.channels; - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = slackCfg.groupPolicy ?? defaultGroupPolicy ?? "open"; - if ( - slackCfg.groupPolicy === undefined && - slackCfg.channels === undefined && - defaultGroupPolicy === undefined && - groupPolicy === "open" - ) { - runtime.log?.( - warn( - 'slack: groupPolicy defaults to "open" when channels.slack is missing; set channels.slack.groupPolicy (or channels.defaults.groupPolicy) or add channels.slack.channels to restrict access.', - ), - ); - } + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const providerConfigPresent = cfg.channels?.slack !== undefined; + const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent, + groupPolicy: slackCfg.groupPolicy, + defaultGroupPolicy, + }); + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "slack", + accountId: account.accountId, + log: (message) => runtime.log?.(warn(message)), + }); const resolveToken = slackCfg.userToken?.trim() || botToken; const useAccessGroups = cfg.commands?.useAccessGroups !== false; @@ -363,3 +367,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { await app.stop().catch(() => undefined); } } + +export const __testing = { + resolveSlackRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, +}; diff --git a/src/telegram/group-access.group-policy.test.ts b/src/telegram/group-access.group-policy.test.ts new file mode 100644 index 000000000..9374230e1 --- /dev/null +++ b/src/telegram/group-access.group-policy.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +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); + }); +}); diff --git a/src/telegram/group-access.ts b/src/telegram/group-access.ts index 023752181..dcd0dd2ef 100644 --- a/src/telegram/group-access.ts +++ b/src/telegram/group-access.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../config/config.js"; import type { ChannelGroupPolicy } from "../config/group-policy.js"; +import { resolveOpenProviderRuntimeGroupPolicy } from "../config/runtime-group-policy.js"; import type { TelegramAccountConfig, TelegramGroupConfig, @@ -72,6 +73,17 @@ export type TelegramGroupPolicyAccessResult = groupPolicy: "open" | "disabled" | "allowlist"; }; +export const resolveTelegramRuntimeGroupPolicy = (params: { + providerConfigPresent: boolean; + groupPolicy?: TelegramAccountConfig["groupPolicy"]; + defaultGroupPolicy?: TelegramAccountConfig["groupPolicy"]; +}) => + resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent: params.providerConfigPresent, + groupPolicy: params.groupPolicy, + defaultGroupPolicy: params.defaultGroupPolicy, + }); + export const evaluateTelegramGroupPolicyAccess = (params: { isGroup: boolean; chatId: string | number; @@ -90,20 +102,21 @@ export const evaluateTelegramGroupPolicyAccess = (params: { requireSenderForAllowlistAuthorization: boolean; checkChatAllowlist: boolean; }): TelegramGroupPolicyAccessResult => { + const { groupPolicy: runtimeFallbackPolicy } = resolveTelegramRuntimeGroupPolicy({ + providerConfigPresent: params.cfg.channels?.telegram !== undefined, + groupPolicy: params.telegramCfg.groupPolicy, + defaultGroupPolicy: params.cfg.channels?.defaults?.groupPolicy, + }); const fallbackPolicy = - firstDefined( - params.telegramCfg.groupPolicy, - params.cfg.channels?.defaults?.groupPolicy, - "open", - ) ?? "open"; + firstDefined(params.telegramCfg.groupPolicy, params.cfg.channels?.defaults?.groupPolicy) ?? + runtimeFallbackPolicy; const groupPolicy = params.useTopicAndGroupOverrides ? (firstDefined( params.topicConfig?.groupPolicy, params.groupConfig?.groupPolicy, params.telegramCfg.groupPolicy, params.cfg.channels?.defaults?.groupPolicy, - "open", - ) ?? "open") + ) ?? runtimeFallbackPolicy) : fallbackPolicy; if (!params.isGroup || !params.enforcePolicy) { diff --git a/src/telegram/monitor.test.ts b/src/telegram/monitor.test.ts index 7c836e1b4..ff12faaa2 100644 --- a/src/telegram/monitor.test.ts +++ b/src/telegram/monitor.test.ts @@ -169,8 +169,12 @@ describe("monitorTelegramProvider (grammY)", () => { expect(api.sendMessage).not.toHaveBeenCalled(); }); - it("retries on recoverable network errors", async () => { - const networkError = Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }); + it("retries on recoverable undici fetch errors", async () => { + const networkError = Object.assign(new TypeError("fetch failed"), { + cause: Object.assign(new Error("connect timeout"), { + code: "UND_ERR_CONNECT_TIMEOUT", + }), + }); runSpy .mockImplementationOnce(() => ({ task: () => Promise.reject(networkError), diff --git a/src/telegram/network-errors.test.ts b/src/telegram/network-errors.test.ts index c435320bd..b92081a82 100644 --- a/src/telegram/network-errors.test.ts +++ b/src/telegram/network-errors.test.ts @@ -30,12 +30,25 @@ describe("isRecoverableTelegramNetworkError", () => { expect(isRecoverableTelegramNetworkError(new Error("Undici: socket failure"))).toBe(true); }); - it("skips message matches for send context", () => { + it("treats undici fetch failed errors as recoverable in send context", () => { const err = new TypeError("fetch failed"); - expect(isRecoverableTelegramNetworkError(err, { context: "send" })).toBe(false); + expect(isRecoverableTelegramNetworkError(err, { context: "send" })).toBe(true); + expect( + isRecoverableTelegramNetworkError(new Error("TypeError: fetch failed"), { context: "send" }), + ).toBe(true); expect(isRecoverableTelegramNetworkError(err, { context: "polling" })).toBe(true); }); + it("skips broad message matches for send context", () => { + const networkRequestErr = new Error("Network request for 'sendMessage' failed!"); + expect(isRecoverableTelegramNetworkError(networkRequestErr, { context: "send" })).toBe(false); + expect(isRecoverableTelegramNetworkError(networkRequestErr, { context: "polling" })).toBe(true); + + const undiciSnippetErr = new Error("Undici: socket failure"); + expect(isRecoverableTelegramNetworkError(undiciSnippetErr, { context: "send" })).toBe(false); + expect(isRecoverableTelegramNetworkError(undiciSnippetErr, { context: "polling" })).toBe(true); + }); + it("returns false for unrelated errors", () => { expect(isRecoverableTelegramNetworkError(new Error("invalid token"))).toBe(false); }); diff --git a/src/telegram/network-errors.ts b/src/telegram/network-errors.ts index 75c22ea7f..177ef00d6 100644 --- a/src/telegram/network-errors.ts +++ b/src/telegram/network-errors.ts @@ -27,9 +27,9 @@ const RECOVERABLE_ERROR_NAMES = new Set([ "BodyTimeoutError", ]); +const ALWAYS_RECOVERABLE_MESSAGES = new Set(["fetch failed", "typeerror: fetch failed"]); + const RECOVERABLE_MESSAGE_SNIPPETS = [ - "fetch failed", - "typeerror: fetch failed", "undici", "network error", "network request", @@ -138,9 +138,12 @@ export function isRecoverableTelegramNetworkError( return true; } - if (allowMessageMatch) { - const message = formatErrorMessage(candidate).toLowerCase(); - if (message && RECOVERABLE_MESSAGE_SNIPPETS.some((snippet) => message.includes(snippet))) { + const message = formatErrorMessage(candidate).trim().toLowerCase(); + if (message && ALWAYS_RECOVERABLE_MESSAGES.has(message)) { + return true; + } + if (allowMessageMatch && message) { + if (RECOVERABLE_MESSAGE_SNIPPETS.some((snippet) => message.includes(snippet))) { return true; } } diff --git a/src/test-utils/repo-scan.ts b/src/test-utils/repo-scan.ts new file mode 100644 index 000000000..9dbf67fed --- /dev/null +++ b/src/test-utils/repo-scan.ts @@ -0,0 +1,138 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +export const DEFAULT_REPO_SCAN_SKIP_DIR_NAMES = new Set([".git", "dist", "node_modules"]); +export const DEFAULT_RUNTIME_SOURCE_ROOTS = ["src", "extensions"] as const; +export const DEFAULT_RUNTIME_SOURCE_EXTENSIONS = [".ts", ".tsx"] as const; +export const RUNTIME_SOURCE_SKIP_PATTERNS = [ + /\.test\.tsx?$/, + /\.test-helpers\.tsx?$/, + /\.test-utils\.tsx?$/, + /\.e2e\.tsx?$/, + /\.d\.ts$/, + /\/(?:__tests__|tests)\//, + /\/[^/]*test-helpers(?:\.[^/]+)?\.tsx?$/, + /\/[^/]*test-utils(?:\.[^/]+)?\.tsx?$/, +] as const; + +export type RepoFileScanOptions = { + roots: readonly string[]; + extensions: readonly string[]; + skipDirNames?: ReadonlySet; + skipHiddenDirectories?: boolean; + shouldIncludeFile?: (relativePath: string) => boolean; +}; +export type RuntimeSourceScanOptions = { + roots?: readonly string[]; + extensions?: readonly string[]; +}; + +type PendingDir = { + absolutePath: string; +}; +const runtimeSourceScanCache = new Map>>(); + +function shouldSkipDirectory( + name: string, + options: Pick, +): boolean { + if (options.skipHiddenDirectories && name.startsWith(".")) { + return true; + } + return (options.skipDirNames ?? DEFAULT_REPO_SCAN_SKIP_DIR_NAMES).has(name); +} + +function hasAllowedExtension(fileName: string, extensions: readonly string[]): boolean { + return extensions.some((extension) => fileName.endsWith(extension)); +} + +function normalizeRelativePath(relativePath: string): string { + return relativePath.replaceAll("\\", "/"); +} + +function toSortedUnique(values: readonly string[]): Array { + return [...new Set(values)].toSorted(); +} + +function getRuntimeScanCacheKey(repoRoot: string, roots: readonly string[]): string { + return `${repoRoot}::${toSortedUnique(roots).join(",")}`; +} + +export async function listRepoFiles( + repoRoot: string, + options: RepoFileScanOptions, +): Promise> { + const files: Array = []; + const pending: Array = []; + + for (const root of options.roots) { + const absolutePath = path.join(repoRoot, root); + try { + const stats = await fs.stat(absolutePath); + if (stats.isDirectory()) { + pending.push({ absolutePath }); + } + } catch { + // Skip missing roots. Useful when extensions/ is absent. + } + } + + while (pending.length > 0) { + const current = pending.pop(); + if (!current) { + continue; + } + const entries = await fs.readdir(current.absolutePath, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + if (!shouldSkipDirectory(entry.name, options)) { + pending.push({ absolutePath: path.join(current.absolutePath, entry.name) }); + } + continue; + } + if (!entry.isFile() || !hasAllowedExtension(entry.name, options.extensions)) { + continue; + } + const filePath = path.join(current.absolutePath, entry.name); + const relativePath = path.relative(repoRoot, filePath); + if (options.shouldIncludeFile && !options.shouldIncludeFile(relativePath)) { + continue; + } + files.push(filePath); + } + } + + files.sort((a, b) => a.localeCompare(b)); + return files; +} + +export function shouldSkipRuntimeSourcePath(relativePath: string): boolean { + const normalizedPath = normalizeRelativePath(relativePath); + return RUNTIME_SOURCE_SKIP_PATTERNS.some((pattern) => pattern.test(normalizedPath)); +} + +export async function listRuntimeSourceFiles( + repoRoot: string, + options: RuntimeSourceScanOptions = {}, +): Promise> { + const roots = options.roots ?? DEFAULT_RUNTIME_SOURCE_ROOTS; + const requestedExtensions = toSortedUnique( + options.extensions ?? DEFAULT_RUNTIME_SOURCE_EXTENSIONS, + ); + const cacheKey = getRuntimeScanCacheKey(repoRoot, roots); + + let pending = runtimeSourceScanCache.get(cacheKey); + if (!pending) { + pending = listRepoFiles(repoRoot, { + roots, + extensions: DEFAULT_RUNTIME_SOURCE_EXTENSIONS, + skipHiddenDirectories: true, + shouldIncludeFile: (relativePath) => !shouldSkipRuntimeSourcePath(relativePath), + }); + runtimeSourceScanCache.set(cacheKey, pending); + } + const files = await pending; + return files.filter((filePath) => + requestedExtensions.some((extension) => filePath.endsWith(extension)), + ); +} diff --git a/src/utils.test.ts b/src/utils.test.ts index 14284b754..ec9a0f4a1 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -240,4 +240,9 @@ describe("resolveUserPath", () => { expect(resolveUserPath("")).toBe(""); expect(resolveUserPath(" ")).toBe(""); }); + + it("returns empty string for undefined/null input", () => { + expect(resolveUserPath(undefined as unknown as string)).toBe(""); + expect(resolveUserPath(null as unknown as string)).toBe(""); + }); }); diff --git a/src/utils.ts b/src/utils.ts index 49e14e0d0..55efabb1b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -283,6 +283,9 @@ export function truncateUtf16Safe(input: string, maxLen: number): string { } export function resolveUserPath(input: string): string { + if (!input) { + return ""; + } const trimmed = input.trim(); if (!trimmed) { return trimmed; diff --git a/src/utils/directive-tags.test.ts b/src/utils/directive-tags.test.ts index 29fcb3021..21b042b22 100644 --- a/src/utils/directive-tags.test.ts +++ b/src/utils/directive-tags.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "vitest"; -import { stripInlineDirectiveTagsForDisplay } from "./directive-tags.js"; +import { + stripInlineDirectiveTagsForDisplay, + stripInlineDirectiveTagsFromMessageForDisplay, +} from "./directive-tags.js"; describe("stripInlineDirectiveTagsForDisplay", () => { test("removes reply and audio directives", () => { @@ -23,3 +26,34 @@ describe("stripInlineDirectiveTagsForDisplay", () => { expect(result.text).toBe(input); }); }); + +describe("stripInlineDirectiveTagsFromMessageForDisplay", () => { + test("strips inline directives from text content blocks", () => { + const input = { + role: "assistant", + content: [{ type: "text", text: "hello [[reply_to_current]] world [[audio_as_voice]]" }], + }; + const result = stripInlineDirectiveTagsFromMessageForDisplay(input); + expect(result).toBeDefined(); + expect(result?.content).toEqual([{ type: "text", text: "hello world " }]); + }); + + test("preserves empty-string text when directives are entire content", () => { + const input = { + role: "assistant", + content: [{ type: "text", text: "[[reply_to_current]]" }], + }; + const result = stripInlineDirectiveTagsFromMessageForDisplay(input); + expect(result).toBeDefined(); + expect(result?.content).toEqual([{ type: "text", text: "" }]); + }); + + test("returns original message when content is not an array", () => { + const input = { + role: "assistant", + content: "plain text", + }; + const result = stripInlineDirectiveTagsFromMessageForDisplay(input); + expect(result).toEqual(input); + }); +}); diff --git a/src/utils/directive-tags.ts b/src/utils/directive-tags.ts index b49a10f2f..97c31d466 100644 --- a/src/utils/directive-tags.ts +++ b/src/utils/directive-tags.ts @@ -29,6 +29,17 @@ type StripInlineDirectiveTagsResult = { changed: boolean; }; +type MessageTextPart = { + type: "text"; + text: string; +} & Record; + +type MessagePart = Record | null | undefined; + +export type DisplayMessageWithContent = { + content?: unknown; +} & Record; + export function stripInlineDirectiveTagsForDisplay(text: string): StripInlineDirectiveTagsResult { if (!text) { return { text, changed: false }; @@ -41,6 +52,36 @@ export function stripInlineDirectiveTagsForDisplay(text: string): StripInlineDir }; } +function isMessageTextPart(part: MessagePart): part is MessageTextPart { + return Boolean(part) && part?.type === "text" && typeof part.text === "string"; +} + +/** + * Strips inline directive tags from message text blocks while preserving message shape. + * Empty post-strip text stays empty-string to preserve caller semantics. + */ +export function stripInlineDirectiveTagsFromMessageForDisplay( + message: DisplayMessageWithContent | undefined, +): DisplayMessageWithContent | undefined { + if (!message) { + return message; + } + if (!Array.isArray(message.content)) { + return message; + } + const cleaned = message.content.map((part) => { + if (!part || typeof part !== "object") { + return part; + } + const record = part as MessagePart; + if (!isMessageTextPart(record)) { + return part; + } + return { ...record, text: stripInlineDirectiveTagsForDisplay(record.text).text }; + }); + return { ...message, content: cleaned }; +} + export function parseInlineDirectives( text?: string, options: InlineDirectiveParseOptions = {}, diff --git a/src/web/inbound/access-control.group-policy.test.ts b/src/web/inbound/access-control.group-policy.test.ts new file mode 100644 index 000000000..8419a1e5d --- /dev/null +++ b/src/web/inbound/access-control.group-policy.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +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); + }); +}); diff --git a/src/web/inbound/access-control.ts b/src/web/inbound/access-control.ts index a7c2601e2..794897a53 100644 --- a/src/web/inbound/access-control.ts +++ b/src/web/inbound/access-control.ts @@ -1,4 +1,9 @@ import { loadConfig } from "../../config/config.js"; +import { + resolveOpenProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, +} from "../../config/runtime-group-policy.js"; import { logVerbose } from "../../globals.js"; import { buildPairingReply } from "../../pairing/pairing-messages.js"; import { @@ -17,6 +22,21 @@ export type InboundAccessControlResult = { const PAIRING_REPLY_HISTORY_GRACE_MS = 30_000; +function resolveWhatsAppRuntimeGroupPolicy(params: { + providerConfigPresent: boolean; + groupPolicy?: "open" | "allowlist" | "disabled"; + defaultGroupPolicy?: "open" | "allowlist" | "disabled"; +}): { + groupPolicy: "open" | "allowlist" | "disabled"; + providerMissingFallbackApplied: boolean; +} { + return resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent: params.providerConfigPresent, + groupPolicy: params.groupPolicy, + defaultGroupPolicy: params.defaultGroupPolicy, + }); +} + export async function checkInboundAccessControl(params: { accountId: string; from: string; @@ -81,8 +101,18 @@ export async function checkInboundAccessControl(params: { // - "open": groups bypass allowFrom, only mention-gating applies // - "disabled": block all group messages entirely // - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.groupPolicy ?? defaultGroupPolicy ?? "open"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy, providerMissingFallbackApplied } = resolveWhatsAppRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.whatsapp !== undefined, + groupPolicy: account.groupPolicy, + defaultGroupPolicy, + }); + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "whatsapp", + accountId: account.accountId, + log: (message) => logVerbose(message), + }); if (params.group && groupPolicy === "disabled") { logVerbose("Blocked group message (groupPolicy: disabled)"); return { @@ -191,3 +221,7 @@ export async function checkInboundAccessControl(params: { resolvedAccountId: account.accountId, }; } + +export const __testing = { + resolveWhatsAppRuntimeGroupPolicy, +}; diff --git a/ui/CHECKLIST.md b/ui/CHECKLIST.md new file mode 100644 index 000000000..d2558b6bc --- /dev/null +++ b/ui/CHECKLIST.md @@ -0,0 +1,144 @@ +# UI Dashboard — Verification Checklist + +Run through this checklist after every change that touches `ui/` files. +Open the dashboard at `http://localhost:` (or the gateway's configured UI URL). + +## Login & Shell + +- [ ] Login gate renders when not authenticated +- [ ] Login with valid password grants access +- [ ] Login with invalid password shows error +- [ ] App shell loads: sidebar, header, content area visible +- [ ] Sidebar shows all tab groups: Chat, Control, Agent, Settings +- [ ] Sidebar collapse/expand works; favicon logo shows when collapsed +- [ ] Router: clicking each sidebar tab navigates and updates URL +- [ ] Browser back/forward navigates between tabs +- [ ] Direct URL navigation (e.g. `/chat`, `/overview`) loads correct tab + +## Themes + +- [ ] Theme switcher cycles through all 5 themes: + - [ ] Dark (Obsidian) + - [ ] Light + - [ ] OpenKnot (Aurora) + - [ ] Field Manual + - [ ] ClawDash (Chrome) +- [ ] Glass components (cards, panels, inputs) render correctly per theme +- [ ] Theme persists across page reload + +## Overview + +- [ ] Overview tab loads without errors +- [ ] Stat cards render: cost, sessions, skills, cron +- [ ] Cards show accent color borders per kind +- [ ] Cards show hover lift + shadow effect +- [ ] Cards are clickable and navigate to corresponding tab +- [ ] Responsive grid: 4 columns → 2 → 1 at breakpoints +- [ ] Attention items render with correct severity icons/colors (error, warning, info) +- [ ] Event log renders with timestamps +- [ ] Log tail section renders live gateway log lines +- [ ] Quick actions section renders +- [ ] Redact toggle in topbar redacts/reveals sensitive values in cards + +## Chat + +- [ ] Chat view renders message history +- [ ] Sending a message works and response streams in +- [ ] Markdown rendering works in responses (code blocks, lists, links) +- [ ] Tool call cards render collapsed by default +- [ ] Tool cards expand/collapse on click; summary shows tool name/count +- [ ] JSON messages render collapsed by default +- [ ] Delete message: trash icon appears on hover, click removes message group +- [ ] Deleted messages persist across reload (localStorage) +- [ ] Clear history button resets session via `sessions.reset` RPC +- [ ] Agent selector dropdown appears when multiple agents configured +- [ ] Switching agents updates session key and reloads history +- [ ] Session list panel: shows all sessions for current agent +- [ ] Session list: clicking a session switches to it +- [ ] Input history (up/down arrow) recalls previous messages +- [ ] Slash command menu opens on `/` keystroke +- [ ] Slash commands show icons, categories, and grouping +- [ ] Pinned messages render if present + +## Command Palette + +- [ ] Opens via keyboard shortcut or UI button +- [ ] Fuzzy search filters commands as you type +- [ ] Results grouped by category with labels +- [ ] Selecting a command executes it +- [ ] "No results" message when nothing matches +- [ ] Clicking overlay closes palette +- [ ] Escape key closes palette + +## Agents + +- [ ] Agent tab loads agent list +- [ ] Agent overview panel: identity card with name, ID, avatar color +- [ ] Agent config display: model, tools, skills shown +- [ ] Agent panels: overview, status/files, tools/skills tabs work +- [ ] Tab counts show for files, skills, channels, cron +- [ ] Sidebar agent filter input filters agents in multi-agent setup +- [ ] Agent actions menu: "copy ID" and "set as default" work +- [ ] Chip-based fallback input (model selection): Enter/comma adds chips + +## Channels & Instances + +- [ ] Channels tab lists connected channels +- [ ] Instances tab lists connected instances +- [ ] Host/IP blurred by default in Connected Instances +- [ ] Reveal toggle shows actual host/IP values +- [ ] Nostr profile form renders if nostr channel present + +## Privacy & Redaction + +- [ ] Topbar redact toggle visible; default is stream mode on +- [ ] Redact ON: sensitive values masked in overview cards +- [ ] Redact ON: cost digits blurred +- [ ] Redact ON: access card blurred +- [ ] Redact ON: raw config JSON masks sensitive values with count badge +- [ ] Redact OFF: all values visible + +## Config + +- [ ] Config tab renders current gateway configuration +- [ ] Config form fields editable +- [ ] Sensitive config values masked when redact is on +- [ ] Config analysis view loads + +## Other Tabs + +- [ ] Sessions tab loads session list +- [ ] Usage tab loads usage statistics with styled sections +- [ ] Cron tab lists cron jobs with status +- [ ] Skills tab lists skills with status report +- [ ] Nodes tab loads +- [ ] Debug tab renders debug info +- [ ] Logs tab renders + +## i18n + +- [ ] English locale loads by default +- [ ] All visible strings use i18n keys (no hardcoded English in templates) +- [ ] zh-CN locale keys present +- [ ] zh-TW locale keys present +- [ ] pt-BR locale keys present + +## Responsive & Mobile + +- [ ] Sidebar collapses on narrow viewport +- [ ] Bottom tabs render on mobile breakpoint +- [ ] Card grid reflows: 4 → 2 → 1 columns +- [ ] Chat input usable on mobile +- [ ] No horizontal overflow on any tab at 375px width + +## Build & Tests + +- [ ] `pnpm build` completes without errors +- [ ] `pnpm test` passes — specifically `ui/` test files: + - [ ] `app-gateway.node.test.ts` + - [ ] `app-settings.test.ts` + - [ ] `config-form.browser.test.ts` + - [ ] `config.browser.test.ts` + - [ ] `chat.test.ts` +- [ ] No new TypeScript errors: `pnpm tsgo` +- [ ] No lint/format issues: `pnpm check` diff --git a/ui/index.html b/ui/index.html index dc03f4911..3409ddbf8 100644 --- a/ui/index.html +++ b/ui/index.html @@ -8,6 +8,18 @@ + diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index db973ec2b..8c66a63c2 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -12,6 +12,7 @@ export const en: TranslationMap = { na: "n/a", docs: "Docs", resources: "Resources", + search: "Search", }, nav: { chat: "Chat", @@ -99,6 +100,47 @@ export const en: TranslationMap = { hint: "This page is HTTP, so the browser blocks device identity. Use HTTPS (Tailscale Serve) or open {url} on the gateway host.", stayHttp: "If you must stay on HTTP, set {config} (token-only).", }, + connection: { + title: "How to connect", + step1: "Start the gateway on your host machine:", + step2: "Get a tokenized dashboard URL:", + step3: "Paste the WebSocket URL and token above, or open the tokenized URL directly.", + step4: "Or generate a reusable token:", + docsHint: "For remote access, Tailscale Serve is recommended. ", + docsLink: "Read the docs →", + }, + cards: { + cost: "Cost", + skills: "Skills", + recentSessions: "Recent Sessions", + }, + attention: { + title: "Attention", + }, + eventLog: { + title: "Event Log", + }, + logTail: { + title: "Gateway Logs", + }, + quickActions: { + newSession: "New Session", + automation: "Automation", + refreshAll: "Refresh All", + terminal: "Terminal", + }, + streamMode: { + active: "Stream mode — values redacted", + disable: "Disable", + }, + palette: { + placeholder: "Type a command…", + noResults: "No results", + }, + }, + login: { + subtitle: "Gateway Dashboard", + tokenPlaceholder: "paste gateway token", }, chat: { disconnected: "Disconnected from gateway.", diff --git a/ui/src/i18n/locales/pt-BR.ts b/ui/src/i18n/locales/pt-BR.ts index 77123f069..b42234917 100644 --- a/ui/src/i18n/locales/pt-BR.ts +++ b/ui/src/i18n/locales/pt-BR.ts @@ -12,6 +12,7 @@ export const pt_BR: TranslationMap = { na: "n/a", docs: "Docs", resources: "Recursos", + search: "Pesquisar", }, nav: { chat: "Chat", @@ -101,6 +102,47 @@ export const pt_BR: TranslationMap = { hint: "Esta página é HTTP, então o navegador bloqueia a identidade do dispositivo. Use HTTPS (Tailscale Serve) ou abra {url} no host do gateway.", stayHttp: "Se você precisar permanecer em HTTP, defina {config} (apenas token).", }, + connection: { + title: "Como conectar", + step1: "Inicie o gateway na sua máquina host:", + step2: "Obtenha uma URL do painel com token:", + step3: "Cole a URL do WebSocket e o token acima, ou abra a URL com token diretamente.", + step4: "Ou gere um token reutilizável:", + docsHint: "Para acesso remoto, recomendamos o Tailscale Serve. ", + docsLink: "Leia a documentação →", + }, + cards: { + cost: "Custo", + skills: "Habilidades", + recentSessions: "Sessões Recentes", + }, + attention: { + title: "Atenção", + }, + eventLog: { + title: "Log de Eventos", + }, + logTail: { + title: "Logs do Gateway", + }, + quickActions: { + newSession: "Nova Sessão", + automation: "Automação", + refreshAll: "Atualizar Tudo", + terminal: "Terminal", + }, + streamMode: { + active: "Modo stream — valores ocultos", + disable: "Desativar", + }, + palette: { + placeholder: "Digite um comando…", + noResults: "Sem resultados", + }, + }, + login: { + subtitle: "Painel do Gateway", + tokenPlaceholder: "cole o token do gateway", }, chat: { disconnected: "Desconectado do gateway.", diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts index 6addadb11..8fd4d86bd 100644 --- a/ui/src/i18n/locales/zh-CN.ts +++ b/ui/src/i18n/locales/zh-CN.ts @@ -12,6 +12,7 @@ export const zh_CN: TranslationMap = { na: "不适用", docs: "文档", resources: "资源", + search: "搜索", }, nav: { chat: "聊天", @@ -98,6 +99,47 @@ export const zh_CN: TranslationMap = { hint: "此页面为 HTTP,因此浏览器阻止设备标识。请使用 HTTPS (Tailscale Serve) 或在网关主机上打开 {url}。", stayHttp: "如果您必须保持 HTTP,请设置 {config} (仅限令牌)。", }, + connection: { + title: "如何连接", + step1: "在主机上启动网关:", + step2: "获取带令牌的仪表盘 URL:", + step3: "将 WebSocket URL 和令牌粘贴到上方,或直接打开带令牌的 URL。", + step4: "或生成可重复使用的令牌:", + docsHint: "如需远程访问,建议使用 Tailscale Serve。", + docsLink: "查看文档 →", + }, + cards: { + cost: "费用", + skills: "技能", + recentSessions: "最近会话", + }, + attention: { + title: "注意事项", + }, + eventLog: { + title: "事件日志", + }, + logTail: { + title: "网关日志", + }, + quickActions: { + newSession: "新建会话", + automation: "自动化", + refreshAll: "全部刷新", + terminal: "终端", + }, + streamMode: { + active: "流模式 — 数据已隐藏", + disable: "禁用", + }, + palette: { + placeholder: "输入命令…", + noResults: "无结果", + }, + }, + login: { + subtitle: "网关仪表盘", + tokenPlaceholder: "粘贴网关令牌", }, chat: { disconnected: "已断开与网关的连接。", diff --git a/ui/src/i18n/locales/zh-TW.ts b/ui/src/i18n/locales/zh-TW.ts index 9187776eb..c480d32fb 100644 --- a/ui/src/i18n/locales/zh-TW.ts +++ b/ui/src/i18n/locales/zh-TW.ts @@ -12,6 +12,7 @@ export const zh_TW: TranslationMap = { na: "不適用", docs: "文檔", resources: "資源", + search: "搜尋", }, nav: { chat: "聊天", @@ -98,6 +99,47 @@ export const zh_TW: TranslationMap = { hint: "此頁面為 HTTP,因此瀏覽器阻止設備標識。請使用 HTTPS (Tailscale Serve) 或在網關主機上打開 {url}。", stayHttp: "如果您必須保持 HTTP,請設置 {config} (僅限令牌)。", }, + connection: { + title: "如何連接", + step1: "在主機上啟動閘道:", + step2: "取得帶令牌的儀表板 URL:", + step3: "將 WebSocket URL 和令牌貼到上方,或直接開啟帶令牌的 URL。", + step4: "或產生可重複使用的令牌:", + docsHint: "如需遠端存取,建議使用 Tailscale Serve。", + docsLink: "查看文件 →", + }, + cards: { + cost: "費用", + skills: "技能", + recentSessions: "最近會話", + }, + attention: { + title: "注意事項", + }, + eventLog: { + title: "事件日誌", + }, + logTail: { + title: "閘道日誌", + }, + quickActions: { + newSession: "新建會話", + automation: "自動化", + refreshAll: "全部刷新", + terminal: "終端", + }, + streamMode: { + active: "串流模式 — 數據已隱藏", + disable: "禁用", + }, + palette: { + placeholder: "輸入指令…", + noResults: "無結果", + }, + }, + login: { + subtitle: "閘道儀表板", + tokenPlaceholder: "貼上閘道令牌", }, chat: { disconnected: "已斷開與網關的連接。", diff --git a/ui/src/styles.css b/ui/src/styles.css index 16b327f3a..7eb2fd170 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -2,4 +2,5 @@ @import "./styles/layout.css"; @import "./styles/layout.mobile.css"; @import "./styles/components.css"; +@import "./styles/glass.css"; @import "./styles/config.css"; diff --git a/ui/src/styles/base.css b/ui/src/styles/base.css index b83afd32c..de02aef78 100644 --- a/ui/src/styles/base.css +++ b/ui/src/styles/base.css @@ -1,108 +1,441 @@ -@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@400;700&family=JetBrains+Mono:wght@400;500;700&display=swap"); + +* { + box-sizing: border-box; +} + +/* ════════════════════════════════════════════════════════ + Theme System — 6 Glassmorphism Themes + ════════════════════════════════════════════════════════ */ + +/* ─── Design Tokens (shared across all themes) ─── */ :root { - /* Background - Warmer dark with depth */ - --bg: #12141a; - --bg-accent: #14161d; - --bg-elevated: #1a1d25; - --bg-hover: #262a35; - --bg-muted: #262a35; + --icon-size-xs: 0.9rem; + --icon-size-sm: 1.05rem; + --icon-size-md: 1.25rem; + --icon-size-xl: 2.4rem; - /* Card / Surface - More contrast between levels */ - --card: #181b22; - --card-foreground: #f4f4f5; - --card-highlight: rgba(255, 255, 255, 0.05); - --popover: #181b22; - --popover-foreground: #f4f4f5; + --font-inter: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --font-serif: "Playfair Display", Georgia, "Times New Roman", serif; + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - /* Panel */ - --panel: #12141a; - --panel-strong: #1a1d25; - --panel-hover: #262a35; - --chrome: rgba(18, 20, 26, 0.95); - --chrome-strong: rgba(18, 20, 26, 0.98); - - /* Text - Slightly warmer */ - --text: #e4e4e7; - --text-strong: #fafafa; - --chat-text: #e4e4e7; - --muted: #71717a; - --muted-strong: #52525b; - --muted-foreground: #71717a; - - /* Border - Subtle but defined */ - --border: #27272a; - --border-strong: #3f3f46; - --border-hover: #52525b; - --input: #27272a; - --ring: #ff5c5c; - - /* Accent - Punchy signature red */ - --accent: #ff5c5c; - --accent-hover: #ff7070; - --accent-muted: #ff5c5c; - --accent-subtle: rgba(255, 92, 92, 0.15); - --accent-foreground: #fafafa; - --accent-glow: rgba(255, 92, 92, 0.25); - --primary: #ff5c5c; - --primary-foreground: #ffffff; - - /* Secondary - Teal accent for variety */ - --secondary: #1e2028; - --secondary-foreground: #f4f4f5; - --accent-2: #14b8a6; - --accent-2-muted: rgba(20, 184, 166, 0.7); - --accent-2-subtle: rgba(20, 184, 166, 0.15); - - /* Semantic - More saturated */ - --ok: #22c55e; - --ok-muted: rgba(34, 197, 94, 0.75); - --ok-subtle: rgba(34, 197, 94, 0.12); - --destructive: #ef4444; - --destructive-foreground: #fafafa; - --warn: #f59e0b; - --warn-muted: rgba(245, 158, 11, 0.75); - --warn-subtle: rgba(245, 158, 11, 0.12); - --danger: #ef4444; - --danger-muted: rgba(239, 68, 68, 0.75); - --danger-subtle: rgba(239, 68, 68, 0.12); - --info: #3b82f6; - - /* Focus - With glow */ - --focus: rgba(255, 92, 92, 0.25); - --focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring); - --focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 20px var(--accent-glow); - - /* Grid */ - --grid-line: rgba(255, 255, 255, 0.04); - - /* Theme transition */ --theme-switch-x: 50%; --theme-switch-y: 50%; +} - /* Typography - Space Grotesk for personality */ - --mono: - "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace; - --font-body: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - --font-display: - "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +@media (prefers-reduced-motion: reduce) { + :root { + --clay-duration-fast: 0ms; + --clay-duration-normal: 0ms; + --clay-duration-slow: 0ms; + } - /* Shadows - Richer with subtle color */ - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2); - --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.03); - --shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.03); - --shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.03); - --shadow-glow: 0 0 30px var(--accent-glow); + * { + animation-duration: 0s !important; + transition-duration: 0s !important; + } +} - /* Radii - Slightly larger for friendlier feel */ +/* ─── Theme: dark (Home) — Deep-sea Operations Console ─── */ + +:root, +:root[data-theme="dark"] { + color-scheme: dark; + + --vscode-bg: #040810; + --vscode-sidebar: #06090f; + --vscode-panel: #0a0e16; + --vscode-panel-border: rgba(0, 212, 170, 0.08); + --vscode-surface: #0e1420; + --vscode-hover: #121a28; + --vscode-contrast: #020408; + --vscode-text: #d0d8e4; + --vscode-muted: #6e7a8a; + --vscode-subtle: #3a4454; + --vscode-ghost: #0c1018; + --vscode-accent: #ca3a29; + --vscode-accent-alpha: rgba(202, 58, 41, 0.14); + --vscode-selection: #3d1418; + --vscode-success: #00d4aa; + --vscode-danger: #ca3a29; + + --kn-claw: #ca3a29; + --kn-claw-bright: #fd8e2e; + --kn-claw-dim: rgba(202, 58, 41, 0.12); + --kn-claw-ember: #fb9231; + --kn-claw-deep: #9a2d1f; + --kn-ocean: #09181e; + --kn-ocean-bright: #132a36; + --kn-ocean-mid: #0c1e28; + --kn-ocean-dim: rgba(9, 24, 30, 0.8); + --kn-ocean-deep: #040810; + --kn-silver: #8a9baa; + --kn-silver-bright: #c0cdd6; + --kn-silver-dim: rgba(138, 155, 170, 0.12); + --kn-bioluminescence: #00d4aa; + --kn-warm-dark: #221016; + --kn-void: #221016; + + --glass-blur: 8px; + --glass-saturate: 120%; + --glass-bg: rgba(10, 14, 22, 0.82); + --glass-bg-elevated: rgba(14, 20, 32, 0.88); + --glass-border: rgba(0, 212, 170, 0.08); + --glass-border-hover: rgba(202, 58, 41, 0.3); + --glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.04); + --glass-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.4); + --glass-shadow-md: 0 8px 24px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(0, 212, 170, 0.06); + --glass-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(0, 212, 170, 0.08); + + --radius-xs: 4px; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + --radius-full: 9999px; +} + +/* ─── Theme: light (Docs) — Warm Editorial Dark ─── */ + +:root[data-theme="light"] { + color-scheme: dark; + + --vscode-bg: #0e0c0e; + --vscode-sidebar: #131012; + --vscode-panel: #161214; + --vscode-panel-border: rgba(255, 255, 255, 0.06); + --vscode-surface: #1a1618; + --vscode-hover: #201c1e; + --vscode-contrast: #080608; + --vscode-text: #d5d0cf; + --vscode-muted: #7a7472; + --vscode-subtle: #4a4442; + --vscode-ghost: #1a1616; + --vscode-accent: #ca3a29; + --vscode-accent-alpha: rgba(202, 58, 41, 0.14); + --vscode-selection: #3d1418; + --vscode-success: #00d4aa; + --vscode-danger: #ca3a29; + + --kn-claw: #ca3a29; + --kn-claw-bright: #fd8e2e; + --kn-claw-dim: rgba(202, 58, 41, 0.12); + --kn-claw-ember: #fb9231; + --kn-claw-deep: #9a2d1f; + --kn-ocean: #0e0c0e; + --kn-ocean-bright: #201c1e; + --kn-ocean-mid: #161214; + --kn-ocean-dim: rgba(14, 12, 14, 0.8); + --kn-ocean-deep: #0e0c0e; + --kn-silver: #8a7e72; + --kn-silver-bright: #c0b4a8; + --kn-silver-dim: rgba(138, 126, 114, 0.12); + --kn-bioluminescence: #00d4aa; + --kn-warm-dark: #1a1416; + --kn-void: #1a1416; + + --glass-blur: 0px; + --glass-saturate: 100%; + --glass-bg: rgba(22, 18, 20, 0.95); + --glass-bg-elevated: rgba(26, 22, 24, 0.96); + --glass-border: rgba(255, 255, 255, 0.06); + --glass-border-hover: rgba(202, 58, 41, 0.25); + --glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.03); + --glass-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3); + --glass-shadow-md: 0 8px 24px rgba(0, 0, 0, 0.4); + --glass-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.5); + + --radius-xs: 4px; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + --radius-full: 9999px; +} + +/* ─── Theme: openknot — Minimalist Premium Noir ─── */ + +:root[data-theme="openknot"] { + color-scheme: dark; + + --vscode-bg: #000000; + --vscode-sidebar: #080808; + --vscode-panel: #0c0c0c; + --vscode-panel-border: rgba(167, 139, 250, 0.08); + --vscode-surface: #111111; + --vscode-hover: #181818; + --vscode-contrast: #000000; + --vscode-text: #e4e4e7; + --vscode-muted: #71717a; + --vscode-subtle: #3f3f46; + --vscode-ghost: #18181b; + --vscode-accent: #a78bfa; + --vscode-accent-alpha: rgba(167, 139, 250, 0.14); + --vscode-selection: #2e1a5e; + --vscode-success: #a78bfa; + --vscode-danger: #a78bfa; + + --kn-claw: #a78bfa; + --kn-claw-bright: #c4b5fd; + --kn-claw-dim: rgba(167, 139, 250, 0.12); + --kn-claw-ember: #c4b5fd; + --kn-claw-deep: #7c3aed; + --kn-ocean: #000000; + --kn-ocean-bright: #1a1a1e; + --kn-ocean-mid: #0e0e12; + --kn-ocean-dim: rgba(0, 0, 0, 0.8); + --kn-ocean-deep: #000000; + --kn-silver: #71717a; + --kn-silver-bright: #a1a1aa; + --kn-silver-dim: rgba(113, 113, 122, 0.12); + --kn-bioluminescence: #c4b5fd; + --kn-warm-dark: #18181b; + --kn-void: #18181b; + + --glass-blur: 12px; + --glass-saturate: 110%; + --glass-bg: rgba(12, 12, 12, 0.85); + --glass-bg-elevated: rgba(17, 17, 17, 0.9); + --glass-border: rgba(167, 139, 250, 0.08); + --glass-border-hover: rgba(167, 139, 250, 0.3); + --glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.04); + --glass-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.5); + --glass-shadow-md: 0 8px 24px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(167, 139, 250, 0.06); + --glass-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(167, 139, 250, 0.08); + + --radius-xs: 4px; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + --radius-full: 9999px; +} + +/* ─── Theme: fieldmanual — Industrial Dossier ─── */ + +:root[data-theme="fieldmanual"] { + color-scheme: dark; + + --vscode-bg: #0e0e0e; + --vscode-sidebar: #121212; + --vscode-panel: #161616; + --vscode-panel-border: rgba(255, 255, 255, 0.1); + --vscode-surface: #1a1a1a; + --vscode-hover: #222222; + --vscode-contrast: #0a0a0a; + --vscode-text: #d4d4d4; + --vscode-muted: #737373; + --vscode-subtle: #404040; + --vscode-ghost: #1a1a1a; + --vscode-accent: #ca3a29; + --vscode-accent-alpha: rgba(202, 58, 41, 0.14); + --vscode-selection: #3d1418; + --vscode-success: #61d6ff; + --vscode-danger: #ca3a29; + + --kn-claw: #ca3a29; + --kn-claw-bright: #ff6b4a; + --kn-claw-dim: rgba(202, 58, 41, 0.12); + --kn-claw-ember: #ff6b4a; + --kn-claw-deep: #9a2d1f; + --kn-ocean: #0e0e0e; + --kn-ocean-bright: #222222; + --kn-ocean-mid: #161616; + --kn-ocean-dim: rgba(14, 14, 14, 0.8); + --kn-ocean-deep: #0e0e0e; + --kn-silver: #737373; + --kn-silver-bright: #a3a3a3; + --kn-silver-dim: rgba(115, 115, 115, 0.12); + --kn-bioluminescence: #61d6ff; + --kn-warm-dark: #1a1a1a; + --kn-void: #1a1a1a; + + --glass-blur: 0px; + --glass-saturate: 100%; + --glass-bg: rgba(22, 22, 22, 0.95); + --glass-bg-elevated: rgba(26, 26, 26, 0.96); + --glass-border: rgba(255, 255, 255, 0.1); + --glass-border-hover: rgba(202, 58, 41, 0.35); + --glass-highlight: none; + --glass-shadow-sm: none; + --glass-shadow-md: none; + --glass-shadow-lg: none; + + --radius-xs: 0px; + --radius-sm: 0px; + --radius-md: 0px; + --radius-lg: 0px; + --radius-xl: 0px; + --radius-full: 0px; +} + +/* ─── Theme: clawdash — Chrome Metallic ─── */ + +:root[data-theme="clawdash"] { + color-scheme: dark; + + --vscode-bg: #050507; + --vscode-sidebar: #08080c; + --vscode-panel: #0c0c10; + --vscode-panel-border: rgba(192, 200, 212, 0.1); + --vscode-surface: #101014; + --vscode-hover: #161620; + --vscode-contrast: #020204; + --vscode-text: #e8ecf0; + --vscode-muted: #8a94a4; + --vscode-subtle: #4a5060; + --vscode-ghost: #1a1a22; + --vscode-accent: #ca3a29; + --vscode-accent-alpha: rgba(202, 58, 41, 0.14); + --vscode-selection: #3d1418; + --vscode-success: #00d4aa; + --vscode-danger: #ca3a29; + + --kn-claw: #ca3a29; + --kn-claw-bright: #ff4e41; + --kn-claw-dim: rgba(202, 58, 41, 0.12); + --kn-claw-ember: #fd8e2e; + --kn-claw-deep: #9a2d1f; + --kn-ocean: #08080c; + --kn-ocean-bright: #161620; + --kn-ocean-mid: #0c0c10; + --kn-ocean-dim: rgba(8, 8, 12, 0.8); + --kn-ocean-deep: #050507; + --kn-silver: #7a8494; + --kn-silver-bright: #c0c8d4; + --kn-silver-dim: rgba(192, 200, 212, 0.12); + --kn-bioluminescence: #00d4aa; + --kn-warm-dark: #1a1a22; + --kn-void: #1a1a22; + + --glass-blur: 16px; + --glass-saturate: 150%; + --glass-bg: rgba(12, 12, 16, 0.8); + --glass-bg-elevated: rgba(16, 16, 20, 0.88); + --glass-border: rgba(192, 200, 212, 0.08); + --glass-border-hover: rgba(192, 200, 212, 0.25); + --glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.06); + --glass-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.4), 0 0 4px rgba(192, 200, 212, 0.04); + --glass-shadow-md: 0 8px 24px rgba(0, 0, 0, 0.5), 0 0 12px rgba(192, 200, 212, 0.06); + --glass-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.6), 0 0 24px rgba(192, 200, 212, 0.08); + + --radius-xs: 3px; --radius-sm: 6px; --radius-md: 8px; - --radius-lg: 12px; - --radius-xl: 16px; + --radius-lg: 10px; + --radius-xl: 14px; --radius-full: 9999px; - --radius: 8px; +} - /* Transitions - Snappy but smooth */ +/* ─── Semantic Alias Layer ─── + Maps foundation vars to the short names used throughout + component CSS, so themes work without per-component overrides. */ + +:root, +:root[data-theme="dark"], +:root[data-theme="light"], +:root[data-theme="openknot"], +:root[data-theme="fieldmanual"], +:root[data-theme="clawdash"] { + /* Core surfaces */ + --bg: var(--vscode-bg); + --bg-accent: var(--vscode-sidebar); + --bg-elevated: var(--vscode-surface); + --bg-hover: var(--vscode-hover); + --bg-muted: var(--vscode-sidebar); + --bg-content: var(--vscode-bg); + + /* Card/popover surfaces */ + --card: var(--vscode-panel); + --card-foreground: var(--vscode-text); + --card-highlight: rgba(255, 255, 255, 0.04); + --popover: var(--vscode-panel); + --popover-foreground: var(--vscode-text); + + /* Panel/chrome surfaces */ + --panel: var(--vscode-sidebar); + --panel-strong: var(--vscode-panel); + --panel-hover: var(--vscode-hover); + --chrome: var(--glass-bg); + --chrome-strong: var(--glass-bg-elevated); + + /* Typography */ + --text: var(--vscode-text); + --text-strong: var(--vscode-text); + --chat-text: var(--vscode-text); + --muted: var(--vscode-muted); + --muted-strong: var(--vscode-subtle); + --muted-foreground: var(--vscode-muted); + + /* Borders + controls */ + --border: var(--glass-border); + --border-strong: var(--glass-border-hover); + --border-hover: var(--glass-border-hover); + --input: var(--glass-border); + --ring: var(--vscode-accent); + + /* Accent */ + --accent: var(--vscode-accent); + --accent-strong: var(--kn-claw-deep); + --accent-hover: var(--kn-claw-bright); + --accent-muted: var(--vscode-accent); + --accent-subtle: var(--vscode-accent-alpha); + --accent-foreground: #fafafa; + --accent-glow: var(--kn-claw-dim); + --accent-soft: var(--vscode-accent-alpha); + --primary: var(--vscode-accent); + --primary-foreground: #ffffff; + + /* Secondary */ + --secondary: var(--vscode-sidebar); + --secondary-foreground: var(--vscode-text); + --accent-2: var(--kn-bioluminescence); + --accent-2-muted: var(--kn-silver); + --accent-2-subtle: var(--kn-silver-dim); + + /* Semantic */ + --ok: var(--vscode-success); + --ok-muted: var(--vscode-success); + --ok-subtle: var(--kn-silver-dim); + --destructive: var(--vscode-danger); + --destructive-foreground: #fafafa; + --warn: var(--kn-claw-ember); + --warn-muted: var(--kn-claw-ember); + --warn-subtle: var(--kn-claw-dim); + --danger: var(--vscode-danger); + --danger-muted: var(--vscode-danger); + --danger-subtle: var(--kn-claw-dim); + --info: #3b82f6; + --success: var(--vscode-success); + + /* Focus */ + --focus: var(--kn-claw-dim); + --focus-offset-color: var(--bg); + --focus-ring-width: 2px; + --focus-ring-offset-width: 2px; + --focus-ring-color: var(--vscode-accent); + --focus-ring: + 0 0 0 var(--focus-ring-offset-width) var(--focus-offset-color), + 0 0 0 calc(var(--focus-ring-offset-width) + var(--focus-ring-width)) var(--focus-ring-color); + --focus-glow: + 0 0 0 var(--focus-ring-offset-width) var(--focus-offset-color), + 0 0 0 calc(var(--focus-ring-offset-width) + var(--focus-ring-width)) var(--focus-ring-color), + 0 0 18px var(--accent-glow); + + --grid-line: rgba(255, 255, 255, 0.04); + + /* Shadows */ + --shadow-sm: var(--glass-shadow-sm); + --shadow-md: var(--glass-shadow-md); + --shadow-lg: var(--glass-shadow-lg); + --shadow-xl: var(--glass-shadow-lg); + --shadow-glow: 0 0 30px var(--accent-glow); + + /* Radii — aliased from foundation */ + --radius: var(--radius-md); + + /* Timing */ --ease-out: cubic-bezier(0.16, 1, 0.3, 1); --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); @@ -110,88 +443,68 @@ --duration-normal: 200ms; --duration-slow: 350ms; - color-scheme: dark; + /* Typography stacks */ + --mono: + "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace; + --font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-display: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + + /* Clay compat layer (dashboard-lit components) */ + --clay-bg: var(--vscode-bg); + --clay-bg-card: var(--vscode-panel); + --clay-bg-elevated: var(--vscode-surface); + --clay-bg-button: var(--vscode-hover); + --clay-bg-interactive: var(--vscode-accent-alpha); + --clay-bg-pressed: var(--vscode-selection); + --clay-bg-scrim: rgba(0, 0, 0, 0.6); + --clay-border-color: var(--glass-border); + --clay-border-subtle: var(--vscode-panel-border); + --clay-shadow: var(--glass-shadow-sm); + --clay-shadow-elevated: var(--glass-shadow-md); + --clay-shadow-pressed: var(--glass-shadow-sm); + --clay-shadow-subtle: var(--glass-shadow-sm); + --clay-radius-sm: var(--radius-sm); + --clay-radius: var(--radius-md); + --clay-radius-md: var(--radius-md); + --clay-radius-lg: var(--radius-lg); + --clay-radius-xl: var(--radius-xl); + --clay-radius-pill: var(--radius-full); + --clay-duration-fast: 150ms; + --clay-duration-normal: 250ms; + --clay-duration-slow: 400ms; + --clay-easing: cubic-bezier(0.16, 1, 0.3, 1); + + /* Layout semantic tokens */ + --topbar-bg: var(--vscode-sidebar); + --topbar-shadow: none; + --topbar-border: 1px solid var(--glass-border); + --topbar-title-color: var(--vscode-text); + --topbar-title-weight: 600; + --sidebar-bg: var(--vscode-sidebar); + --sidebar-border: none; + --sidebar-nav-inactive: var(--vscode-muted); + --sidebar-nav-active-bg: var(--vscode-accent-alpha); + --sidebar-nav-active-bar: 3px solid var(--vscode-accent); + --agent-header-bg: var(--vscode-panel); + --agent-header-border: 1px solid var(--glass-border); + --agent-tab-active-bg: var(--vscode-accent-alpha); + --agent-tab-hover-bg: var(--vscode-accent-alpha); } -/* Light theme - Clean with subtle warmth */ -:root[data-theme="light"] { - --bg: #fafafa; - --bg-accent: #f5f5f5; - --bg-elevated: #ffffff; - --bg-hover: #f0f0f0; - --bg-muted: #f0f0f0; - --bg-content: #f5f5f5; +/* ─── Accessibility: High Contrast ─── */ - --card: #ffffff; - --card-foreground: #18181b; - --card-highlight: rgba(0, 0, 0, 0.03); - --popover: #ffffff; - --popover-foreground: #18181b; - - --panel: #fafafa; - --panel-strong: #f5f5f5; - --panel-hover: #ebebeb; - --chrome: rgba(250, 250, 250, 0.95); - --chrome-strong: rgba(250, 250, 250, 0.98); - - --text: #3f3f46; - --text-strong: #18181b; - --chat-text: #3f3f46; - --muted: #71717a; - --muted-strong: #52525b; - --muted-foreground: #71717a; - - --border: #e4e4e7; - --border-strong: #d4d4d8; - --border-hover: #a1a1aa; - --input: #e4e4e7; - - --accent: #dc2626; - --accent-hover: #ef4444; - --accent-muted: #dc2626; - --accent-subtle: rgba(220, 38, 38, 0.12); - --accent-foreground: #ffffff; - --accent-glow: rgba(220, 38, 38, 0.15); - --primary: #dc2626; - --primary-foreground: #ffffff; - - --secondary: #f4f4f5; - --secondary-foreground: #3f3f46; - --accent-2: #0d9488; - --accent-2-muted: rgba(13, 148, 136, 0.75); - --accent-2-subtle: rgba(13, 148, 136, 0.12); - - --ok: #16a34a; - --ok-muted: rgba(22, 163, 74, 0.75); - --ok-subtle: rgba(22, 163, 74, 0.1); - --destructive: #dc2626; - --destructive-foreground: #fafafa; - --warn: #d97706; - --warn-muted: rgba(217, 119, 6, 0.75); - --warn-subtle: rgba(217, 119, 6, 0.1); - --danger: #dc2626; - --danger-muted: rgba(220, 38, 38, 0.75); - --danger-subtle: rgba(220, 38, 38, 0.1); - --info: #2563eb; - - --focus: rgba(220, 38, 38, 0.2); - --focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 16px var(--accent-glow); - - --grid-line: rgba(0, 0, 0, 0.05); - - /* Light shadows */ - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06); - --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04); - --shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.04); - --shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.04); - --shadow-glow: 0 0 24px var(--accent-glow); - - color-scheme: light; +@media (prefers-contrast: more) { + :root { + --glass-shadow-sm: 0 0 0 2px var(--vscode-text); + --glass-shadow-md: 0 0 0 2px var(--vscode-text); + --glass-shadow-lg: 0 0 0 2px var(--vscode-text); + --glass-border: rgba(255, 255, 255, 0.3); + } } -* { - box-sizing: border-box; -} +/* ════════════════════════════════════════════════════════ + Base Styles + ════════════════════════════════════════════════════════ */ html, body { @@ -200,8 +513,8 @@ body { body { margin: 0; - font: 400 14px/1.55 var(--font-body); - letter-spacing: -0.02em; + font: 400 15px/1.55 var(--font-body); + letter-spacing: -0.01em; background: var(--bg); color: var(--text); -webkit-font-smoothing: antialiased; @@ -289,7 +602,157 @@ select { background: var(--border-strong); } -/* Animations - Polished with spring feel */ +/* ════════════════════════════════════════════════════════ + Theme-Specific Decorative Effects + ════════════════════════════════════════════════════════ */ + +/* ─── Dark — Star field + ambient gradients ─── */ + +:root[data-theme="dark"] body { + background: + radial-gradient(ellipse 80% 50% at 50% -5%, rgba(0, 212, 170, 0.06) 0%, transparent 60%), + radial-gradient(ellipse 60% 40% at 60% 20%, rgba(202, 58, 41, 0.04) 0%, transparent 50%), + var(--bg); +} + +@keyframes star-twinkle { + 0% { + opacity: 0.35; + } + 100% { + opacity: 0.55; + } +} + +:root[data-theme="dark"] body::after { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + opacity: 0.45; + animation: star-twinkle 5s ease-in-out infinite alternate; + box-shadow: + 120px 40px 0 0.4px rgba(0, 212, 170, 0.5), + 340px 90px 0 0.3px rgba(0, 212, 170, 0.3), + 580px 60px 0 0.5px rgba(0, 212, 170, 0.6), + 800px 130px 0 0.3px rgba(0, 212, 170, 0.4), + 1050px 50px 0 0.4px rgba(0, 212, 170, 0.3), + 90px 200px 0 0.5px rgba(0, 212, 170, 0.4), + 470px 220px 0 0.4px rgba(0, 212, 170, 0.5), + 900px 250px 0 0.5px rgba(0, 212, 170, 0.6), + 200px 420px 0 0.5px rgba(0, 212, 170, 0.5), + 640px 450px 0 0.4px rgba(0, 212, 170, 0.4), + 1060px 380px 0 0.5px rgba(0, 212, 170, 0.3), + 380px 580px 0 0.3px rgba(0, 212, 170, 0.4), + 780px 570px 0 0.3px rgba(0, 212, 170, 0.5), + 110px 680px 0 0.5px rgba(0, 212, 170, 0.4), + 520px 660px 0 0.4px rgba(0, 212, 170, 0.5); +} + +/* ─── openknot — Lavender stars ─── */ + +:root[data-theme="openknot"] body::after { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + opacity: 0.35; + animation: star-twinkle 8s ease-in-out infinite alternate; + box-shadow: + 120px 40px 0 0.4px rgba(196, 181, 253, 0.5), + 340px 90px 0 0.3px rgba(196, 181, 253, 0.3), + 580px 60px 0 0.5px rgba(196, 181, 253, 0.6), + 800px 130px 0 0.3px rgba(196, 181, 253, 0.4), + 90px 200px 0 0.5px rgba(196, 181, 253, 0.4), + 470px 220px 0 0.4px rgba(196, 181, 253, 0.5), + 900px 250px 0 0.5px rgba(196, 181, 253, 0.6), + 200px 420px 0 0.5px rgba(196, 181, 253, 0.5), + 640px 450px 0 0.4px rgba(196, 181, 253, 0.4), + 380px 580px 0 0.3px rgba(196, 181, 253, 0.4), + 780px 570px 0 0.3px rgba(196, 181, 253, 0.5), + 520px 660px 0 0.4px rgba(196, 181, 253, 0.5); +} + +/* ─── fieldmanual — Industrial Dossier Overrides ─── */ + +:root[data-theme="fieldmanual"] .page-title, +:root[data-theme="fieldmanual"] .panel-title, +:root[data-theme="fieldmanual"] .agent-chat__welcome h2 { + font-family: var(--font-mono); + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 700; +} + +:root[data-theme="fieldmanual"] .sidebar-brand__title { + font-family: var(--font-mono); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +:root[data-theme="fieldmanual"] .glass-dashboard-card, +:root[data-theme="fieldmanual"] .stat-card, +:root[data-theme="fieldmanual"] .agent-chat__starter { + border-style: dashed; +} + +:root[data-theme="fieldmanual"] .sidebar { + backdrop-filter: none; + -webkit-backdrop-filter: none; + background: var(--vscode-sidebar); +} + +:root[data-theme="fieldmanual"] .glass-dashboard-card { + backdrop-filter: none; + -webkit-backdrop-filter: none; + background: var(--vscode-panel); +} + +:root[data-theme="fieldmanual"] body::after { + display: none; +} + +/* ─── clawdash — Chrome Metallic Overrides ─── */ + +:root[data-theme="clawdash"] body { + background: + radial-gradient(ellipse 80% 50% at 40% -10%, rgba(192, 200, 212, 0.06) 0%, transparent 60%), + radial-gradient(ellipse 60% 40% at 70% 30%, rgba(202, 58, 41, 0.04) 0%, transparent 50%), + var(--bg); +} + +:root[data-theme="clawdash"] body::after { + display: none; +} + +:root[data-theme="clawdash"] .nav-item--active { + border-image: linear-gradient(to bottom, var(--kn-silver-bright), var(--kn-claw)) 1; + border-image-slice: 1; +} + +/* ─── High Contrast Overrides (all themes) ─── */ + +@media (prefers-contrast: more) { + .topbar, + .sidebar, + .nav-item--active, + .stat-card, + .callout, + .pill, + pre, + input, + button { + box-shadow: 0 0 0 2px var(--text) !important; + border-width: 1.5px; + } +} + +/* ════════════════════════════════════════════════════════ + Animations + ════════════════════════════════════════════════════════ */ + @keyframes rise { from { opacity: 0; @@ -361,6 +824,15 @@ select { } } +@keyframes chrome-shimmer { + 0% { + left: -100%; + } + 100% { + left: 100%; + } +} + /* Stagger animation delays for grouped elements */ .stagger-1 { animation-delay: 0ms; diff --git a/ui/src/styles/chat.css b/ui/src/styles/chat.css index 07d3b644a..d35b7316d 100644 --- a/ui/src/styles/chat.css +++ b/ui/src/styles/chat.css @@ -3,3 +3,4 @@ @import "./chat/grouped.css"; @import "./chat/tool-cards.css"; @import "./chat/sidebar.css"; +@import "./chat/agent-chat.css"; diff --git a/ui/src/styles/chat/agent-chat.css b/ui/src/styles/chat/agent-chat.css new file mode 100644 index 000000000..0b70ef31a --- /dev/null +++ b/ui/src/styles/chat/agent-chat.css @@ -0,0 +1,1287 @@ +/* =========================================== + Agent Chat — ported from dashboard-lit + =========================================== */ + +.agent-chat { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow: hidden; + position: relative; +} + +.agent-chat__thread { + flex: 1 1 0; + min-height: 0; + overflow-y: auto; + padding: 12px 18px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.agent-chat__empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--muted); + font-size: 0.92rem; +} + +.agent-chat__error { + color: color-mix(in srgb, var(--accent) 85%, #fff); + font-size: 0.85rem; + padding: 6px 10px; + margin-top: 4px; + background: color-mix(in srgb, var(--accent) 8%, transparent); + border-radius: var(--radius-sm); + border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent); +} + +/* ─── Welcome / Empty State ─── */ + +.agent-chat__welcome { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + padding: 40px 24px 32px; + text-align: center; + position: relative; + overflow: hidden; +} + +.agent-chat__welcome-glow { + position: absolute; + top: 10%; + left: 50%; + transform: translateX(-50%); + width: 280px; + height: 180px; + border-radius: 50%; + background: radial-gradient(ellipse, var(--agent-color, var(--accent)) 0%, transparent 70%); + opacity: 0.06; + pointer-events: none; + filter: blur(40px); +} + +.agent-chat__welcome h2 { + font-size: 1.5rem; + font-weight: 700; + color: var(--text); + margin: 8px 0 0; + letter-spacing: -0.02em; +} + +.agent-chat__personality { + font-size: 0.88rem; + color: var(--muted); + max-width: 380px; + line-height: 1.55; + margin: 2px 0 0; +} + +.agent-chat__badges { + display: flex; + gap: 6px; + flex-wrap: wrap; + justify-content: center; + margin-top: 6px; +} + +.agent-chat__badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 12px; + border-radius: 999px; + border: 1px solid var(--border); + background: transparent; + color: var(--muted); + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.01em; +} + +.agent-chat__badge svg { + width: 14px; + height: 14px; +} + +/* ─── Starter Cards ─── */ + +.agent-chat__starters { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-top: 16px; + width: 100%; + max-width: 420px; +} + +.agent-chat__starter { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--card); + color: var(--text); + font-size: 0.82rem; + font-weight: 500; + text-align: left; + cursor: pointer; + transition: + border-color var(--duration-fast) ease, + background var(--duration-fast) ease, + box-shadow var(--duration-fast) ease, + transform var(--duration-fast) var(--ease-spring); + line-height: 1.35; +} + +.agent-chat__starter:hover { + border-color: color-mix(in srgb, var(--agent-color, var(--accent)) 45%, transparent); + background: color-mix(in srgb, var(--agent-color, var(--accent)) 5%, transparent); + box-shadow: 0 2px 12px color-mix(in srgb, var(--agent-color, var(--accent)) 8%, transparent); + transform: translateY(-1px); +} + +.agent-chat__starter:active { + transform: translateY(0); + box-shadow: none; +} + +.agent-chat__starter:disabled { + opacity: 0.45; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.agent-chat__starter-icon { + font-size: 1.15rem; + line-height: 1; + flex-shrink: 0; +} + +.agent-chat__starter-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-chat__starter-arrow { + display: flex; + align-items: center; + color: var(--agent-color, var(--accent)); + opacity: 0; + transform: translateX(-3px); + transition: + opacity var(--duration-fast) ease, + transform var(--duration-fast) ease; + flex-shrink: 0; +} + +.agent-chat__starter-arrow svg { + width: 14px; + height: 14px; +} + +.agent-chat__starter:hover .agent-chat__starter-arrow { + opacity: 0.8; + transform: translateX(0); +} + +@media (max-width: 400px) { + .agent-chat__starters { + grid-template-columns: 1fr; + max-width: 280px; + } +} + +.agent-chat__hint { + font-size: 0.73rem; + color: var(--muted); + margin-top: 20px; + opacity: 0.7; +} + +.agent-chat__hint kbd { + display: inline-block; + padding: 1px 5px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--card); + font-size: 0.7rem; + font-family: inherit; +} + +/* ─── Avatar Circle ─── */ + +.agent-chat__avatar { + width: 56px; + height: 56px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.4rem; + font-weight: 700; + color: #fff; + background: var(--agent-color, var(--accent)); + flex-shrink: 0; +} + +.agent-chat__avatar--sm { + width: 24px; + height: 24px; + font-size: 0.65rem; +} + +/* ─── Chat Bubble ─── */ + +.chat-bubble { + padding: 10px 14px; + max-width: 100%; + word-wrap: break-word; + overflow-wrap: break-word; + position: relative; +} + +.chat-bubble--history { + opacity: 0.65; +} + +.chat-bubble--user { + background: color-mix(in srgb, var(--accent) 6%, var(--card)); + border-radius: var(--radius-lg); + border: 1px solid color-mix(in srgb, var(--accent) 14%, transparent); + margin-left: auto; + max-width: 85%; +} + +.chat-bubble--assistant { + padding: 10px 14px; +} + +.chat-bubble--tool { + padding: 4px 14px; +} + +.chat-bubble__header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.chat-bubble__role { + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--ok); +} + +.chat-bubble--user .chat-bubble__role { + color: var(--accent); +} + +.chat-bubble__role--tool { + color: var(--warn); + display: inline-flex; + align-items: center; + gap: 4px; +} + +.chat-bubble__role--tool svg { + width: 14px; + height: 14px; +} + +.chat-bubble__model-tag { + font-size: 0.68rem; + font-weight: 600; + padding: 1px 6px; + border-radius: 999px; + background: color-mix(in srgb, var(--text) 8%, transparent); + color: var(--muted); +} + +.chat-bubble__ts { + font-size: 0.72rem; + color: var(--muted); +} + +.chat-bubble__body { + font-size: 0.92rem; + line-height: 1.45; + white-space: pre-wrap; + word-wrap: break-word; +} + +.chat-bubble__actions { + display: none; + gap: 4px; + margin-top: 4px; +} + +.chat-bubble:hover .chat-bubble__actions { + display: flex; +} + +.chat-bubble__action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: var(--radius-sm); + border: none; + background: transparent; + color: var(--muted); + cursor: pointer; + transition: all var(--duration-fast) ease; + padding: 0; +} + +.chat-bubble__action svg { + width: 14px; + height: 14px; +} + +.chat-bubble__action:hover { + color: var(--text); + background: var(--bg-hover); +} + +/* ─── Chat Divider ─── */ + +.agent-chat__divider { + display: flex; + align-items: center; + gap: 12px; + margin: 10px 0; + font-size: 0.72rem; + color: var(--accent); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.agent-chat__divider::before, +.agent-chat__divider::after { + content: ""; + flex: 1; + height: 1px; + background: color-mix(in srgb, var(--accent) 30%, transparent); +} + +/* ─── Streaming Indicator ─── */ + +.agent-chat__streaming { + padding: 10px 14px; + border-left: 2px solid var(--accent); + animation: chat-pulse 1.5s ease-in-out infinite; +} + +.agent-chat__streaming-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +.agent-chat__streaming-name { + font-size: 0.82rem; + font-weight: 600; + color: var(--text); +} + +.agent-chat__streaming-dots { + display: inline-flex; + gap: 3px; + align-items: center; +} + +.agent-chat__streaming-dots span { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--accent); + animation: chat-pulse 1.2s ease-in-out infinite; +} + +.agent-chat__streaming-dots span:nth-child(2) { + animation-delay: 0.2s; +} + +.agent-chat__streaming-dots span:nth-child(3) { + animation-delay: 0.4s; +} + +.agent-chat__streaming-label { + font-size: 0.75rem; + color: var(--muted); + font-style: italic; +} + +.agent-chat__streaming-timer { + font-size: 0.72rem; + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +.agent-chat__streaming-content { + font-size: 0.92rem; + line-height: 1.45; +} + +.agent-chat__cursor { + display: inline-block; + width: 2px; + height: 1em; + background: var(--accent); + margin-left: 1px; + vertical-align: text-bottom; + animation: cursor-blink 0.8s step-end infinite; +} + +@keyframes cursor-blink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0; + } +} + +@keyframes chat-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* ─── Input Bar (Cursor-style unified container) ─── */ + +.agent-chat__input { + position: relative; + display: flex; + flex-direction: column; + margin: 0 18px 14px; + padding: 0; + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + flex-shrink: 0; + overflow: hidden; + transition: + border-color var(--duration-fast) ease, + box-shadow var(--duration-fast) ease; +} + +.agent-chat__input:focus-within { + border-color: color-mix(in srgb, var(--accent) 50%, transparent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 10%, transparent); +} + +@supports (backdrop-filter: blur(1px)) { + .agent-chat__input { + backdrop-filter: blur(16px) saturate(1.8); + -webkit-backdrop-filter: blur(16px) saturate(1.8); + } +} + +/* Textarea — full width, borderless inside the container */ + +.agent-chat__input > textarea { + width: 100%; + min-height: 40px; + max-height: 150px; + resize: none; + padding: 12px 14px 8px; + border: none; + background: transparent; + color: var(--text); + font-size: 0.92rem; + font-family: inherit; + line-height: 1.4; + outline: none; + box-sizing: border-box; +} + +.agent-chat__input > textarea::placeholder { + color: var(--muted); +} + +/* ─── Toolbar (below textarea) ─── */ + +.agent-chat__toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent); +} + +.agent-chat__toolbar-left, +.agent-chat__toolbar-right { + display: flex; + align-items: center; + gap: 4px; +} + +/* ─── Toolbar buttons (ghost style) ─── */ + +.agent-chat__input-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: var(--radius-sm); + border: none; + background: transparent; + color: var(--muted); + cursor: pointer; + flex-shrink: 0; + transition: all var(--duration-fast) ease; + padding: 0; +} + +.agent-chat__input-btn svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.agent-chat__input-btn:hover:not(:disabled) { + color: var(--text); + background: var(--bg-hover); +} + +.agent-chat__input-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.agent-chat__input-btn--active { + color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); +} + +.agent-chat__toolbar .btn-ghost { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: var(--radius-sm); + border: none; + background: transparent; + color: var(--muted); + cursor: pointer; + padding: 0; + transition: all var(--duration-fast) ease; +} + +.agent-chat__toolbar .btn-ghost svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.agent-chat__toolbar .btn-ghost:hover:not(:disabled) { + color: var(--text); + background: var(--bg-hover); +} + +.agent-chat__toolbar .btn-ghost:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.agent-chat__input-divider { + width: 1px; + height: 16px; + background: var(--border); + margin: 0 4px; +} + +.agent-chat__token-count { + font-size: 0.7rem; + color: var(--muted); + white-space: nowrap; + flex-shrink: 0; + align-self: center; +} + +/* Send / Stop button */ + +.chat-send-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: var(--radius-md); + border: none; + background: var(--accent); + color: var(--accent-foreground); + cursor: pointer; + flex-shrink: 0; + transition: all var(--duration-fast) ease; + padding: 0; +} + +.chat-send-btn svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.chat-send-btn:hover:not(:disabled) { + background: var(--accent-hover); + box-shadow: 0 2px 8px color-mix(in srgb, var(--accent) 24%, transparent); +} + +.chat-send-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.chat-send-btn--stop { + background: var(--danger); +} + +.chat-send-btn--stop:hover:not(:disabled) { + background: color-mix(in srgb, var(--danger) 85%, #fff); +} + +/* ─── Search Bar ─── */ + +.agent-chat__search-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border-bottom: 1px solid var(--border); + background: var(--card); +} + +.agent-chat__search-bar svg { + width: 16px; + height: 16px; + color: var(--muted); + flex-shrink: 0; +} + +.agent-chat__search-bar input { + flex: 1; + border: none; + background: transparent; + color: var(--text); + font-size: 0.88rem; + outline: none; +} + +.agent-chat__search-bar input::placeholder { + color: var(--muted); +} + +/* ─── Pinned Messages ─── */ + +.agent-chat__pinned { + border-bottom: 1px solid var(--border); + padding: 6px 14px; +} + +.agent-chat__pinned-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: var(--radius-sm); + border: none; + background: transparent; + color: var(--accent); + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; + transition: background var(--duration-fast) ease; +} + +.agent-chat__pinned-toggle svg { + width: 14px; + height: 14px; +} + +.agent-chat__pinned-toggle:hover { + background: var(--bg-hover); +} + +.agent-chat__pinned-list { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 4px; + padding-left: 8px; +} + +.agent-chat__pinned-item { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + border-radius: var(--radius-sm); + font-size: 0.82rem; +} + +.agent-chat__pinned-role { + font-weight: 600; + font-size: 0.72rem; + text-transform: uppercase; + color: var(--muted); + flex-shrink: 0; +} + +.agent-chat__pinned-text { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text); +} + +/* ─── Scroll Pill ─── */ + +.agent-chat__scroll-pill { + position: absolute; + bottom: 100px; + left: 50%; + transform: translateX(-50%); + display: inline-flex; + align-items: center; + gap: 5px; + padding: 6px 14px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--card); + color: var(--accent); + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; + box-shadow: var(--shadow-md); + z-index: 20; + transition: all var(--duration-fast) ease; +} + +.agent-chat__scroll-pill svg { + width: 14px; + height: 14px; +} + +.agent-chat__scroll-pill:hover { + background: color-mix(in srgb, var(--accent) 10%, var(--card)); +} + +/* ─── Slash Command Menu ─── */ + +.slash-menu { + position: absolute; + bottom: 100%; + left: 0; + right: 0; + max-height: 320px; + overflow-y: auto; + background: var(--popover); + border: 1px solid var(--border-strong); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + z-index: 30; + margin-bottom: 4px; + padding: 6px; + scrollbar-width: thin; +} + +.slash-menu-group + .slash-menu-group { + margin-top: 4px; + padding-top: 4px; + border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent); +} + +.slash-menu-group__label { + padding: 4px 10px 2px; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--accent); + opacity: 0.7; +} + +.slash-menu-item { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: + background var(--duration-fast) ease, + color var(--duration-fast) ease; +} + +.slash-menu-item:hover, +.slash-menu-item--active { + background: color-mix(in srgb, var(--accent) 10%, var(--bg-hover)); +} + +.slash-menu-icon { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + flex-shrink: 0; + color: var(--accent); + opacity: 0.7; +} + +.slash-menu-icon svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.slash-menu-item--active .slash-menu-icon, +.slash-menu-item:hover .slash-menu-icon { + opacity: 1; +} + +.slash-menu-name { + font-size: 0.82rem; + font-weight: 600; + font-family: var(--mono); + color: var(--accent); + white-space: nowrap; +} + +.slash-menu-args { + font-size: 0.75rem; + color: var(--muted); + font-family: var(--mono); + opacity: 0.65; +} + +.slash-menu-desc { + font-size: 0.75rem; + color: var(--muted); + flex: 1; + text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.slash-menu-item--active .slash-menu-name { + color: var(--accent-hover); +} + +.slash-menu-item--active .slash-menu-desc { + color: var(--text); +} + +/* ─── Attachment Previews ─── */ + +.chat-attachments-preview { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.chat-attachment-thumb { + position: relative; + width: 60px; + height: 60px; + border-radius: var(--radius-sm); + overflow: hidden; + border: 1px solid var(--border); +} + +.chat-attachment-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.chat-attachment-remove { + position: absolute; + top: 2px; + right: 2px; + width: 18px; + height: 18px; + border-radius: 50%; + border: none; + background: rgba(0, 0, 0, 0.6); + color: #fff; + font-size: 12px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.chat-attachment-file { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.72rem; + color: var(--muted); + padding: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ─── Reasoning Block ─── */ + +.reasoning-block { + margin: 4px 0; +} + +.reasoning-block__toggle { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg-hover); + color: var(--muted); + font-size: 0.75rem; + font-weight: 600; + cursor: pointer; + transition: all var(--duration-fast) ease; +} + +.reasoning-block__toggle:hover { + color: var(--text); + border-color: var(--border-strong); +} + +.reasoning-block__content { + display: none; + margin-top: 6px; + padding: 8px 12px; + font-size: 0.82rem; + line-height: 1.5; + color: var(--muted); + font-style: italic; + white-space: pre-wrap; + word-wrap: break-word; + border-left: 2px solid var(--border); +} + +.reasoning-block--open .reasoning-block__content { + display: block; +} + +.reasoning-block--streaming .reasoning-block__toggle { + animation: chat-pulse 1.5s ease-in-out infinite; +} + +/* ─── Tool Block ─── */ + +.tool-block { + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg); + overflow: hidden; + margin: 4px 0; +} + +.tool-block__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + font-size: 0.82rem; + font-weight: 600; + color: var(--text); + transition: background var(--duration-fast) ease; +} + +.tool-block__header:hover { + background: var(--bg-hover); +} + +.tool-block__name { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.tool-block__name svg { + width: 14px; + height: 14px; +} + +.tool-block__body { + display: none; + padding: 0 12px 10px; +} + +.tool-block--open .tool-block__body { + display: block; +} + +.tool-block__output { + margin: 0; + font-family: var(--mono); + font-size: 0.78rem; + line-height: 1.5; + color: var(--muted); + white-space: pre-wrap; + word-wrap: break-word; + max-height: 300px; + overflow: auto; + padding: 8px; + border-radius: var(--radius-sm); + background: var(--bg-accent); + border: 1px solid var(--border); +} + +.tool-block__chevron { + transition: transform var(--duration-fast) ease; +} + +.tool-block__chevron svg { + width: 14px; + height: 14px; +} + +.tool-block--open .tool-block__chevron { + transform: rotate(180deg); +} + +/* ─── File Input (hidden) ─── */ + +.agent-chat__file-input { + display: none; +} + +/* ─── Danger ghost button ─── */ + +.btn-ghost--danger:hover { + color: var(--danger) !important; +} + +.btn-ghost--sm { + padding: 4px; +} + +.btn-ghost--sm svg { + width: 14px; + height: 14px; +} + +/* ─── Agent Bar ─── */ + +.chat-agent-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 12px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent); + flex-shrink: 0; + gap: 6px; +} + +.chat-agent-bar__left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.chat-agent-bar__right { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} + +.chat-agent-bar__name { + font-size: 12px; + font-weight: 600; + color: var(--text); +} + +.chat-agent-select { + background: color-mix(in srgb, var(--secondary) 70%, transparent); + border: 1px solid var(--border); + border-radius: var(--radius-md); + color: var(--text); + font-size: 12px; + font-weight: 500; + padding: 2px 20px 2px 6px; + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 24 24' fill='none' stroke='%237d8590' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 4px center; + transition: + border-color 150ms ease, + background 150ms ease; +} + +.chat-agent-select:hover { + border-color: var(--border-strong); + background: color-mix(in srgb, var(--secondary) 90%, transparent); +} + +.chat-agent-select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-subtle); +} + +/* ─── Sessions Panel ─── */ + +.chat-sessions-panel { + position: relative; +} + +.chat-sessions-summary { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + border-radius: var(--radius-sm); + font-size: 11px; + font-weight: 500; + color: var(--muted); + cursor: pointer; + user-select: none; + list-style: none; + transition: + color 150ms ease, + background 150ms ease; +} + +.chat-sessions-summary::-webkit-details-marker { + display: none; +} + +.chat-sessions-summary::before { + content: "▸"; + font-size: 9px; + transition: transform 150ms ease; +} + +.chat-sessions-panel[open] > .chat-sessions-summary::before { + transform: rotate(90deg); +} + +.chat-sessions-summary:hover { + color: var(--text); + background: color-mix(in srgb, var(--bg-hover) 60%, transparent); +} + +.chat-sessions-summary svg { + width: 12px; + height: 12px; +} + +.chat-sessions-list { + position: absolute; + top: 100%; + left: 0; + z-index: 50; + min-width: 240px; + max-width: 360px; + max-height: 280px; + overflow-y: auto; + margin-top: 4px; + padding: 4px; + background: var(--popover); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + display: flex; + flex-direction: column; + gap: 2px; +} + +.chat-session-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + padding: 4px 8px; + border: none; + border-radius: var(--radius-sm); + background: none; + color: var(--text); + font-size: 11px; + cursor: pointer; + text-align: left; + width: 100%; + transition: background 120ms ease; +} + +.chat-session-item:hover { + background: var(--bg-hover); +} + +.chat-session-item--active { + background: var(--accent-subtle); + color: var(--accent); + font-weight: 500; +} + +.chat-session-item__name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.chat-session-item__meta { + font-size: 11px; + flex-shrink: 0; + white-space: nowrap; +} diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css index c43743267..46cd18f4e 100644 --- a/ui/src/styles/chat/grouped.css +++ b/ui/src/styles/chat/grouped.css @@ -83,14 +83,15 @@ /* Avatar Styles */ .chat-avatar { - width: 40px; - height: 40px; - border-radius: 8px; - background: var(--panel-strong); + width: 38px; + height: 38px; + border-radius: var(--radius-md); + border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); + background: color-mix(in srgb, var(--panel-strong) 95%, transparent); display: grid; place-items: center; font-weight: 600; - font-size: 14px; + font-size: 13px; flex-shrink: 0; align-self: flex-end; /* Align with last message in group */ margin-bottom: 4px; /* Optical alignment */ @@ -127,14 +128,15 @@ img.chat-avatar { .chat-bubble { position: relative; display: inline-block; - border: 1px solid transparent; - background: var(--card); + border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); + background: color-mix(in srgb, var(--card) 97%, transparent); border-radius: var(--radius-lg); padding: 10px 14px; - box-shadow: none; + box-shadow: inset 0 1px 0 var(--card-highlight); transition: background 150ms ease-out, - border-color 150ms ease-out; + border-color 150ms ease-out, + box-shadow 150ms ease-out; max-width: 100%; word-wrap: break-word; } @@ -147,8 +149,8 @@ img.chat-avatar { position: absolute; top: 6px; right: 8px; - border: 1px solid var(--border); - background: var(--bg); + border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); + background: color-mix(in srgb, var(--bg) 94%, transparent); color: var(--muted); border-radius: var(--radius-md); padding: 4px 6px; @@ -159,7 +161,8 @@ img.chat-avatar { pointer-events: none; transition: opacity 120ms ease-out, - background 120ms ease-out; + background 120ms ease-out, + border-color 120ms ease-out; } .chat-copy-btn__icon { @@ -206,6 +209,7 @@ img.chat-avatar { .chat-copy-btn:hover { background: var(--bg-hover); + border-color: var(--border-strong); } .chat-copy-btn[data-copying="1"] { @@ -243,29 +247,20 @@ img.chat-avatar { } } -/* Light mode: restore borders */ -:root[data-theme="light"] .chat-bubble { - border-color: var(--border); - box-shadow: inset 0 1px 0 var(--card-highlight); -} - .chat-bubble:hover { - background: var(--bg-hover); + background: color-mix(in srgb, var(--card) 82%, var(--bg-hover)); + border-color: var(--border-strong); + box-shadow: var(--shadow-sm); } /* User bubbles have different styling */ .chat-group.user .chat-bubble { - background: var(--accent-subtle); - border-color: transparent; -} - -:root[data-theme="light"] .chat-group.user .chat-bubble { - border-color: rgba(234, 88, 12, 0.2); - background: rgba(251, 146, 60, 0.12); + background: color-mix(in srgb, var(--accent-subtle) 85%, var(--secondary)); + border-color: color-mix(in srgb, var(--accent) 30%, transparent); } .chat-group.user .chat-bubble:hover { - background: rgba(255, 77, 77, 0.15); + background: var(--danger-subtle); } /* Streaming animation */ @@ -298,3 +293,59 @@ img.chat-avatar { transform: translateY(0); } } + +/* Delete button (appears on hover in group footer) */ + +.chat-group-delete { + all: unset; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: var(--radius-sm); + color: var(--muted); + cursor: pointer; + opacity: 0; + pointer-events: none; + transition: + opacity 120ms ease-out, + color 120ms ease-out, + background 120ms ease-out; + margin-left: auto; +} + +.chat-group-delete svg { + width: 12px; + height: 12px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.chat-group:hover .chat-group-delete { + opacity: 0.5; + pointer-events: auto; +} + +.chat-group-delete:hover { + opacity: 1 !important; + color: var(--danger); + background: var(--danger-subtle); +} + +.chat-group-delete:focus-visible { + opacity: 1; + pointer-events: auto; + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +@media (hover: none) { + .chat-group-delete { + opacity: 0.5; + pointer-events: auto; + } +} diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 67299bab8..a94a9ce2f 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -52,11 +52,15 @@ flex: 1 1 0; /* Grow, shrink, and use 0 base for proper scrolling */ overflow-y: auto; overflow-x: hidden; - padding: 12px 4px; + padding: 10px 6px; margin: 0 -4px; min-height: 0; /* Allow shrinking for flex scroll behavior */ - border-radius: 12px; - background: transparent; + border-radius: var(--radius-lg); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--panel) 72%, transparent), + transparent + ); } /* Focus mode exit button */ @@ -111,20 +115,22 @@ font-size: 13px; font-family: var(--font-body); color: var(--text); - background: var(--panel-strong); - border: 1px solid var(--border); + background: color-mix(in srgb, var(--panel-strong) 92%, transparent); + border: 1px solid color-mix(in srgb, var(--border) 86%, transparent); border-radius: 999px; cursor: pointer; white-space: nowrap; z-index: 10; transition: background 150ms ease-out, - border-color 150ms ease-out; + border-color 150ms ease-out, + box-shadow 150ms ease-out; } .chat-new-messages:hover { background: var(--panel); - border-color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 36%, transparent); + box-shadow: var(--shadow-sm); } .chat-new-messages svg { @@ -147,8 +153,9 @@ flex-direction: column; gap: 12px; margin-top: auto; /* Push to bottom of flex container */ - padding: 12px 4px 4px; - background: linear-gradient(to bottom, transparent, var(--bg) 20%); + padding: 14px 6px 6px; + background: linear-gradient(to bottom, transparent, color-mix(in srgb, var(--bg) 94%, black) 22%); + backdrop-filter: blur(4px); z-index: 10; } @@ -218,21 +225,6 @@ stroke-width: 2px; } -/* Light theme attachment overrides */ -:root[data-theme="light"] .chat-attachments { - background: #f8fafc; - border-color: rgba(16, 24, 40, 0.1); -} - -:root[data-theme="light"] .chat-attachment { - border-color: rgba(16, 24, 40, 0.15); - background: #fff; -} - -:root[data-theme="light"] .chat-attachment__remove { - background: rgba(0, 0, 0, 0.6); -} - /* Message images (sent images displayed in chat) */ .chat-message-images { display: flex; @@ -267,10 +259,6 @@ flex: 1; } -:root[data-theme="light"] .chat-compose { - background: linear-gradient(to bottom, transparent, var(--bg-content) 20%); -} - .chat-compose__field { flex: 1 1 auto; min-width: 0; @@ -290,13 +278,16 @@ min-height: 40px; max-height: 150px; padding: 9px 12px; - border-radius: 8px; + border-radius: var(--radius-md); overflow-y: auto; resize: none; white-space: pre-wrap; font-family: var(--font-body); font-size: 14px; line-height: 1.45; + border: 1px solid color-mix(in srgb, var(--input) 92%, transparent); + background: color-mix(in srgb, var(--card) 98%, transparent); + box-shadow: inset 0 1px 0 var(--card-highlight); } .chat-compose__field textarea:disabled { @@ -327,13 +318,13 @@ display: flex; align-items: center; justify-content: flex-start; - gap: 12px; + gap: 6px; flex-wrap: wrap; } .chat-controls__session { - min-width: 140px; - max-width: 300px; + min-width: 120px; + max-width: 260px; } .chat-controls__thinking { @@ -345,31 +336,28 @@ /* Icon button style */ .btn--icon { - padding: 8px !important; - min-width: 36px; - height: 36px; + padding: 6px !important; + min-width: 32px; + height: 32px; display: inline-flex; align-items: center; justify-content: center; - border: 1px solid var(--border); - background: rgba(255, 255, 255, 0.06); + border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); + background: color-mix(in srgb, var(--secondary) 85%, transparent); + border-radius: var(--radius-md); } /* Controls separator */ .chat-controls__separator { - color: rgba(255, 255, 255, 0.4); - font-size: 18px; - margin: 0 8px; + color: var(--border); + font-size: 14px; + margin: 0 2px; font-weight: 300; } -:root[data-theme="light"] .chat-controls__separator { - color: rgba(16, 24, 40, 0.3); -} - .btn--icon:hover { - background: rgba(255, 255, 255, 0.12); - border-color: rgba(255, 255, 255, 0.2); + background: var(--bg-hover); + border-color: var(--border-strong); } /* Ensure chat toolbar toggles have a clearly visible active state. */ @@ -379,31 +367,10 @@ color: var(--accent); } -/* Light theme icon button overrides */ -:root[data-theme="light"] .btn--icon { - background: #ffffff; - border-color: var(--border); - box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05); - color: var(--muted); -} - -:root[data-theme="light"] .btn--icon:hover { - background: #ffffff; - border-color: var(--border-strong); - color: var(--text); -} - -:root[data-theme="light"] .chat-controls .btn--icon.active { - border-color: var(--accent); - background: var(--accent-subtle); - color: var(--accent); - box-shadow: 0 0 0 1px var(--accent-subtle); -} - .btn--icon svg { display: block; - width: 18px; - height: 18px; + width: 16px; + height: 16px; stroke: currentColor; fill: none; stroke-width: 1.5px; @@ -412,9 +379,9 @@ } .chat-controls__session select { - padding: 6px 10px; - font-size: 13px; - max-width: 300px; + padding: 4px 8px; + font-size: 12px; + max-width: 260px; overflow: hidden; text-overflow: ellipsis; } @@ -423,22 +390,16 @@ display: flex; align-items: center; gap: 4px; - font-size: 12px; - padding: 4px 10px; - background: rgba(255, 255, 255, 0.04); - border-radius: 6px; - border: 1px solid var(--border); -} - -/* Light theme thinking indicator override */ -:root[data-theme="light"] .chat-controls__thinking { - background: rgba(255, 255, 255, 0.9); - border-color: rgba(16, 24, 40, 0.15); + font-size: 11px; + padding: 2px 8px; + background: color-mix(in srgb, var(--secondary) 90%, transparent); + border-radius: var(--radius-sm); + border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); } @media (max-width: 640px) { .chat-session { - min-width: 140px; + min-width: 100px; } .chat-compose { @@ -447,10 +408,11 @@ .chat-controls { flex-wrap: wrap; - gap: 8px; + gap: 4px; } .chat-controls__session { - min-width: 120px; + min-width: 100px; + max-width: 180px; } } diff --git a/ui/src/styles/chat/sidebar.css b/ui/src/styles/chat/sidebar.css index 934e285d9..bc2949309 100644 --- a/ui/src/styles/chat/sidebar.css +++ b/ui/src/styles/chat/sidebar.css @@ -19,11 +19,12 @@ .chat-sidebar { flex: 1; min-width: 300px; - border-left: 1px solid var(--border); + border-left: 1px solid color-mix(in srgb, var(--border) 90%, transparent); display: flex; flex-direction: column; overflow: hidden; animation: slide-in 200ms ease-out; + background: color-mix(in srgb, var(--panel) 94%, transparent); } @keyframes slide-in { @@ -50,12 +51,13 @@ justify-content: space-between; align-items: center; padding: 12px 16px; - border-bottom: 1px solid var(--border); + border-bottom: 1px solid color-mix(in srgb, var(--border) 88%, transparent); flex-shrink: 0; position: sticky; top: 0; z-index: 10; - background: var(--panel); + background: color-mix(in srgb, var(--panel) 95%, transparent); + backdrop-filter: blur(6px); } /* Smaller close button for sidebar */ @@ -79,12 +81,13 @@ .sidebar-markdown { font-size: 14px; - line-height: 1.5; + line-height: 1.6; } .sidebar-markdown pre { - background: rgba(0, 0, 0, 0.12); - border-radius: 4px; + background: color-mix(in srgb, var(--secondary) 90%, transparent); + border-radius: var(--radius-md); + border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); padding: 12px; overflow-x: auto; } diff --git a/ui/src/styles/chat/text.css b/ui/src/styles/chat/text.css index d6eea9866..ead2a6905 100644 --- a/ui/src/styles/chat/text.css +++ b/ui/src/styles/chat/text.css @@ -5,17 +5,12 @@ .chat-thinking { margin-bottom: 10px; padding: 10px 12px; - border-radius: 10px; - border: 1px dashed rgba(255, 255, 255, 0.18); - background: rgba(255, 255, 255, 0.04); + border-radius: var(--radius-md); + border: 1px dashed color-mix(in srgb, var(--border) 84%, transparent); + background: color-mix(in srgb, var(--secondary) 75%, transparent); color: var(--muted); font-size: 12px; - line-height: 1.4; -} - -:root[data-theme="light"] .chat-thinking { - border-color: rgba(16, 24, 40, 0.25); - background: rgba(16, 24, 40, 0.04); + line-height: 1.45; } .chat-text { @@ -57,14 +52,16 @@ } .chat-text :where(:not(pre) > code) { - background: rgba(0, 0, 0, 0.15); - padding: 0.15em 0.4em; - border-radius: 4px; + background: color-mix(in srgb, var(--secondary) 82%, transparent); + border: 1px solid color-mix(in srgb, var(--border) 84%, transparent); + padding: 0.15em 0.42em; + border-radius: 5px; } .chat-text :where(pre) { - background: rgba(0, 0, 0, 0.15); - border-radius: 6px; + background: color-mix(in srgb, var(--secondary) 82%, transparent); + border: 1px solid color-mix(in srgb, var(--border) 84%, transparent); + border-radius: var(--radius-md); padding: 10px 12px; overflow-x: auto; } @@ -74,12 +71,50 @@ padding: 0; } +/* Collapsed JSON code blocks */ + +.chat-text :where(details.json-collapse) { + background: color-mix(in srgb, var(--secondary) 82%, transparent); + border: 1px solid color-mix(in srgb, var(--border) 84%, transparent); + border-radius: var(--radius-md); +} + +.chat-text :where(details.json-collapse > summary) { + padding: 8px 12px; + cursor: pointer; + font-size: 12px; + color: var(--muted); + font-family: var(--mono); + user-select: none; + list-style: none; +} + +.chat-text :where(details.json-collapse > summary::-webkit-details-marker) { + display: none; +} + +.chat-text :where(details.json-collapse > summary::before) { + content: "▸ "; +} + +.chat-text :where(details.json-collapse[open] > summary::before) { + content: "▾ "; +} + +.chat-text :where(details.json-collapse > pre) { + background: none; + border: none; + border-top: 1px solid color-mix(in srgb, var(--border) 84%, transparent); + border-radius: 0; + margin: 0; +} + .chat-text :where(blockquote) { - border-left: 3px solid var(--border-strong); + border-left: 3px solid color-mix(in srgb, var(--border-strong) 88%, transparent); padding-left: 12px; margin-left: 0; color: var(--muted); - background: rgba(255, 255, 255, 0.02); + background: color-mix(in srgb, var(--secondary) 78%, transparent); padding: 8px 12px; border-radius: 0 var(--radius-sm) var(--radius-sm) 0; } @@ -87,34 +122,12 @@ .chat-text :where(blockquote blockquote) { margin-top: 8px; border-left-color: var(--border-hover); - background: rgba(255, 255, 255, 0.03); + background: color-mix(in srgb, var(--secondary) 55%, transparent); } .chat-text :where(blockquote blockquote blockquote) { border-left-color: var(--muted-strong); - background: rgba(255, 255, 255, 0.04); -} - -:root[data-theme="light"] .chat-text :where(blockquote) { - background: rgba(0, 0, 0, 0.03); -} - -:root[data-theme="light"] .chat-text :where(blockquote blockquote) { - background: rgba(0, 0, 0, 0.05); -} - -:root[data-theme="light"] .chat-text :where(blockquote blockquote blockquote) { - background: rgba(0, 0, 0, 0.04); -} - -:root[data-theme="light"] .chat-text :where(:not(pre) > code) { - background: rgba(0, 0, 0, 0.08); - border: 1px solid rgba(0, 0, 0, 0.1); -} - -:root[data-theme="light"] .chat-text :where(pre) { - background: rgba(0, 0, 0, 0.05); - border: 1px solid rgba(0, 0, 0, 0.1); + background: color-mix(in srgb, var(--secondary) 60%, transparent); } .chat-text :where(hr) { diff --git a/ui/src/styles/chat/tool-cards.css b/ui/src/styles/chat/tool-cards.css index 6384db115..c1e478aa9 100644 --- a/ui/src/styles/chat/tool-cards.css +++ b/ui/src/styles/chat/tool-cards.css @@ -1,14 +1,15 @@ /* Tool Card Styles */ .chat-tool-card { - border: 1px solid var(--border); - border-radius: 8px; + border: 1px solid color-mix(in srgb, var(--border) 90%, transparent); + border-radius: var(--radius-md); padding: 12px; margin-top: 8px; - background: var(--card); + background: color-mix(in srgb, var(--card) 97%, transparent); box-shadow: inset 0 1px 0 var(--card-highlight); transition: border-color 150ms ease-out, - background 150ms ease-out; + background 150ms ease-out, + box-shadow 150ms ease-out; /* Fixed max-height to ensure cards don't expand too much */ max-height: 120px; overflow: hidden; @@ -16,7 +17,8 @@ .chat-tool-card:hover { border-color: var(--border-strong); - background: var(--bg-hover); + background: color-mix(in srgb, var(--card) 82%, var(--bg-hover)); + box-shadow: var(--shadow-sm); } /* First tool card in a group - no top margin */ @@ -128,13 +130,13 @@ color: var(--muted); margin-top: 8px; padding: 8px 10px; - background: var(--secondary); + background: color-mix(in srgb, var(--secondary) 92%, transparent); border-radius: var(--radius-md); white-space: pre-wrap; overflow: hidden; max-height: 44px; line-height: 1.4; - border: 1px solid var(--border); + border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); } .chat-tool-card--clickable:hover .chat-tool-card__preview { @@ -148,16 +150,18 @@ color: var(--text); margin-top: 6px; padding: 6px 8px; - background: var(--secondary); + background: color-mix(in srgb, var(--secondary) 92%, transparent); border-radius: var(--radius-sm); + border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); white-space: pre-wrap; word-break: break-word; } /* Reading Indicator */ .chat-reading-indicator { - background: transparent; - border: 1px solid var(--border); + background: color-mix(in srgb, var(--secondary) 70%, transparent); + border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); + border-radius: var(--radius-md); padding: 12px; display: inline-flex; } @@ -200,3 +204,176 @@ transform: scale(1); } } + +/* =========================================== + Collapsible Tool Cards + =========================================== */ + +.chat-tools-collapse { + margin-top: 8px; + border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--card) 94%, transparent); + overflow: hidden; +} + +.chat-tools-summary { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + color: var(--muted); + user-select: none; + list-style: none; + transition: + color 150ms ease, + background 150ms ease; +} + +.chat-tools-summary::-webkit-details-marker { + display: none; +} + +.chat-tools-summary::before { + content: "▸"; + font-size: 10px; + flex-shrink: 0; + transition: transform 150ms ease; +} + +.chat-tools-collapse[open] > .chat-tools-summary::before { + transform: rotate(90deg); +} + +.chat-tools-summary:hover { + color: var(--text); + background: color-mix(in srgb, var(--bg-hover) 50%, transparent); +} + +.chat-tools-summary__icon { + display: inline-flex; + align-items: center; + width: 14px; + height: 14px; + color: var(--accent); + opacity: 0.7; + flex-shrink: 0; +} + +.chat-tools-summary__icon svg { + width: 14px; + height: 14px; +} + +.chat-tools-summary__count { + font-weight: 600; + color: var(--text); +} + +.chat-tools-summary__names { + color: var(--muted); + font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-tools-collapse__body { + padding: 4px 12px 12px; + border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent); +} + +.chat-tools-collapse__body .chat-tool-card:first-child { + margin-top: 8px; +} + +/* =========================================== + Collapsible JSON Block + =========================================== */ + +.chat-json-collapse { + margin-top: 4px; + border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--secondary) 60%, transparent); + overflow: hidden; +} + +.chat-json-summary { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + cursor: pointer; + font-size: 12px; + color: var(--muted); + user-select: none; + list-style: none; + transition: + color 150ms ease, + background 150ms ease; +} + +.chat-json-summary::-webkit-details-marker { + display: none; +} + +.chat-json-summary::before { + content: "▸"; + font-size: 10px; + flex-shrink: 0; + transition: transform 150ms ease; +} + +.chat-json-collapse[open] > .chat-json-summary::before { + transform: rotate(90deg); +} + +.chat-json-summary:hover { + color: var(--text); + background: color-mix(in srgb, var(--bg-hover) 50%, transparent); +} + +.chat-json-badge { + display: inline-flex; + align-items: center; + padding: 1px 5px; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--accent) 15%, transparent); + color: var(--accent); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + line-height: 1.4; + flex-shrink: 0; +} + +.chat-json-label { + font-family: var(--mono); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-json-content { + margin: 0; + padding: 10px 12px; + border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent); + font-family: var(--mono); + font-size: 12px; + line-height: 1.5; + color: var(--text); + overflow-x: auto; + max-height: 400px; + overflow-y: auto; +} + +.chat-json-content code { + font-family: inherit; + font-size: inherit; +} diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 09b89d9c2..8c323526b 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -1,5 +1,79 @@ @import "./chat.css"; +/* =========================================== + Login Gate + =========================================== */ + +.login-gate { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + min-height: 100dvh; + background: var(--bg); + padding: 24px; +} + +.login-gate__theme { + position: fixed; + top: 16px; + right: 16px; + z-index: 10; +} + +.login-gate__card { + width: min(520px, 100%); + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 32px; + animation: scale-in 0.25s var(--ease-out); +} + +.login-gate__header { + text-align: center; + margin-bottom: 24px; +} + +.login-gate__logo { + width: 48px; + height: 48px; + margin-bottom: 12px; +} + +.login-gate__title { + font-size: 22px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.login-gate__sub { + color: var(--muted); + font-size: 14px; + margin-top: 4px; +} + +.login-gate__form { + display: flex; + flex-direction: column; + gap: 12px; +} + +.login-gate__connect { + margin-top: 4px; + width: 100%; + justify-content: center; + padding: 10px 16px; + font-size: 15px; + font-weight: 600; +} + +.login-gate__help { + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--border); +} + /* =========================================== Update Banner =========================================== */ @@ -26,7 +100,7 @@ } .update-banner__btn:hover:not(:disabled) { - background: rgba(239, 68, 68, 0.15); + background: var(--danger-subtle); } /* =========================================== @@ -56,7 +130,7 @@ } .card-title { - font-size: 15px; + font-size: 16px; font-weight: 600; letter-spacing: -0.02em; color: var(--text-strong); @@ -64,7 +138,7 @@ .card-sub { color: var(--muted); - font-size: 13px; + font-size: 14px; margin-top: 6px; line-height: 1.5; } @@ -74,10 +148,10 @@ =========================================== */ .stat { - background: var(--card); + background: color-mix(in srgb, var(--card) 96%, transparent); border-radius: var(--radius-md); padding: 14px 16px; - border: 1px solid var(--border); + border: 1px solid color-mix(in srgb, var(--border) 92%, transparent); transition: border-color var(--duration-normal) var(--ease-out), box-shadow var(--duration-normal) var(--ease-out); @@ -87,20 +161,20 @@ .stat:hover { border-color: var(--border-strong); box-shadow: - var(--shadow-sm), + 0 6px 16px rgba(0, 0, 0, 0.18), inset 0 1px 0 var(--card-highlight); } .stat-label { color: var(--muted); - font-size: 11px; + font-size: 12px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.04em; } .stat-value { - font-size: 24px; + font-size: 26px; font-weight: 700; margin-top: 6px; letter-spacing: -0.03em; @@ -148,7 +222,7 @@ .account-count { margin-top: 10px; - font-size: 12px; + font-size: 13px; font-weight: 500; color: var(--muted); } @@ -184,13 +258,13 @@ .account-card-id { font-family: var(--mono); - font-size: 12px; + font-size: 13px; color: var(--muted); } .account-card-status { margin-top: 10px; - font-size: 13px; + font-size: 14px; } .account-card-status div { @@ -200,7 +274,7 @@ .account-card-error { margin-top: 8px; color: var(--danger); - font-size: 12px; + font-size: 13px; } /* =========================================== @@ -209,7 +283,7 @@ .label { color: var(--muted); - font-size: 12px; + font-size: 13px; font-weight: 500; } @@ -217,17 +291,20 @@ display: inline-flex; align-items: center; gap: 6px; - border: 1px solid var(--border); + border: 1px solid color-mix(in srgb, var(--border) 90%, transparent); padding: 6px 12px; border-radius: var(--radius-full); - background: var(--secondary); - font-size: 13px; + background: color-mix(in srgb, var(--secondary) 92%, transparent); + font-size: 14px; font-weight: 500; - transition: border-color var(--duration-fast) ease; + transition: + border-color var(--duration-fast) ease, + background var(--duration-fast) ease; } .pill:hover { border-color: var(--border-strong); + background: var(--bg-hover); } .pill.danger { @@ -241,67 +318,100 @@ =========================================== */ .theme-toggle { - --theme-item: 28px; - --theme-gap: 2px; - --theme-pad: 4px; - position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--clay-border-color); + border-radius: 999px; + padding: 5px; + height: 36px; + background: var(--clay-bg); + overflow: hidden; + max-width: 36px; + transition: + max-width var(--clay-duration-normal) var(--clay-easing), + padding var(--clay-duration-normal) var(--clay-easing); } -.theme-toggle__track { - position: relative; - display: grid; - grid-template-columns: repeat(3, var(--theme-item)); - gap: var(--theme-gap); - padding: var(--theme-pad); - border-radius: var(--radius-full); - border: 1px solid var(--border); - background: var(--secondary); +@media (hover: hover) { + .theme-toggle:hover { + max-width: 400px; + padding: 4px 6px; + } } -.theme-toggle__indicator { - position: absolute; - top: 50%; - left: var(--theme-pad); - width: var(--theme-item); - height: var(--theme-item); - border-radius: var(--radius-full); - transform: translateY(-50%) - translateX(calc(var(--theme-index, 0) * (var(--theme-item) + var(--theme-gap)))); - background: var(--accent); - transition: transform var(--duration-normal) var(--ease-out); - z-index: 0; +.theme-toggle:focus-within { + max-width: 400px; + padding: 4px 6px; } -.theme-toggle__button { - height: var(--theme-item); - width: var(--theme-item); - display: grid; - place-items: center; +.theme-toggle.theme-toggle--open { + max-width: 400px; + padding: 4px 6px; +} + +.theme-btn { border: 0; - border-radius: var(--radius-full); background: transparent; + padding: 6px 10px; + border-radius: 999px; + font-size: 0.84rem; color: var(--muted); + display: inline-flex; + align-items: center; + gap: 0.35rem; + white-space: nowrap; + flex-shrink: 0; cursor: pointer; - position: relative; - z-index: 1; - transition: color var(--duration-fast) ease; + transition: + color var(--clay-duration-fast) var(--clay-easing), + background var(--clay-duration-fast) var(--clay-easing), + transform var(--clay-duration-fast) var(--clay-easing); } -.theme-toggle__button:hover { +.theme-btn.active { + padding: 6px 8px; + background: var(--clay-bg-button); + color: var(--text); + box-shadow: var(--clay-shadow-pressed); +} + +.theme-btn:not(.active) { + opacity: 0; + pointer-events: none; + width: 0; + padding: 6px 0; + overflow: hidden; + transition: + opacity var(--clay-duration-fast) var(--clay-easing), + width var(--clay-duration-fast) var(--clay-easing), + padding var(--clay-duration-fast) var(--clay-easing), + color var(--clay-duration-fast) var(--clay-easing), + background var(--clay-duration-fast) var(--clay-easing), + transform var(--clay-duration-fast) var(--clay-easing); +} + +.theme-toggle:hover .theme-btn, +.theme-toggle:focus-within .theme-btn, +.theme-toggle--open .theme-btn { + opacity: 1; + pointer-events: auto; + width: auto; + padding: 6px 10px; +} + +.theme-btn:hover { + border: 0; color: var(--text); } -.theme-toggle__button.active { - color: var(--accent-foreground); +.theme-btn:active { + transform: scale(0.93); } -.theme-toggle__button.active .theme-icon { - stroke: var(--accent-foreground); -} - -.theme-icon { - width: 14px; - height: 14px; +.theme-btn svg { + width: 16px; + height: 16px; stroke: currentColor; fill: none; stroke-width: 1.5px; @@ -318,13 +428,13 @@ height: 8px; border-radius: var(--radius-full); background: var(--danger); - box-shadow: 0 0 8px rgba(239, 68, 68, 0.5); + box-shadow: 0 0 8px color-mix(in srgb, var(--danger) 50%, transparent); animation: pulse-subtle 2s ease-in-out infinite; } .statusDot.ok { background: var(--ok); - box-shadow: 0 0 8px rgba(34, 197, 94, 0.5); + box-shadow: 0 0 8px color-mix(in srgb, var(--ok) 50%, transparent); animation: none; } @@ -336,12 +446,13 @@ display: inline-flex; align-items: center; justify-content: center; + gap: 8px; - border: 1px solid var(--border); - background: var(--bg-elevated); - padding: 9px 16px; + border: 1px solid color-mix(in srgb, var(--border) 92%, transparent); + background: color-mix(in srgb, var(--bg-elevated) 95%, transparent); + padding: 10px 18px; border-radius: var(--radius-md); - font-size: 13px; + font-size: 14px; font-weight: 500; letter-spacing: -0.01em; cursor: pointer; @@ -352,14 +463,14 @@ transform var(--duration-fast) var(--ease-out); } -.btn:hover { +.btn:hover:not(:disabled) { background: var(--bg-hover); border-color: var(--border-strong); transform: translateY(-1px); box-shadow: var(--shadow-sm); } -.btn:active { +.btn:active:not(:disabled) { background: var(--secondary); transform: translateY(0); box-shadow: none; @@ -377,18 +488,16 @@ } .btn.primary { - border-color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 88%, black 10%); background: var(--accent); color: var(--primary-foreground); - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.24); } .btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); - box-shadow: - var(--shadow-md), - 0 0 20px var(--accent-glow); + box-shadow: var(--shadow-md); } /* Keyboard shortcut badge (shadcn style) */ @@ -412,28 +521,20 @@ background: rgba(255, 255, 255, 0.2); } -:root[data-theme="light"] .btn-kbd { - background: rgba(0, 0, 0, 0.08); -} - -:root[data-theme="light"] .btn.primary .btn-kbd { - background: rgba(255, 255, 255, 0.25); -} - .btn.active { - border-color: var(--accent); - background: var(--accent-subtle); + border-color: color-mix(in srgb, var(--accent) 35%, transparent); + background: color-mix(in srgb, var(--accent-subtle) 75%, var(--secondary)); color: var(--accent); } .btn.danger { - border-color: transparent; + border-color: color-mix(in srgb, var(--danger) 25%, transparent); background: var(--danger-subtle); color: var(--danger); } .btn.danger:hover { - background: rgba(239, 68, 68, 0.15); + background: color-mix(in srgb, var(--danger-subtle) 70%, transparent); } .btn--sm { @@ -441,9 +542,16 @@ font-size: 12px; } +.btn:focus-visible { + border-color: var(--ring); + box-shadow: var(--focus-ring); +} + .btn:disabled { opacity: 0.5; cursor: not-allowed; + transform: none; + box-shadow: none; } /* =========================================== @@ -461,29 +569,39 @@ .field span { color: var(--muted); - font-size: 13px; + font-size: 14px; font-weight: 500; } .field input, .field textarea, .field select { - border: 1px solid var(--input); - background: var(--card); + border: 1px solid color-mix(in srgb, var(--input) 92%, transparent); + background: color-mix(in srgb, var(--card) 96%, var(--bg)); border-radius: var(--radius-md); - padding: 8px 12px; + padding: 10px 14px; outline: none; box-shadow: inset 0 1px 0 var(--card-highlight); transition: border-color var(--duration-fast) ease, - box-shadow var(--duration-fast) ease; + box-shadow var(--duration-fast) ease, + background var(--duration-fast) ease; } -.field input:focus, -.field textarea:focus, -.field select:focus { +.field input:focus-visible, +.field textarea:focus-visible, +.field select:focus-visible { border-color: var(--ring); box-shadow: var(--focus-ring); + background: var(--card); +} + +.field input:disabled, +.field textarea:disabled, +.field select:disabled { + opacity: 0.6; + cursor: not-allowed; + background: color-mix(in srgb, var(--secondary) 80%, transparent); } .field select { @@ -526,33 +644,6 @@ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); } -:root[data-theme="light"] .field input, -:root[data-theme="light"] .field textarea, -:root[data-theme="light"] .field select { - background: var(--card); - border-color: var(--input); -} - -:root[data-theme="light"] .btn { - background: var(--bg); - border-color: var(--input); -} - -:root[data-theme="light"] .btn:hover { - background: var(--bg-hover); -} - -:root[data-theme="light"] .btn.active { - border-color: var(--accent); - background: var(--accent-subtle); - color: var(--accent); -} - -:root[data-theme="light"] .btn.primary { - background: var(--accent); - border-color: var(--accent); -} - /* =========================================== Utilities =========================================== */ @@ -580,23 +671,45 @@ } .callout.danger { - border-color: rgba(239, 68, 68, 0.25); - background: linear-gradient(135deg, rgba(239, 68, 68, 0.08) 0%, rgba(239, 68, 68, 0.04) 100%); + border-color: color-mix(in srgb, var(--danger) 25%, transparent); + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--danger) 8%, transparent) 0%, + color-mix(in srgb, var(--danger) 4%, transparent) 100% + ); color: var(--danger); } .callout.info { - border-color: rgba(59, 130, 246, 0.25); - background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(59, 130, 246, 0.04) 100%); + border-color: color-mix(in srgb, var(--info) 25%, transparent); + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--info) 8%, transparent) 0%, + color-mix(in srgb, var(--info) 4%, transparent) 100% + ); color: var(--info); } .callout.success { - border-color: rgba(34, 197, 94, 0.25); - background: linear-gradient(135deg, rgba(34, 197, 94, 0.08) 0%, rgba(34, 197, 94, 0.04) 100%); + border-color: color-mix(in srgb, var(--ok) 25%, transparent); + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--ok) 8%, transparent) 0%, + color-mix(in srgb, var(--ok) 4%, transparent) 100% + ); color: var(--ok); } +.callout.warn { + border-color: color-mix(in srgb, var(--warn) 25%, transparent); + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--warn) 8%, transparent) 0%, + color-mix(in srgb, var(--warn) 4%, transparent) 100% + ); + color: var(--warn); +} + /* Compaction indicator */ .compaction-indicator { align-self: center; @@ -607,7 +720,7 @@ line-height: 1.2; padding: 6px 14px; margin-bottom: 8px; - border-radius: 999px; + border-radius: var(--radius-full); border: 1px solid var(--border); background: var(--panel-strong); color: var(--text); @@ -629,7 +742,7 @@ .compaction-indicator--active { color: var(--info); - border-color: rgba(59, 130, 246, 0.35); + border-color: color-mix(in srgb, var(--info) 35%, transparent); } .compaction-indicator--active svg { @@ -638,17 +751,17 @@ .compaction-indicator--complete { color: var(--ok); - border-color: rgba(34, 197, 94, 0.35); + border-color: color-mix(in srgb, var(--ok) 35%, transparent); } .compaction-indicator--fallback { - color: #d97706; + color: var(--warn); border-color: rgba(217, 119, 6, 0.35); } .compaction-indicator--fallback-cleared { color: var(--ok); - border-color: rgba(34, 197, 94, 0.35); + border-color: color-mix(in srgb, var(--ok) 35%, transparent); } @keyframes compaction-spin { @@ -674,13 +787,6 @@ max-width: 100%; } -:root[data-theme="light"] .code-block, -:root[data-theme="light"] .list-item, -:root[data-theme="light"] .table-row, -:root[data-theme="light"] .chip { - background: var(--bg); -} - /* =========================================== Lists =========================================== */ @@ -691,16 +797,24 @@ container-type: inline-size; } +.list-scroll { + max-height: 400px; + overflow-y: auto; +} + .list-item { display: grid; grid-template-columns: minmax(0, 1fr) minmax(200px, 260px); gap: 16px; align-items: start; - border: 1px solid var(--border); + border: 1px solid color-mix(in srgb, var(--border) 92%, transparent); border-radius: var(--radius-md); padding: 12px; - background: var(--card); - transition: border-color var(--duration-fast) ease; + background: color-mix(in srgb, var(--card) 97%, transparent); + transition: + border-color var(--duration-fast) ease, + box-shadow var(--duration-fast) ease, + background var(--duration-fast) ease; } .list-item-clickable { @@ -709,11 +823,14 @@ .list-item-clickable:hover { border-color: var(--border-strong); + background: color-mix(in srgb, var(--card) 80%, var(--bg-hover)); + box-shadow: var(--shadow-sm); } .list-item-selected { - border-color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 35%, transparent); box-shadow: var(--focus-ring); + background: color-mix(in srgb, var(--accent-subtle) 45%, var(--card)); } .list-main { @@ -728,7 +845,9 @@ .list-sub { color: var(--muted); - font-size: 12px; + font-size: 13px; + overflow-wrap: anywhere; + word-break: break-word; } .list-meta { @@ -760,7 +879,7 @@ .cron-job .list-title { font-weight: 600; - font-size: 15px; + font-size: 16px; letter-spacing: -0.015em; } @@ -800,6 +919,7 @@ display: grid; gap: 3px; margin-top: 2px; + min-width: 0; } .cron-job-detail-label { @@ -813,6 +933,9 @@ .cron-job-detail-value { font-size: 13px; line-height: 1.35; + overflow-wrap: anywhere; + word-break: break-word; + min-width: 0; } .cron-job-state { @@ -852,7 +975,7 @@ .cron-job-status-ok { color: var(--ok); - border-color: rgba(34, 197, 94, 0.35); + border-color: color-mix(in srgb, var(--ok) 35%, transparent); background: var(--ok-subtle); } @@ -921,13 +1044,13 @@ } .chip { - font-size: 12px; + font-size: 13px; font-weight: 500; - border: 1px solid var(--border); + border: 1px solid color-mix(in srgb, var(--border) 85%, transparent); border-radius: var(--radius-full); padding: 5px 12px; color: var(--muted); - background: var(--secondary); + background: color-mix(in srgb, var(--secondary) 92%, transparent); transition: border-color var(--duration-fast) var(--ease-out), background var(--duration-fast) var(--ease-out), @@ -936,6 +1059,7 @@ .chip:hover { border-color: var(--border-strong); + background: var(--bg-hover); transform: translateY(-1px); } @@ -957,7 +1081,7 @@ .chip-danger { color: var(--danger); - border-color: rgba(239, 68, 68, 0.3); + border-color: color-mix(in srgb, var(--danger) 30%, transparent); background: var(--danger-subtle); } @@ -967,7 +1091,7 @@ .table { display: grid; - gap: 6px; + gap: 8px; } .table-head, @@ -979,22 +1103,32 @@ } .table-head { - font-size: 12px; + font-size: 13px; font-weight: 500; color: var(--muted); padding: 0 12px; } .table-row { - border: 1px solid var(--border); - padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--border) 92%, transparent); + padding: 12px 14px; border-radius: var(--radius-md); - background: var(--card); - transition: border-color var(--duration-fast) ease; + background: color-mix(in srgb, var(--card) 97%, transparent); + transition: + border-color var(--duration-fast) ease, + box-shadow var(--duration-fast) ease, + background var(--duration-fast) ease; } .table-row:hover { border-color: var(--border-strong); + background: color-mix(in srgb, var(--card) 82%, var(--bg-hover)); + box-shadow: var(--shadow-sm); +} + +.table-row:focus-within { + border-color: var(--ring); + box-shadow: var(--focus-ring); } .session-link { @@ -1028,12 +1162,13 @@ =========================================== */ .log-stream { - border: 1px solid var(--border); + border: 1px solid color-mix(in srgb, var(--border) 92%, transparent); border-radius: var(--radius-md); - background: var(--card); + background: color-mix(in srgb, var(--card) 98%, transparent); max-height: 500px; overflow: auto; container-type: inline-size; + box-shadow: inset 0 1px 0 var(--card-highlight); } .log-row { @@ -1041,9 +1176,9 @@ grid-template-columns: 90px 70px minmax(140px, 200px) minmax(0, 1fr); gap: 12px; align-items: start; - padding: 8px 12px; - border-bottom: 1px solid var(--border); - font-size: 12px; + padding: 9px 12px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 90%, transparent); + font-size: 13px; transition: background var(--duration-fast) ease; } @@ -1245,7 +1380,7 @@ .chat-new-messages { align-self: center; margin: 8px auto 0; - border-radius: 999px; + border-radius: var(--radius-full); padding: 6px 12px; font-size: 12px; line-height: 1; @@ -1284,31 +1419,16 @@ min-width: 0; } -:root[data-theme="light"] .chat-bubble { - border-color: var(--border); - background: var(--bg); -} - .chat-line.user .chat-bubble { border-color: transparent; background: var(--accent-subtle); } -:root[data-theme="light"] .chat-line.user .chat-bubble { - border-color: rgba(234, 88, 12, 0.2); - background: rgba(251, 146, 60, 0.12); -} - .chat-line.assistant .chat-bubble { border-color: transparent; background: var(--secondary); } -:root[data-theme="light"] .chat-line.assistant .chat-bubble { - border-color: var(--border); - background: var(--bg-muted); -} - @keyframes chatStreamPulse { 0%, 100% { @@ -1439,10 +1559,6 @@ background: var(--secondary); } -:root[data-theme="light"] .chat-text :where(:not(pre) > code) { - background: var(--bg-muted); -} - .chat-text :where(pre) { margin-top: 0.75em; padding: 10px 12px; @@ -1452,10 +1568,6 @@ overflow: auto; } -:root[data-theme="light"] .chat-text :where(pre) { - background: var(--bg-muted); -} - .chat-text :where(pre code) { font-size: 12px; white-space: pre; @@ -1492,10 +1604,6 @@ gap: 4px; } -:root[data-theme="light"] .chat-tool-card { - background: var(--bg-muted); -} - .chat-tool-card__title { font-family: var(--mono); font-size: 12px; @@ -1550,12 +1658,8 @@ background: var(--card); } -:root[data-theme="light"] .chat-tool-card__output { - background: var(--bg); -} - .chat-stamp { - font-size: 11px; + font-size: 12px; color: var(--muted); } @@ -1685,7 +1789,7 @@ } .exec-approval-title { - font-size: 14px; + font-size: 15px; font-weight: 600; } @@ -1762,6 +1866,8 @@ display: grid; gap: 12px; align-self: start; + position: sticky; + top: 16px; } .agents-main { @@ -1802,7 +1908,7 @@ width: 32px; height: 32px; border-radius: 50%; - background: var(--secondary); + background: hsl(var(--agent-hue, 220) 30% 18%); display: grid; place-items: center; font-weight: 600; @@ -1890,16 +1996,27 @@ color: white; } +.agent-tab-count { + font-weight: 400; + font-size: 11px; + opacity: 0.7; + margin-left: 4px; +} + .agents-overview-grid { display: grid; - gap: 14px; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); } .agent-kv { display: grid; gap: 6px; min-width: 0; + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 12px; + background: var(--bg-elevated); } .agent-kv > div { @@ -1915,6 +2032,15 @@ .agent-model-select { display: grid; gap: 12px; + margin-top: 12px; +} + +.agent-model-actions { + display: flex; + gap: 8px; + align-items: flex-end; + flex-shrink: 0; + padding-bottom: 2px; } .agent-model-meta { @@ -2149,3 +2275,794 @@ grid-template-columns: 1fr; } } + +.agent-identity-card { + display: flex; + gap: 12px; + align-items: center; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg-elevated); +} + +.agent-identity-card .agent-avatar { + width: 56px; + height: 56px; + font-size: 24px; + flex-shrink: 0; +} + +.agent-identity-details { + display: grid; + gap: 4px; + min-width: 0; +} + +.agent-identity-name { + font-weight: 700; + font-size: 16px; +} + +.agent-identity-meta { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + color: var(--muted); + font-size: 13px; +} + +.agent-chip-input { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--card); + padding: 6px 8px; + min-height: 38px; + cursor: text; + transition: border-color var(--duration-fast) ease; +} + +.agent-chip-input:focus-within { + border-color: var(--accent); +} + +.agent-chip-input .chip { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.agent-chip-input .chip-remove { + cursor: pointer; + opacity: 0.6; + font-size: 14px; + line-height: 1; + padding: 0 2px; + background: none; + border: none; + color: inherit; +} + +.agent-chip-input .chip-remove:hover { + opacity: 1; +} + +.agent-chip-input input { + border: none; + background: transparent; + color: inherit; + font: inherit; + font-size: 13px; + outline: none; + padding: 2px 0; + flex: 1; + min-width: 120px; +} + +.agent-actions-wrap { + position: relative; +} + +.agent-actions-toggle { + background: var(--secondary); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 6px 10px; + cursor: pointer; + font-size: 16px; + line-height: 1; + color: var(--muted); + transition: border-color var(--duration-fast) ease; +} + +.agent-actions-toggle:hover { + border-color: var(--border-strong); + color: var(--vscode-text); +} + +.agent-actions-menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 50; + min-width: 180px; + background: var(--glass-bg-elevated); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--glass-shadow-md); + padding: 4px; + display: grid; + gap: 2px; +} + +.agent-actions-menu button { + display: block; + width: 100%; + text-align: left; + padding: 8px 12px; + border: none; + background: transparent; + color: var(--vscode-text); + font-size: 13px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background var(--duration-fast) ease; +} + +.agent-actions-menu button:hover { + background: var(--vscode-hover); +} + +.agent-actions-menu button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.agent-actions-menu button:disabled:hover { + background: transparent; +} + +.workspace-link { + background: none; + border: none; + color: var(--accent); + cursor: pointer; + font: inherit; + padding: 0; + text-decoration: underline; + text-decoration-style: dotted; + text-underline-offset: 3px; +} + +.workspace-link:hover { + text-decoration-style: solid; +} + +/* =========================================== + Overview Dashboard Cards + =========================================== */ + +.ov-cards { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + margin-top: 18px; +} + +.ov-stat-card { + --ov-accent: var(--muted); + display: grid; + gap: 0; + padding: 0; + overflow: hidden; + border-top: 2px solid var(--ov-accent); + position: relative; +} + +.ov-stat-card.clickable { + cursor: pointer; + transition: + border-color 0.15s ease, + transform 0.15s ease, + box-shadow 0.15s ease; +} + +.ov-stat-card.clickable:hover { + border-color: var(--accent); + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25); +} + +.ov-stat-card[data-kind="cost"] { + --ov-accent: var(--kn-bioluminescence); +} + +.ov-stat-card[data-kind="sessions"] { + --ov-accent: var(--kn-silver); +} + +.ov-stat-card[data-kind="skills"] { + --ov-accent: var(--kn-claw-ember); +} + +.ov-stat-card[data-kind="cron"] { + --ov-accent: var(--vscode-accent); +} + +.ov-stat-card__inner { + display: flex; + gap: 12px; + align-items: flex-start; + padding: 14px 16px; +} + +.ov-stat-card__icon { + flex-shrink: 0; + width: 20px; + height: 20px; + color: var(--ov-accent); + opacity: 0.8; + margin-top: 1px; +} + +.ov-stat-card__icon svg { + width: 100%; + height: 100%; +} + +.ov-stat-card__body { + min-width: 0; + flex: 1; +} + +.ov-stat-card__body .stat-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + margin-bottom: 6px; + font-weight: 600; +} + +.ov-stat-card__body .stat-value { + font-size: 22px; + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.1; +} + +.ov-stat-card__body .muted { + font-size: 12px; + margin-top: 6px; + line-height: 1.4; +} + +.redacted { + filter: blur(5px); + user-select: none; + pointer-events: none; + transition: filter var(--duration-normal, 250ms) ease; +} + +/* Recent sessions */ + +.ov-recent-sessions { + margin-top: 14px; +} + +.ov-session-list { + margin-top: 10px; +} + +.ov-session-row { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 0; + border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent); + font-size: 13px; + transition: opacity 0.1s ease; +} + +.ov-session-row:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.ov-session-row:first-child { + padding-top: 0; +} + +.ov-session-key { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; +} + +.ov-session-key .blur-digits { + filter: blur(5px); + transition: filter 200ms ease-out; + user-select: none; +} + +.ov-session-row:hover .blur-digits { + filter: none; +} + +/* =========================================== + Attention Center + =========================================== */ + +.ov-attention { + margin-top: 18px; +} + +.ov-attention-list { + margin-top: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.ov-attention-item { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + border-radius: var(--radius); + border: 1px solid var(--border); + font-size: 13px; +} + +.ov-attention-item.danger { + border-color: var(--danger); + background: var(--danger-subtle); +} + +.ov-attention-item.warn { + border-color: var(--warn, #d97706); + background: color-mix(in srgb, var(--warn, #d97706) 8%, transparent); +} + +.ov-attention-icon { + flex-shrink: 0; + width: 16px; + height: 16px; + margin-top: 1px; +} + +.ov-attention-icon svg { + width: 100%; + height: 100%; +} + +.ov-attention-body { + flex: 1; + min-width: 0; +} + +.ov-attention-title { + font-weight: 600; + margin-bottom: 2px; +} + +.ov-attention-link { + flex-shrink: 0; + font-size: 12px; + color: var(--accent); + text-decoration: none; + align-self: center; +} + +.ov-attention-link:hover { + text-decoration: underline; +} + +/* =========================================== + Debug Event Log + =========================================== */ + +.debug-event-log-scroll { + margin-top: 12px; + max-height: 480px; + overflow-y: auto; +} + +.debug-event-entry { + border-bottom: 1px solid var(--border); +} + +.debug-event-entry:last-child { + border-bottom: none; +} + +.debug-event-summary { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 0; + cursor: pointer; + font-family: var(--mono); + font-size: 13px; + list-style: none; +} + +.debug-event-summary::-webkit-details-marker { + display: none; +} + +.debug-event-summary::before { + content: "▸"; + flex-shrink: 0; + width: 12px; + color: var(--muted); + transition: transform 0.15s ease; +} + +.debug-event-entry[open] > .debug-event-summary::before { + transform: rotate(90deg); +} + +.debug-event-name { + font-weight: 600; +} + +.debug-event-ts { + margin-left: auto; + flex-shrink: 0; + font-size: 12px; +} + +.debug-event-payload { + margin: 0 0 8px 22px; + max-height: 300px; + overflow-y: auto; +} + +/* =========================================== + Overview Event Log + =========================================== */ + +.ov-event-log { + margin-top: 0; +} + +.ov-expandable-toggle { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 14px; + font-weight: 600; + list-style: none; + padding: 0; +} + +.ov-expandable-toggle::-webkit-details-marker { + display: none; +} + +.ov-expandable-toggle .nav-item__icon { + width: 16px; + height: 16px; +} + +.ov-expandable-toggle .nav-item__icon svg { + width: 100%; + height: 100%; +} + +.ov-count-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 10px; + background: var(--border); + color: var(--muted); + font-size: 11px; + font-weight: 600; +} + +.ov-event-log-list { + margin-top: 12px; + max-height: 300px; + overflow-y: auto; +} + +.ov-event-log-entry { + display: flex; + align-items: baseline; + gap: 8px; + padding: 4px 0; + border-bottom: 1px solid var(--border); + font-size: 12px; + font-family: var(--mono); +} + +.ov-event-log-entry:last-child { + border-bottom: none; +} + +.ov-event-log-ts { + flex-shrink: 0; + color: var(--muted); + width: 70px; +} + +.ov-event-log-name { + font-weight: 600; + min-width: 100px; +} + +.ov-event-log-payload { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* =========================================== + Overview Log Tail + =========================================== */ + +.ov-log-tail { + margin-top: 0; +} + +.ov-log-refresh { + margin-left: auto; + cursor: pointer; + width: 14px; + height: 14px; + color: var(--muted); +} + +.ov-log-refresh svg { + width: 100%; + height: 100%; +} + +.ov-log-refresh:hover { + color: var(--fg); +} + +.ov-log-tail-content { + margin-top: 10px; + max-height: 220px; + overflow: auto; + font-family: var(--mono); + font-size: 11px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + background: var(--bg-inset, var(--bg)); + padding: 10px; + border-radius: var(--radius); + border: 1px solid var(--border); +} + +/* =========================================== + Overview Quick Actions + =========================================== */ + +.ov-quick-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 18px; +} + +.ov-quick-action-btn { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; +} + +.ov-quick-action-btn .nav-item__icon { + width: 14px; + height: 14px; +} + +.ov-quick-action-btn .nav-item__icon svg { + width: 100%; + height: 100%; +} + +/* =========================================== + Stream Mode Banner + =========================================== */ + +.ov-stream-banner { + display: flex; + align-items: center; + gap: 8px; +} + +.ov-stream-banner .nav-item__icon { + width: 14px; + height: 14px; +} + +.ov-stream-banner .nav-item__icon svg { + width: 100%; + height: 100%; +} + +/* =========================================== + Overview Bottom Grid + =========================================== */ + +.ov-bottom-grid { + display: grid; + grid-template-columns: 1fr; + gap: 14px; + max-height: 420px; + overflow-y: auto; +} + +@media (max-width: 768px) { + .ov-bottom-grid { + grid-template-columns: 1fr; + } + + .ov-cards { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 480px) { + .ov-cards { + grid-template-columns: 1fr; + } +} + +/* =========================================== + Command Palette + =========================================== */ + +.cmd-palette-overlay { + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: min(20vh, 160px); + animation: fade-in 0.12s ease-out; +} + +.cmd-palette { + width: min(560px, 90vw); + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + overflow: hidden; + animation: scale-in 0.15s ease-out; +} + +.cmd-palette__input { + width: 100%; + padding: 14px 18px; + background: transparent; + border: none; + border-bottom: 1px solid var(--border); + font-size: 15px; + color: var(--fg); + outline: none; +} + +.cmd-palette__input::placeholder { + color: var(--muted); +} + +.cmd-palette__results { + max-height: 320px; + overflow-y: auto; + padding: 6px 0; +} + +.cmd-palette__group-label { + padding: 8px 18px 4px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); + font-weight: 600; +} + +.cmd-palette__item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 18px; + cursor: pointer; + font-size: 14px; + transition: background 0.1s; +} + +.cmd-palette__item:hover, +.cmd-palette__item--active { + background: var(--hover); +} + +.cmd-palette__item .nav-item__icon { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.cmd-palette__item .nav-item__icon svg { + width: 100%; + height: 100%; +} + +.cmd-palette__item-desc { + margin-left: auto; + font-size: 12px; +} + +/* =========================================== + Bottom Tabs (Mobile Navigation) + =========================================== */ + +.bottom-tabs { + display: none; + border-top: 1px solid var(--border); + background: var(--card); + padding: 4px 0; +} + +.bottom-tab { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + flex: 1; + padding: 6px 4px; + border: none; + background: none; + color: var(--muted); + cursor: pointer; + font-size: 10px; + transition: color 0.15s; +} + +.bottom-tab--active { + color: var(--accent); +} + +.bottom-tab__icon { + width: 20px; + height: 20px; +} + +.bottom-tab__icon svg { + width: 100%; + height: 100%; +} + +.bottom-tab__label { + font-weight: 500; +} + +@media (max-width: 768px) { + .bottom-tabs { + display: flex; + } +} diff --git a/ui/src/styles/config.css b/ui/src/styles/config.css index ec4003a12..c68908a42 100644 --- a/ui/src/styles/config.css +++ b/ui/src/styles/config.css @@ -8,7 +8,7 @@ grid-template-columns: 260px minmax(0, 1fr); gap: 0; height: calc(100vh - 160px); - margin: -16px; + margin: 0 -16px -16px; border-radius: var(--radius-xl); border: 1px solid var(--border); background: var(--panel); @@ -27,10 +27,6 @@ overflow: hidden; } -:root[data-theme="light"] .config-sidebar { - background: var(--bg-hover); -} - .config-sidebar__header { display: flex; align-items: center; @@ -41,7 +37,7 @@ .config-sidebar__title { font-weight: 600; - font-size: 14px; + font-size: 15px; letter-spacing: -0.01em; } @@ -75,7 +71,7 @@ border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--bg-elevated); - font-size: 13px; + font-size: 14px; outline: none; transition: border-color var(--duration-fast) ease, @@ -93,14 +89,6 @@ background: var(--bg-hover); } -:root[data-theme="light"] .config-search__input { - background: white; -} - -:root[data-theme="light"] .config-search__input:focus { - background: white; -} - .config-search__clear { position: absolute; right: 22px; @@ -145,7 +133,7 @@ border-radius: var(--radius-md); background: transparent; color: var(--muted); - font-size: 13px; + font-size: 14px; font-weight: 500; text-align: left; cursor: pointer; @@ -159,10 +147,6 @@ color: var(--text); } -:root[data-theme="light"] .config-nav__item:hover { - background: rgba(0, 0, 0, 0.04); -} - .config-nav__item.active { background: var(--accent-subtle); color: var(--accent); @@ -206,10 +190,6 @@ border: 1px solid var(--border); } -:root[data-theme="light"] .config-mode-toggle { - background: white; -} - .config-mode-toggle__btn { flex: 1; padding: 9px 14px; @@ -260,10 +240,6 @@ border-bottom: 1px solid var(--border); } -:root[data-theme="light"] .config-actions { - background: var(--bg-hover); -} - .config-actions__left, .config-actions__right { display: flex; @@ -275,7 +251,7 @@ padding: 6px 14px; border-radius: var(--radius-full); background: var(--accent-subtle); - border: 1px solid rgba(255, 77, 77, 0.3); + border: 1px solid color-mix(in srgb, var(--danger) 30%, transparent); color: var(--accent); font-size: 12px; font-weight: 600; @@ -289,7 +265,7 @@ /* Diff Panel */ .config-diff { margin: 18px 22px 0; - border: 1px solid rgba(255, 77, 77, 0.25); + border: 1px solid color-mix(in srgb, var(--danger) 25%, transparent); border-radius: var(--radius-lg); background: var(--accent-subtle); overflow: hidden; @@ -343,10 +319,6 @@ font-family: var(--mono); } -:root[data-theme="light"] .config-diff__item { - background: white; -} - .config-diff__path { font-weight: 600; color: var(--text); @@ -384,10 +356,6 @@ background: var(--bg-accent); } -:root[data-theme="light"] .config-section-hero { - background: var(--bg-hover); -} - .config-section-hero__icon { width: 30px; height: 30px; @@ -411,7 +379,7 @@ } .config-section-hero__title { - font-size: 16px; + font-size: 17px; font-weight: 600; letter-spacing: -0.01em; white-space: nowrap; @@ -420,7 +388,7 @@ } .config-section-hero__desc { - font-size: 13px; + font-size: 14px; color: var(--muted); } @@ -434,10 +402,6 @@ overflow-x: auto; } -:root[data-theme="light"] .config-subnav { - background: var(--bg-hover); -} - .config-subnav__item { border: 1px solid transparent; border-radius: var(--radius-full); @@ -454,10 +418,6 @@ white-space: nowrap; } -:root[data-theme="light"] .config-subnav__item { - background: white; -} - .config-subnav__item:hover { color: var(--text); border-color: var(--border); @@ -551,10 +511,6 @@ border-color: var(--border-strong); } -:root[data-theme="light"] .config-section-card { - background: white; -} - .config-section-card__header { display: flex; align-items: flex-start; @@ -564,10 +520,6 @@ border-bottom: 1px solid var(--border); } -:root[data-theme="light"] .config-section-card__header { - background: var(--bg-hover); -} - .config-section-card__icon { width: 34px; height: 34px; @@ -587,7 +539,7 @@ .config-section-card__title { margin: 0; - font-size: 17px; + font-size: 18px; font-weight: 600; letter-spacing: -0.01em; white-space: nowrap; @@ -597,7 +549,7 @@ .config-section-card__desc { margin: 5px 0 0; - font-size: 13px; + font-size: 14px; color: var(--muted); line-height: 1.45; } @@ -624,23 +576,23 @@ padding: 14px; border-radius: var(--radius-md); background: var(--danger-subtle); - border: 1px solid rgba(239, 68, 68, 0.3); + border: 1px solid color-mix(in srgb, var(--danger) 30%, transparent); } .cfg-field__label { - font-size: 13px; + font-size: 14px; font-weight: 600; color: var(--text); } .cfg-field__help { - font-size: 12px; + font-size: 13px; color: var(--muted); line-height: 1.45; } .cfg-field__error { - font-size: 12px; + font-size: 13px; color: var(--danger); } @@ -675,14 +627,6 @@ background: var(--bg-hover); } -:root[data-theme="light"] .cfg-input { - background: white; -} - -:root[data-theme="light"] .cfg-input:focus { - background: white; -} - .cfg-input--sm { padding: 9px 12px; font-size: 13px; @@ -733,10 +677,6 @@ box-shadow: var(--focus-ring); } -:root[data-theme="light"] .cfg-textarea { - background: white; -} - .cfg-textarea--sm { padding: 10px 12px; font-size: 12px; @@ -751,10 +691,6 @@ background: var(--bg-accent); } -:root[data-theme="light"] .cfg-number { - background: white; -} - .cfg-number__btn { width: 44px; border: none; @@ -775,14 +711,6 @@ cursor: not-allowed; } -:root[data-theme="light"] .cfg-number__btn { - background: var(--bg-hover); -} - -:root[data-theme="light"] .cfg-number__btn:hover:not(:disabled) { - background: var(--border); -} - .cfg-number__input { width: 85px; padding: 11px; @@ -825,10 +753,6 @@ box-shadow: var(--focus-ring); } -:root[data-theme="light"] .cfg-select { - background-color: white; -} - /* Segmented Control */ .cfg-segmented { display: inline-flex; @@ -838,17 +762,13 @@ background: var(--bg-accent); } -:root[data-theme="light"] .cfg-segmented { - background: var(--bg-hover); -} - .cfg-segmented__btn { padding: 9px 18px; border: none; border-radius: var(--radius-sm); background: transparent; color: var(--muted); - font-size: 13px; + font-size: 14px; font-weight: 500; cursor: pointer; transition: @@ -898,14 +818,6 @@ cursor: not-allowed; } -:root[data-theme="light"] .cfg-toggle-row { - background: white; -} - -:root[data-theme="light"] .cfg-toggle-row:hover:not(.disabled) { - background: var(--bg-hover); -} - .cfg-toggle-row__content { flex: 1; min-width: 0; @@ -913,7 +825,7 @@ .cfg-toggle-row__label { display: block; - font-size: 14px; + font-size: 15px; font-weight: 500; color: var(--text); } @@ -921,7 +833,7 @@ .cfg-toggle-row__help { display: block; margin-top: 3px; - font-size: 12px; + font-size: 13px; color: var(--muted); line-height: 1.45; } @@ -952,10 +864,6 @@ border-color var(--duration-normal) ease; } -:root[data-theme="light"] .cfg-toggle__track { - background: var(--border); -} - .cfg-toggle__track::after { content: ""; position: absolute; @@ -973,7 +881,7 @@ .cfg-toggle input:checked + .cfg-toggle__track { background: var(--ok-subtle); - border-color: rgba(34, 197, 94, 0.4); + border-color: color-mix(in srgb, var(--ok) 40%, transparent); } .cfg-toggle input:checked + .cfg-toggle__track::after { @@ -993,10 +901,6 @@ overflow: hidden; } -:root[data-theme="light"] .cfg-object { - background: white; -} - .cfg-object__header { display: flex; align-items: center; @@ -1066,10 +970,6 @@ border-bottom: 1px solid var(--border); } -:root[data-theme="light"] .cfg-array__header { - background: var(--bg-hover); -} - .cfg-array__label { flex: 1; font-size: 14px; @@ -1085,10 +985,6 @@ border-radius: var(--radius-full); } -:root[data-theme="light"] .cfg-array__count { - background: white; -} - .cfg-array__add { display: inline-flex; align-items: center; @@ -1156,10 +1052,6 @@ border-bottom: 1px solid var(--border); } -:root[data-theme="light"] .cfg-array__item-header { - background: var(--bg-hover); -} - .cfg-array__item-index { font-size: 11px; font-weight: 600; @@ -1220,10 +1112,6 @@ border-bottom: 1px solid var(--border); } -:root[data-theme="light"] .cfg-map__header { - background: var(--bg-hover); -} - .cfg-map__label { font-size: 13px; font-weight: 600; @@ -1320,7 +1208,7 @@ } .pill--ok { - border-color: rgba(34, 197, 94, 0.35); + border-color: color-mix(in srgb, var(--ok) 35%, transparent); color: var(--ok); } @@ -1444,3 +1332,85 @@ min-width: 70px; } } + +/* =========================================== + Environment Values Blur + Peek Toggle + =========================================== */ + +.config-env-values--blurred .cfg-input, +.config-env-values--blurred .cfg-number__input, +.config-env-values--blurred textarea { + color: transparent; + text-shadow: 0 0 8px var(--text); +} + +.config-env-values--blurred .cfg-input::placeholder, +.config-env-values--blurred textarea::placeholder { + text-shadow: none; + color: var(--muted); + opacity: 0.7; +} + +.config-env-values--blurred .cfg-input:focus, +.config-env-values--blurred .cfg-number__input:focus, +.config-env-values--blurred textarea:focus { + color: transparent; + text-shadow: 0 0 8px var(--text); +} + +.config-env-values--visible.config-env-values--blurred .cfg-input, +.config-env-values--visible.config-env-values--blurred .cfg-number__input, +.config-env-values--visible.config-env-values--blurred textarea { + color: var(--text); + text-shadow: none; +} + +.config-env-values--visible.config-env-values--blurred .cfg-input:focus, +.config-env-values--visible.config-env-values--blurred .cfg-number__input:focus, +.config-env-values--visible.config-env-values--blurred textarea:focus { + color: var(--text); + text-shadow: none; +} + +.config-env-peek-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: var(--radius-md); + border: 1px solid var(--border); + background: transparent; + color: var(--muted); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all var(--duration-fast) ease; + flex-shrink: 0; + margin-left: auto; +} + +.config-env-peek-btn:hover { + color: var(--text); + border-color: var(--border-strong); + background: var(--bg-hover); +} + +.config-env-peek-btn--active { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 40%, transparent); + background: color-mix(in srgb, var(--accent) 8%, transparent); +} + +.config-env-peek-btn svg { + flex-shrink: 0; +} + +/* Raw JSON redaction blur */ + +.config-raw-redacted { + color: transparent !important; + text-shadow: 0 0 8px var(--text); + transition: + color var(--duration-normal, 250ms) ease, + text-shadow var(--duration-normal, 250ms) ease; +} diff --git a/ui/src/styles/glass.css b/ui/src/styles/glass.css new file mode 100644 index 000000000..e059a72b6 --- /dev/null +++ b/ui/src/styles/glass.css @@ -0,0 +1,554 @@ +/* ════════════════════════════════════════════════════════ + Glass Component System + Glassmorphism primitives used across dashboard views. + ════════════════════════════════════════════════════════ */ + +/* ─── Animations ─── */ + +@keyframes glass-enter { + from { + opacity: 0; + transform: scale(0.97) translateY(6px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +@keyframes modal-overlay-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes modal-dialog-in { + from { + opacity: 0; + transform: scale(0.95) translateY(12px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +@keyframes glass-dropdown-in { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes ambient-drift { + 0% { + background-position: 0% 0%; + } + 50% { + background-position: 100% 100%; + } + 100% { + background-position: 0% 0%; + } +} + +@keyframes active-breathe { + 0%, + 100% { + opacity: 0.5; + } + 50% { + opacity: 1; + } +} + +@keyframes card-rise { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.glass-animate-in { + animation: glass-enter var(--clay-duration-normal) var(--clay-easing) both; +} + +/* ─── Glass Buttons ─── */ + +.glass-btn-primary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 10px 18px; + border: none; + border-radius: var(--radius-sm); + background: linear-gradient(135deg, var(--kn-claw), var(--kn-claw-deep)); + color: #fff; + font-weight: 600; + font-size: 0.88rem; + cursor: pointer; + position: relative; + overflow: hidden; + transition: + transform 0.15s ease, + box-shadow 0.2s ease, + filter 0.15s ease; +} + +.glass-btn-primary:hover { + transform: translateY(-1px); + filter: brightness(1.1); + box-shadow: 0 4px 16px rgba(202, 58, 41, 0.3); +} + +.glass-btn-primary:active { + transform: translateY(0); +} + +.glass-btn-secondary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 10px 18px; + border: 1px solid var(--glass-border); + border-radius: var(--radius-sm); + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + color: var(--text); + font-weight: 500; + font-size: 0.88rem; + cursor: pointer; + transition: + border-color 0.2s ease, + background 0.15s ease; +} + +.glass-btn-secondary:hover { + border-color: var(--glass-border-hover); + background: var(--bg-hover); +} + +.glass-btn-ocean { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 10px 18px; + border: 1px solid rgba(0, 212, 170, 0.2); + border-radius: var(--radius-sm); + background: rgba(0, 212, 170, 0.08); + color: var(--kn-bioluminescence); + font-weight: 600; + font-size: 0.88rem; + cursor: pointer; + transition: + border-color 0.2s ease, + background 0.15s ease; +} + +.glass-btn-ocean:hover { + border-color: rgba(0, 212, 170, 0.35); + background: rgba(0, 212, 170, 0.14); +} + +/* ─── Glass Input ─── */ + +.glass-input { + width: 100%; + padding: 10px 14px; + border: 1px solid var(--glass-border); + border-radius: var(--radius-sm); + background: var(--glass-bg); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + color: var(--text); + font-size: 0.92rem; + font-family: inherit; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; +} + +.glass-input:focus { + outline: none; + border-color: var(--accent); + border-width: 2px; + box-shadow: 0 0 0 3px var(--accent-subtle); +} + +.glass-input::placeholder { + color: var(--muted); +} + +/* ─── Glass Tabs ─── */ + +.glass-tab { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 6px 14px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--muted); + font-size: 0.82rem; + font-weight: 500; + cursor: pointer; + position: relative; + transition: + color 0.15s ease, + background 0.15s ease; +} + +.glass-tab:hover { + color: var(--text); + background: var(--accent-subtle); +} + +.glass-tab-active { + color: var(--text); + background: var(--accent-subtle); + font-weight: 600; +} + +.glass-tab-active::after { + content: ""; + position: absolute; + bottom: 0; + left: 20%; + width: 60%; + height: 2px; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + border-radius: 1px; +} + +.glass-segmented-control { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 3px; + border: 1px solid var(--glass-border); + border-radius: var(--radius-full); + background: var(--glass-bg); +} + +/* ─── Glass Dialog ─── */ + +.glass-dialog { + background: var(--glass-bg-elevated); + backdrop-filter: blur(40px) saturate(var(--glass-saturate)); + -webkit-backdrop-filter: blur(40px) saturate(var(--glass-saturate)); + border: 1px solid var(--glass-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + position: relative; + overflow: hidden; +} + +/* ─── Glass Select Panel (Dropdown) ─── */ + +.glass-select-panel { + background: var(--glass-bg-elevated); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + border: 1px solid var(--glass-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + animation: glass-dropdown-in 0.15s ease-out both; +} + +/* ─── Glass Overlay (Modal Backdrop) ─── */ + +.glass-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + z-index: 100; + animation: modal-overlay-in 0.25s ease-out both; +} + +/* ─── Glass Depth Layers ─── */ + +.glass-layer-1 { + background: var(--glass-bg); + backdrop-filter: blur(8px) saturate(120%); + -webkit-backdrop-filter: blur(8px) saturate(120%); +} + +.glass-layer-2 { + background: var(--glass-bg-elevated); + backdrop-filter: blur(16px) saturate(140%); + -webkit-backdrop-filter: blur(16px) saturate(140%); +} + +.glass-layer-3 { + background: rgba(0, 0, 0, 0.3); + backdrop-filter: blur(32px) saturate(160%); + -webkit-backdrop-filter: blur(32px) saturate(160%); +} + +/* ─── Glass Card Variants ─── */ + +.glass-card { + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + border: 1px solid var(--glass-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; +} + +.glass-card:hover { + border-color: var(--glass-border-hover); + box-shadow: var(--shadow-md); +} + +.glass-card-active { + border-color: var(--accent); + box-shadow: + 0 0 0 1px var(--accent), + var(--shadow-md); +} + +.glass-card-active-ocean { + border-color: var(--kn-bioluminescence); + box-shadow: + 0 0 0 1px var(--kn-bioluminescence), + var(--shadow-md); +} + +/* ─── Glass Noise Texture ─── */ + +.glass-noise::after { + content: ""; + position: absolute; + inset: 0; + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); + opacity: 0.05; + mix-blend-mode: overlay; + pointer-events: none; + border-radius: inherit; +} + +/* ─── Glass Border Gradient ─── */ + +.glass-border-gradient { + position: relative; +} + +.glass-border-gradient::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + padding: 1px; + background: linear-gradient(135deg, var(--glass-border-hover), transparent 60%); + mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + mask-composite: exclude; + -webkit-mask-composite: xor; + opacity: 0; + transition: opacity 0.2s ease; + pointer-events: none; +} + +.glass-border-gradient:hover::before { + opacity: 1; +} + +/* ─── Ambient Background ─── */ + +.ambient-bg { + position: relative; +} + +.ambient-bg::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: -1; + background: + radial-gradient(ellipse 80% 50% at 20% 80%, var(--kn-claw-dim) 0%, transparent 60%), + radial-gradient(ellipse 60% 40% at 80% 20%, var(--kn-ocean-dim) 0%, transparent 50%); +} + +.ambient-bg::after { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: -1; + background: + radial-gradient(ellipse 50% 30% at 60% 60%, var(--kn-claw-dim) 0%, transparent 50%), + radial-gradient(ellipse 40% 50% at 30% 30%, rgba(0, 212, 170, 0.03) 0%, transparent 50%); + animation: ambient-drift 120s ease-in-out infinite alternate; + background-size: 200% 200%; +} + +/* ─── Typography Utilities ─── */ + +.text-display { + font-weight: 700; + letter-spacing: -0.04em; + line-height: 1.1; +} + +/* ─── Glass Dashboard Card ─── */ + +.glass-dashboard-card { + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + border: 1px solid var(--glass-border); + border-radius: var(--radius-md); + padding: 1.25rem; + overflow: hidden; + position: relative; + box-shadow: var(--shadow-sm), var(--glass-highlight); + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; + min-width: 0; +} + +.glass-dashboard-card::after { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + opacity: 0; + transition: opacity 0.2s ease; +} + +.glass-dashboard-card:hover { + border-color: var(--glass-border-hover); + box-shadow: var(--shadow-md); +} + +.glass-dashboard-card:hover::after { + opacity: 0.6; +} + +/* ─── Card Header Convention ─── */ + +.card-header { + display: flex; + align-items: center; + gap: 0.625rem; + margin-bottom: 0.875rem; + min-height: 28px; +} + +.card-header__prefix { + color: var(--accent); + font-family: var(--mono); + font-size: 0.82rem; + font-weight: 600; + line-height: 1; +} + +.card-header__title { + font-size: 0.9rem; + font-weight: 700; + color: var(--text); + letter-spacing: -0.01em; + margin: 0; +} + +.card-header__actions { + margin-left: auto; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.card-header__link { + font-size: 0.75rem; + color: var(--accent); + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; + white-space: nowrap; +} + +.card-header__link:hover { + text-decoration: underline; +} + +/* ─── Count Badge ─── */ + +.count-badge { + font-size: 0.72rem; + font-family: var(--mono); + font-variant-numeric: tabular-nums; + background: var(--clay-bg-card); + color: var(--muted); + padding: 1px 7px; + border-radius: 9999px; + line-height: 1.4; + white-space: nowrap; +} + +.count-badge--accent { + color: var(--accent); +} + +.count-badge--emerald { + color: var(--success); +} + +.count-badge--amber { + color: var(--warn); +} + +.count-badge--red { + color: var(--danger); +} + +/* ─── Glass Divider ─── */ + +.glass-divider { + height: 1px; + background: var(--clay-border-subtle); + margin: 1.25rem 0; + border: none; +} + +/* ─── Glass Event Row ─── */ + +.glass-event-row { + padding: 6px 8px; + border-radius: var(--clay-radius-sm); + cursor: pointer; + transition: background var(--clay-duration-fast) ease; +} + +.glass-event-row:hover { + background: var(--clay-bg-interactive); +} diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index b939c27c2..340e33850 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -5,8 +5,8 @@ .shell { --shell-pad: 16px; --shell-gap: 16px; - --shell-nav-width: 220px; - --shell-topbar-height: 56px; + --shell-nav-width: 240px; + --shell-topbar-height: 62px; --shell-focus-duration: 200ms; --shell-focus-ease: var(--ease-out); height: 100vh; @@ -14,7 +14,7 @@ grid-template-columns: var(--shell-nav-width) minmax(0, 1fr); grid-template-rows: var(--shell-topbar-height) 1fr; grid-template-areas: - "topbar topbar" + "nav topbar" "nav content"; gap: 0; animation: dashboard-enter 0.4s var(--ease-out); @@ -41,7 +41,7 @@ } .shell--nav-collapsed { - grid-template-columns: 0px minmax(0, 1fr); + grid-template-columns: 60px minmax(0, 1fr); } .shell--chat-focus { @@ -80,139 +80,274 @@ display: flex; justify-content: space-between; align-items: center; - gap: 16px; + gap: 12px; padding: 0 20px; height: var(--shell-topbar-height); - border-bottom: 1px solid var(--border); - background: var(--bg); + background: var(--topbar-bg); + backdrop-filter: saturate(var(--glass-saturate)) blur(var(--glass-blur)); + -webkit-backdrop-filter: saturate(var(--glass-saturate)) blur(var(--glass-blur)); + border-bottom: var(--topbar-border); } -.topbar-left { +/* --- Left: Dashboard Header --- */ + +.dashboard-header { display: flex; align-items: center; - gap: 12px; + gap: 0.5rem; + min-width: 0; } -.topbar .nav-collapse-toggle { - width: 36px; - height: 36px; - margin-bottom: 0; -} - -.topbar .nav-collapse-toggle__icon { - width: 20px; - height: 20px; -} - -.topbar .nav-collapse-toggle__icon svg { - width: 20px; - height: 20px; -} - -/* Brand */ -.brand { +.dashboard-header__breadcrumb { display: flex; align-items: center; - gap: 10px; + gap: 6px; + font-size: 0.82rem; + min-width: 0; } -.brand-logo { - width: 28px; - height: 28px; - flex-shrink: 0; -} - -.brand-logo img { - width: 100%; - height: 100%; - object-fit: contain; -} - -.brand-text { - display: flex; - flex-direction: column; - gap: 1px; -} - -.brand-title { - font-size: 16px; - font-weight: 700; - letter-spacing: -0.03em; - line-height: 1.1; - color: var(--text-strong); -} - -.brand-sub { - font-size: 10px; - font-weight: 500; +.dashboard-header__breadcrumb-link { color: var(--muted); - letter-spacing: 0.05em; - text-transform: uppercase; - line-height: 1; + text-decoration: none; + cursor: pointer; + white-space: nowrap; } -/* Topbar status */ -.topbar-status { +.dashboard-header__breadcrumb-link:hover { + color: var(--text); +} + +.dashboard-header__breadcrumb-sep { + color: var(--muted); + opacity: 0.5; +} + +.dashboard-header__breadcrumb-current { + color: var(--text); + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dashboard-header__actions { + margin-left: auto; display: flex; align-items: center; gap: 8px; } -.topbar-status .pill { - padding: 6px 10px; - gap: 6px; - font-size: 12px; - font-weight: 500; - height: 32px; - box-sizing: border-box; -} +/* --- Center: Search / Command Palette Trigger --- */ -.topbar-status .pill .mono { +.topbar-search { display: flex; align-items: center; - line-height: 1; - margin-top: 0px; + gap: 8px; + padding: 6px 12px; + min-width: 200px; + max-width: 340px; + flex: 1; + height: 34px; + border: 1px solid var(--border); + border-radius: var(--radius-full); + background: color-mix(in srgb, var(--secondary) 60%, transparent); + color: var(--muted); + font-size: 13px; + font-family: var(--font-body); + cursor: pointer; + transition: + border-color 180ms ease, + background 180ms ease, + box-shadow 180ms ease; + -webkit-appearance: none; + appearance: none; } -.topbar-status .statusDot { +.topbar-search:hover { + border-color: var(--border-strong); + background: color-mix(in srgb, var(--secondary) 85%, transparent); +} + +.topbar-search:focus-visible { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-subtle); +} + +.topbar-search__label { + flex: 1; + text-align: left; + pointer-events: none; +} + +.topbar-search__kbd { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 1px 6px; + min-width: 22px; + height: 20px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg) 70%, transparent); + color: var(--muted); + font-size: 11px; + font-family: var(--font-body); + font-weight: 500; + line-height: 1; + pointer-events: none; + flex-shrink: 0; +} + +/* --- Right: Status area --- */ + +.topbar-status { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.topbar-divider { + width: 1px; + height: 20px; + background: var(--border); + flex-shrink: 0; +} + +/* Connection indicator */ + +.topbar-connection { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: var(--radius-full); + font-size: 12px; + font-weight: 500; + color: var(--danger); + background: var(--danger-subtle); + transition: + color 250ms ease, + background 250ms ease; +} + +.topbar-connection--ok { + color: var(--ok); + background: var(--ok-subtle); +} + +.topbar-connection__dot { width: 6px; height: 6px; + border-radius: var(--radius-full); + background: currentColor; + box-shadow: 0 0 6px currentColor; + flex-shrink: 0; } +.topbar-connection:not(.topbar-connection--ok) .topbar-connection__dot { + animation: pulse-subtle 2s ease-in-out infinite; +} + +.topbar-connection__label { + text-transform: uppercase; + letter-spacing: 0.04em; + line-height: 1; +} + +/* Redact / stream-mode toggle */ + +.topbar-redact { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-width: 28px; + height: 28px; + padding: 0 4px; + border: 1px solid transparent; + border-radius: var(--radius); + background: none; + color: var(--muted); + cursor: pointer; + transition: + color 180ms ease, + background 180ms ease, + border-color 180ms ease; + flex-shrink: 0; +} + +.topbar-redact svg { + width: 14px; + height: 14px; +} + +.topbar-redact:hover { + color: var(--text); + background: color-mix(in srgb, var(--secondary) 80%, transparent); + border-color: var(--border); +} + +.topbar-redact--active { + border-radius: var(--radius-full); + padding: 4px 10px; + color: var(--warn); + background: var(--warn-subtle); +} + +.topbar-redact--active:hover { + color: var(--warn); + background: color-mix(in srgb, var(--warn-subtle) 80%, var(--warn) 10%); +} + +.topbar-redact__label { + font-size: 12px; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; + line-height: 1; + white-space: nowrap; +} + +/* Topbar theme toggle sizing */ + .topbar-status .theme-toggle { - --theme-item: 24px; - --theme-gap: 2px; - --theme-pad: 3px; + height: 30px; } -.topbar-status .theme-icon { - width: 12px; - height: 12px; +.topbar-status .theme-btn svg { + width: 13px; + height: 13px; } /* =========================================== Navigation Sidebar =========================================== */ -.nav { +.sidebar { grid-area: nav; + display: flex; + flex-direction: column; overflow-y: auto; overflow-x: hidden; - padding: 16px 12px; - background: var(--bg); - scrollbar-width: none; /* Firefox */ + scrollbar-width: none; + background: var(--sidebar-bg); + backdrop-filter: saturate(var(--glass-saturate)) blur(var(--glass-blur)); + -webkit-backdrop-filter: saturate(var(--glass-saturate)) blur(var(--glass-blur)); transition: width var(--shell-focus-duration) var(--shell-focus-ease), padding var(--shell-focus-duration) var(--shell-focus-ease), opacity var(--shell-focus-duration) var(--shell-focus-ease); min-height: 0; + border-right: 1px solid var(--glass-border); } -.nav::-webkit-scrollbar { - display: none; /* Chrome/Safari */ +.sidebar::-webkit-scrollbar { + display: none; } -.shell--chat-focus .nav { +.shell--chat-focus .sidebar { width: 0; padding: 0; border-width: 0; @@ -221,51 +356,141 @@ opacity: 0; } -.nav--collapsed { - width: 0; - min-width: 0; - padding: 0; - overflow: hidden; - border: none; - opacity: 0; - pointer-events: none; +.sidebar--collapsed { + align-items: center; } -/* Nav collapse toggle */ -.nav-collapse-toggle { - width: 32px; +.sidebar--collapsed .sidebar-header { + justify-content: center; + padding: 10px 8px; + min-height: 54px; +} + +.sidebar--collapsed .nav-group__items { + padding: 4px 0; + align-items: center; +} + +.sidebar--collapsed .nav-item { + margin: 0; + padding: 10px; + justify-content: center; + width: 44px; + height: 44px; +} + +.sidebar--collapsed .nav-item__icon { + width: 22px; + height: 22px; + opacity: 0.85; +} + +.sidebar--collapsed .nav-item__icon svg { + width: 22px; + height: 22px; + stroke-width: 1.75px; +} + +.sidebar--collapsed .nav-item--active { + border-left: 0; +} + +.sidebar--collapsed .sidebar-footer { + display: flex; + flex-direction: column; + align-items: center; + padding: 8px 0; +} + +.sidebar--collapsed .sidebar-footer .nav-item { + margin: 0; + padding: 10px; + width: 44px; + height: 44px; +} + +/* Sidebar header (brand + collapse) */ +.sidebar-header { + display: flex; + flex-direction: row; + align-items: center; + padding: 10px 8px; + gap: 0; + flex-shrink: 0; + min-height: 54px; +} + +.sidebar-brand { + flex: 2; + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + + max-height: 28px; + + padding-left: 10px; + padding-right: 10px; + + @media (max-width: 1100px) { + padding-left: 0; + padding-right: 0; + } +} + +.sidebar-brand__logo { + width: 28px; + height: 28px; + flex-shrink: 0; + object-fit: contain; +} + +.sidebar-brand__title { + font-size: 15px; + font-weight: 700; + letter-spacing: -0.03em; + line-height: 1.1; + color: var(--text-strong); + white-space: nowrap; +} + +.sidebar-collapse-btn { + flex: 1; height: 32px; + + @media (max-width: 1100px) { + height: 28px; + } + display: flex; align-items: center; justify-content: center; - background: transparent; - border: 1px solid transparent; - border-radius: var(--radius-md); + background: var(--bg); + border: var(--border) 1px solid transparent; + border-radius: var(--radius-sm); cursor: pointer; + color: var(--muted); + flex-shrink: 0; transition: background var(--duration-fast) ease, - border-color var(--duration-fast) ease; - margin-bottom: 16px; + border-color var(--duration-fast) ease, + color var(--duration-fast) ease; } -.nav-collapse-toggle:hover { - background: var(--bg-hover); +.sidebar--collapsed .sidebar-collapse-btn { + flex: none; + width: 100%; +} + +.sidebar-collapse-btn:hover { + background: var(--bg); border-color: var(--border); + color: var(--text); } -.nav-collapse-toggle__icon { - display: flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - color: var(--muted); - transition: color var(--duration-fast) ease; -} - -.nav-collapse-toggle__icon svg { - width: 18px; - height: 18px; +.sidebar-collapse-btn svg { + width: 24px; + height: 24px; stroke: currentColor; fill: none; stroke-width: 1.5px; @@ -273,13 +498,22 @@ stroke-linejoin: round; } -.nav-collapse-toggle:hover .nav-collapse-toggle__icon { - color: var(--text); +/* Sidebar nav section */ +.sidebar-nav { + flex: 1; + padding: 4px 8px; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: none; +} + +.sidebar-nav::-webkit-scrollbar { + display: none; } /* Nav groups */ .nav-group { - margin-bottom: 20px; + margin-bottom: 16px; display: grid; gap: 2px; } @@ -297,16 +531,16 @@ display: none; } -/* Nav label */ -.nav-label { +/* Nav group label */ +.nav-group__label { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; padding: 6px 10px; - font-size: 11px; - font-weight: 500; + font-size: 12px; + font-weight: 600; color: var(--muted); margin-bottom: 4px; background: transparent; @@ -314,37 +548,40 @@ cursor: pointer; text-align: left; border-radius: var(--radius-sm); + text-transform: uppercase; + letter-spacing: 0.04em; transition: color var(--duration-fast) ease, background var(--duration-fast) ease; } -.nav-label:hover { +.nav-group__label:hover { color: var(--text); background: var(--bg-hover); } -.nav-label--static { - cursor: default; -} - -.nav-label--static:hover { - color: var(--muted); - background: transparent; -} - -.nav-label__text { +.nav-group__label-text { flex: 1; } -.nav-label__chevron { - font-size: 10px; +.nav-group__chevron { + display: flex; + align-items: center; + justify-content: center; + width: 12px; + height: 12px; opacity: 0.5; transition: transform var(--duration-fast) ease; } -.nav-group--collapsed .nav-label__chevron { - transform: rotate(-90deg); +.nav-group__chevron svg { + width: 12px; + height: 12px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; } /* Nav items */ @@ -354,7 +591,7 @@ align-items: center; justify-content: flex-start; gap: 10px; - padding: 8px 10px; + padding: 9px 12px; border-radius: var(--radius-md); border: 1px solid transparent; background: transparent; @@ -364,12 +601,13 @@ transition: border-color var(--duration-fast) ease, background var(--duration-fast) ease, - color var(--duration-fast) ease; + color var(--duration-fast) ease, + box-shadow var(--duration-fast) ease; } .nav-item__icon { - width: 16px; - height: 16px; + width: 18px; + height: 18px; display: flex; align-items: center; justify-content: center; @@ -379,8 +617,8 @@ } .nav-item__icon svg { - width: 16px; - height: 16px; + width: 18px; + height: 18px; stroke: currentColor; fill: none; stroke-width: 1.5px; @@ -389,14 +627,32 @@ } .nav-item__text { - font-size: 13px; + font-size: 14px; font-weight: 500; white-space: nowrap; } +.nav-item__external-icon { + display: flex; + align-items: center; + margin-left: auto; + opacity: 0.4; +} + +.nav-item__external-icon svg { + width: 12px; + height: 12px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + .nav-item:hover { color: var(--text); - background: var(--bg-hover); + background: color-mix(in srgb, var(--secondary) 90%, transparent); + border-color: color-mix(in srgb, var(--border) 75%, transparent); text-decoration: none; } @@ -404,23 +660,55 @@ opacity: 1; } -.nav-item.active { +.nav-item--active { color: var(--text-strong); - background: var(--accent-subtle); + background: color-mix(in srgb, var(--accent-subtle) 70%, var(--secondary)); + border-color: color-mix(in srgb, var(--accent) 34%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent); } -.nav-item.active .nav-item__icon { +.nav-item--active .nav-item__icon { opacity: 1; color: var(--accent); } +/* Sidebar footer — aligned with chat compose bar */ +.sidebar-footer { + padding: 14px 8px 6px; + border-top: 1px solid var(--border); + flex-shrink: 0; + margin-top: auto; +} + +.sidebar-version { + display: flex; + align-items: center; + justify-content: center; + padding: 6px 10px; +} + +.sidebar-version__text { + font-size: 12px; + font-weight: 500; + color: var(--muted); + letter-spacing: 0.02em; +} + +.sidebar-version__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--muted); + opacity: 0.4; +} + /* =========================================== Content Area =========================================== */ .content { grid-area: content; - padding: 12px 16px 32px; + padding: 14px 18px 36px; display: block; min-height: 0; overflow-y: auto; @@ -431,14 +719,10 @@ margin-top: 24px; } -:root[data-theme="light"] .content { - background: var(--bg-content); -} - .content--chat { display: flex; flex-direction: column; - gap: 24px; + gap: 16px; overflow: hidden; padding-bottom: 0; } @@ -452,8 +736,8 @@ display: flex; align-items: flex-end; justify-content: space-between; - gap: 16px; - padding: 4px 8px; + gap: 12px; + padding: 2px 0; overflow: hidden; transform-origin: top center; transition: @@ -461,7 +745,7 @@ transform var(--shell-focus-duration) var(--shell-focus-ease), max-height var(--shell-focus-duration) var(--shell-focus-ease), padding var(--shell-focus-duration) var(--shell-focus-ease); - max-height: 80px; + max-height: 64px; } .shell--chat-focus .content-header { @@ -473,24 +757,25 @@ } .page-title { - font-size: 26px; - font-weight: 700; - letter-spacing: -0.035em; - line-height: 1.15; + font-size: 22px; + font-weight: 600; + letter-spacing: -0.03em; + line-height: 1.2; color: var(--text-strong); } .page-sub { color: var(--muted); - font-size: 14px; + font-size: 13px; font-weight: 400; - margin-top: 6px; + margin-top: 2px; letter-spacing: -0.01em; } .page-meta { display: flex; - gap: 8px; + gap: 6px; + align-items: center; } /* Chat view header adjustments */ @@ -498,7 +783,7 @@ flex-direction: row; align-items: center; justify-content: space-between; - gap: 16px; + gap: 12px; } .content--chat .content-header > div:first-child { @@ -577,16 +862,31 @@ "content"; } - .nav { + .sidebar { position: static; max-height: none; display: flex; + flex-direction: row; gap: 6px; overflow-x: auto; border-right: none; border-bottom: 1px solid var(--border); + } + + .sidebar-header { + display: none; + } + + .sidebar-footer { + display: none; + } + + .sidebar-nav { + display: flex; + flex-direction: row; + gap: 6px; padding: 10px 14px; - background: var(--bg); + overflow-x: auto; } .nav-group { @@ -606,8 +906,12 @@ gap: 10px; } + .topbar-search__kbd { + display: none; + } + .topbar-status { - flex-wrap: wrap; + flex-wrap: nowrap; } .table-head, diff --git a/ui/src/styles/layout.mobile.css b/ui/src/styles/layout.mobile.css index 450a83608..19c5c6474 100644 --- a/ui/src/styles/layout.mobile.css +++ b/ui/src/styles/layout.mobile.css @@ -4,7 +4,22 @@ /* Tablet: Horizontal nav */ @media (max-width: 1100px) { - .nav { + .sidebar { + flex-direction: row; + flex-wrap: nowrap; + border-right: none; + border-bottom: 1px solid var(--border); + } + + .sidebar-header { + display: none; + } + + .sidebar-footer { + display: none; + } + + .sidebar-nav { display: flex; flex-direction: row; flex-wrap: nowrap; @@ -15,7 +30,7 @@ scrollbar-width: none; } - .nav::-webkit-scrollbar { + .sidebar-nav::-webkit-scrollbar { display: none; } @@ -27,7 +42,7 @@ display: contents; } - .nav-label { + .nav-group__label { display: none; } @@ -56,53 +71,56 @@ padding: 10px 12px; gap: 8px; flex-direction: row; - flex-wrap: wrap; + flex-wrap: nowrap; justify-content: space-between; align-items: center; } - .brand { - flex: 1; - min-width: 0; - } - - .brand-title { + .sidebar-brand__title { font-size: 14px; } - .brand-sub { + .dashboard-header__breadcrumb-link, + .dashboard-header__breadcrumb-sep { + display: none; + } + + .topbar-search { + min-width: 0; + max-width: none; + flex: 1; + } + + .topbar-search__label { + display: none; + } + + .topbar-search__kbd { + display: none; + } + + .topbar-connection__label { + display: none; + } + + .topbar-divider { display: none; } .topbar-status { gap: 6px; - width: auto; flex-wrap: nowrap; } - .topbar-status .pill { - padding: 4px 8px; - font-size: 11px; - gap: 4px; - } - - .topbar-status .pill .mono { - display: none; - } - - .topbar-status .pill span:nth-child(2) { - display: none; - } - /* Nav */ - .nav { + .sidebar-nav { padding: 8px 10px; gap: 4px; -webkit-overflow-scrolling: touch; scrollbar-width: none; } - .nav::-webkit-scrollbar { + .sidebar-nav::-webkit-scrollbar { display: none; } @@ -110,7 +128,7 @@ display: contents; } - .nav-label { + .nav-group__label { display: none; } @@ -122,8 +140,21 @@ flex-shrink: 0; } - /* Content */ + /* Content — compact header on chat, hide on other tabs */ .content-header { + padding: 0; + max-height: 48px; + } + + .content:not(.content--chat) .content-header { + display: none; + } + + .content--chat .page-title { + font-size: 18px; + } + + .content--chat .page-sub { display: none; } @@ -194,10 +225,29 @@ } /* Chat */ + .chat-agent-bar { + padding: 4px 8px; + gap: 4px; + } + + .chat-agent-bar__name { + font-size: 11px; + } + + .chat-agent-select { + font-size: 11px; + padding: 2px 16px 2px 4px; + } + + .chat-sessions-summary { + padding: 2px 4px; + font-size: 10px; + } + .chat-header { flex-direction: column; align-items: stretch; - gap: 8px; + gap: 6px; } .chat-header__left { @@ -215,40 +265,60 @@ } .chat-thread { - margin-top: 8px; - padding: 12px 8px; + margin-top: 6px; + padding: 10px 6px; } .chat-msg { - max-width: 90%; + max-width: 92%; } .chat-bubble { - padding: 8px 12px; + padding: 6px 10px; border-radius: var(--radius-md); } .chat-compose { - gap: 8px; + gap: 6px; } .chat-compose__field textarea { - min-height: 60px; - padding: 8px 10px; + min-height: 52px; + padding: 6px 10px; border-radius: var(--radius-md); font-size: 14px; } + .agent-chat__input { + margin: 0 8px 10px; + } + + .agent-chat__toolbar { + padding: 4px 8px; + } + + .agent-chat__input-btn, + .agent-chat__toolbar .btn-ghost { + width: 28px; + height: 28px; + } + + .agent-chat__input-btn svg, + .agent-chat__toolbar .btn-ghost svg { + width: 14px; + height: 14px; + } + /* Log stream */ .log-stream { border-radius: var(--radius-md); - max-height: 380px; + max-height: 320px; } .log-row { grid-template-columns: 1fr; - gap: 4px; - padding: 8px; + gap: 2px; + padding: 6px; } .log-time { @@ -264,7 +334,15 @@ } .log-message { - font-size: 12px; + font-size: 11px; + word-break: break-word; + } + + .ov-log-tail-content { + max-height: 200px; + font-size: 10px; + padding: 8px; + line-height: 1.5; } /* Lists */ @@ -288,11 +366,13 @@ font-size: 11px; } - /* Theme toggle */ .theme-toggle { - --theme-item: 24px; - --theme-gap: 2px; - --theme-pad: 3px; + height: 28px; + } + + .theme-btn svg { + width: 12px; + height: 12px; } .theme-icon { @@ -311,11 +391,11 @@ padding: 8px 10px; } - .brand-title { + .sidebar-brand__title { font-size: 13px; } - .nav { + .sidebar-nav { padding: 6px 8px; } @@ -356,15 +436,12 @@ font-size: 11px; } - .topbar-status .pill { + .topbar-connection { padding: 3px 6px; - font-size: 10px; } .theme-toggle { - --theme-item: 22px; - --theme-gap: 2px; - --theme-pad: 2px; + height: 26px; } .theme-icon { diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index 30e4a1203..c0b9b8b04 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -50,7 +50,7 @@ function createHost() { token: "", sessionKey: "main", lastActiveSessionKey: "main", - theme: "system", + theme: "dark", chatFocusMode: false, chatShowThinking: true, splitRatio: 0.6, diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index 4126b5707..340922baf 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -24,6 +24,7 @@ import { parseExecApprovalResolved, removeExecApproval, } from "./controllers/exec-approval.ts"; +import { loadHealthState } from "./controllers/health.ts"; import { loadNodes } from "./controllers/nodes.ts"; import { loadSessions } from "./controllers/sessions.ts"; import type { GatewayEventFrame, GatewayHelloOk } from "./gateway.ts"; @@ -33,7 +34,7 @@ import type { UiSettings } from "./storage.ts"; import type { AgentsListResult, PresenceEntry, - HealthSnapshot, + HealthSummary, StatusSummary, UpdateAvailable, } from "./types.ts"; @@ -55,7 +56,10 @@ type GatewayHost = { agentsLoading: boolean; agentsList: AgentsListResult | null; agentsError: string | null; - debugHealth: HealthSnapshot | null; + healthLoading: boolean; + healthResult: HealthSummary | null; + healthError: string | null; + debugHealth: HealthSummary | null; assistantName: string; assistantAvatar: string | null; assistantAgentId: string | null; @@ -156,6 +160,7 @@ export function connectGateway(host: GatewayHost) { resetToolStream(host as unknown as Parameters[0]); void loadAssistantIdentity(host as unknown as OpenClawApp); void loadAgents(host as unknown as OpenClawApp); + void loadHealthState(host as unknown as OpenClawApp); void loadNodes(host as unknown as OpenClawApp, { quiet: true }); void loadDevices(host as unknown as OpenClawApp, { quiet: true }); void refreshActiveTab(host as unknown as Parameters[0]); @@ -165,6 +170,11 @@ export function connectGateway(host: GatewayHost) { return; } host.connected = false; + // Code 1008 = Policy Violation (auth failure) — show the gateway's reason directly + if (code === 1008) { + host.lastError = reason || "Authentication failed. Check your gateway token."; + return; + } // Code 1012 = Service Restart (expected during config saves, don't show as error) if (code !== 1012) { host.lastError = `disconnected (${code}): ${reason || "no reason"}`; @@ -201,7 +211,7 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) { { ts: Date.now(), event: evt.event, payload: evt.payload }, ...host.eventLogBuffer, ].slice(0, 250); - if (host.tab === "debug") { + if (host.tab === "debug" || host.tab === "overview") { host.eventLog = host.eventLogBuffer; } @@ -293,7 +303,7 @@ export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) { const snapshot = hello.snapshot as | { presence?: PresenceEntry[]; - health?: HealthSnapshot; + health?: HealthSummary; sessionDefaults?: SessionDefaultsSnapshot; updateAvailable?: UpdateAvailable; } @@ -303,6 +313,7 @@ export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) { } if (snapshot?.health) { host.debugHealth = snapshot.health; + host.healthResult = snapshot.health; } if (snapshot?.sessionDefaults) { applySessionDefaults(host, snapshot.sessionDefaults); diff --git a/ui/src/ui/app-lifecycle.ts b/ui/src/ui/app-lifecycle.ts index 414427141..f7d8d5c1e 100644 --- a/ui/src/ui/app-lifecycle.ts +++ b/ui/src/ui/app-lifecycle.ts @@ -10,8 +10,6 @@ import { import { observeTopbar, scheduleChatScroll, scheduleLogsScroll } from "./app-scroll.ts"; import { applySettingsFromUrl, - attachThemeListener, - detachThemeListener, inferBasePath, syncTabWithLocation, syncThemeWithSettings, @@ -38,14 +36,28 @@ type LifecycleHost = { topbarObserver: ResizeObserver | null; }; +function handleCmdK(host: LifecycleHost, e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + (host as unknown as { paletteOpen: boolean }).paletteOpen = !( + host as unknown as { paletteOpen: boolean } + ).paletteOpen; + } +} + export function handleConnected(host: LifecycleHost) { host.basePath = inferBasePath(); void loadControlUiBootstrapConfig(host); applySettingsFromUrl(host as unknown as Parameters[0]); syncTabWithLocation(host as unknown as Parameters[0], true); syncThemeWithSettings(host as unknown as Parameters[0]); - attachThemeListener(host as unknown as Parameters[0]); window.addEventListener("popstate", host.popStateHandler); + (host as unknown as { cmdKHandler: (e: KeyboardEvent) => void }).cmdKHandler = (e) => + handleCmdK(host, e); + window.addEventListener( + "keydown", + (host as unknown as { cmdKHandler: (e: KeyboardEvent) => void }).cmdKHandler, + ); connectGateway(host as unknown as Parameters[0]); startNodesPolling(host as unknown as Parameters[0]); if (host.tab === "logs") { @@ -62,10 +74,13 @@ export function handleFirstUpdated(host: LifecycleHost) { export function handleDisconnected(host: LifecycleHost) { window.removeEventListener("popstate", host.popStateHandler); + const cmdK = (host as unknown as { cmdKHandler?: (e: KeyboardEvent) => void }).cmdKHandler; + if (cmdK) { + window.removeEventListener("keydown", cmdK); + } stopNodesPolling(host as unknown as Parameters[0]); stopLogsPolling(host as unknown as Parameters[0]); stopDebugPolling(host as unknown as Parameters[0]); - detachThemeListener(host as unknown as Parameters[0]); host.topbarObserver?.disconnect(); host.topbarObserver = null; } diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index d95414729..316c7968e 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -1,4 +1,4 @@ -import { html } from "lit"; +import { html, nothing } from "lit"; import { repeat } from "lit/directives/repeat.js"; import { t } from "../i18n/index.ts"; import { refreshChat } from "./app-chat.ts"; @@ -49,10 +49,12 @@ function resetChatStateForSessionSwitch(state: AppViewState, sessionKey: string) export function renderTab(state: AppViewState, tab: Tab) { const href = pathForTab(tab, state.basePath); + const isActive = state.tab === tab; + const collapsed = state.settings.navCollapsed; return html` { if ( event.defaultPrevented || @@ -77,7 +79,7 @@ export function renderTab(state: AppViewState, tab: Tab) { title=${titleForTab(tab)} > - ${titleForTab(tab)} + ${!collapsed ? html`${titleForTab(tab)}` : nothing} `; } @@ -394,10 +396,17 @@ function resolveSessionOptions( return options; } -const THEME_ORDER: ThemeMode[] = ["system", "light", "dark"]; +type ThemeOption = { id: ThemeMode; label: string; iconKey: keyof typeof icons }; +const THEME_OPTIONS: ThemeOption[] = [ + { id: "dark", label: "Dark", iconKey: "monitor" }, + { id: "light", label: "Light", iconKey: "book" }, + { id: "openknot", label: "Knot", iconKey: "zap" }, + { id: "fieldmanual", label: "Field", iconKey: "terminal" }, + { id: "clawdash", label: "Chrome", iconKey: "settings" }, +]; export function renderThemeToggle(state: AppViewState) { - const index = Math.max(0, THEME_ORDER.indexOf(state.theme)); + const app = state as unknown as OpenClawApp; const applyTheme = (next: ThemeMode) => (event: MouseEvent) => { const element = event.currentTarget as HTMLElement; const context: ThemeTransitionContext = { element }; @@ -408,74 +417,34 @@ export function renderThemeToggle(state: AppViewState) { state.setTheme(next, context); }; + const handleCollapse = () => app.handleThemeToggleCollapse(); + return html` -
-
- - - - -
+
{ + const toggle = e.currentTarget as HTMLElement; + requestAnimationFrame(() => { + if (!toggle.contains(document.activeElement)) { + handleCollapse(); + } + }); + }} + > + ${state.themeOrder.map((id) => { + const opt = THEME_OPTIONS.find((o) => o.id === id)!; + return html` + + `; + })}
`; } - -function renderSunIcon() { - return html` - - `; -} - -function renderMoonIcon() { - return html` - - `; -} - -function renderMonitorIcon() { - return html` - - `; -} diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index a87f9a805..3be024788 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -1,5 +1,8 @@ import { html, nothing } from "lit"; -import { parseAgentSessionKey } from "../../../src/routing/session-key.js"; +import { + buildAgentMainSessionKey, + parseAgentSessionKey, +} from "../../../src/routing/session-key.js"; import { t } from "../i18n/index.ts"; import { refreshChatAvatar } from "./app-chat.ts"; import { renderUsageTab } from "./app-render-usage-tab.ts"; @@ -52,17 +55,21 @@ import { updateSkillEdit, updateSkillEnabled, } from "./controllers/skills.ts"; +import "./components/dashboard-header.ts"; import { icons } from "./icons.ts"; import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts"; import { renderAgents } from "./views/agents.ts"; +import { renderBottomTabs } from "./views/bottom-tabs.ts"; import { renderChannels } from "./views/channels.ts"; import { renderChat } from "./views/chat.ts"; +import { renderCommandPalette } from "./views/command-palette.ts"; import { renderConfig } from "./views/config.ts"; import { renderCron } from "./views/cron.ts"; import { renderDebug } from "./views/debug.ts"; import { renderExecApprovalPrompt } from "./views/exec-approval.ts"; import { renderGatewayUrlConfirmation } from "./views/gateway-url-confirmation.ts"; import { renderInstances } from "./views/instances.ts"; +import { renderLoginGate } from "./views/login-gate.ts"; import { renderLogs } from "./views/logs.ts"; import { renderNodes } from "./views/nodes.ts"; import { renderOverview } from "./views/overview.ts"; @@ -89,6 +96,15 @@ function resolveAssistantAvatarUrl(state: AppViewState): string | undefined { } export function renderApp(state: AppViewState) { + // Gate: require successful gateway connection before showing the dashboard. + // The gateway URL confirmation overlay is always rendered so URL-param flows still work. + if (!state.connected) { + return html` + ${renderLoginGate(state)} + ${renderGatewayUrlConfirmation(state)} + `; + } + const presenceCount = state.presenceEntries.length; const sessionsCount = state.sessionsResult?.count ?? null; const cronNext = state.cronStatus?.nextWakeAtMs ?? null; @@ -108,83 +124,172 @@ export function renderApp(state: AppViewState) { null; return html` + ${renderCommandPalette({ + open: state.paletteOpen, + query: (state as unknown as { paletteQuery?: string }).paletteQuery ?? "", + activeIndex: (state as unknown as { paletteActiveIndex?: number }).paletteActiveIndex ?? 0, + onToggle: () => { + state.paletteOpen = !state.paletteOpen; + }, + onQueryChange: (q) => { + (state as unknown as { paletteQuery: string }).paletteQuery = q; + }, + onActiveIndexChange: (i) => { + (state as unknown as { paletteActiveIndex: number }).paletteActiveIndex = i; + }, + onNavigate: (tab) => { + state.setTab(tab as import("./navigation.ts").Tab); + }, + onSlashCommand: (_cmd) => { + state.setTab("chat" as import("./navigation.ts").Tab); + }, + })}
-
- -
- -
-
OPENCLAW
-
Gateway Dashboard
-
-
-
+ +
-
- - ${t("common.health")} - ${state.connected ? t("common.ok") : t("common.offline")} + + +
+ + ${state.connected ? t("common.ok") : t("common.offline")}
+ ${renderThemeToggle(state)}
-
@@ -225,6 +330,15 @@ export function renderApp(state: AppViewState) { cronEnabled: state.cronStatus?.enabled ?? null, cronNext, lastChannelsRefresh: state.channelsLastSuccess, + usageResult: state.usageResult, + sessionsResult: state.sessionsResult, + skillsReport: state.skillsReport, + cronJobs: state.cronJobs, + cronStatus: state.cronStatus, + attentionItems: state.attentionItems, + eventLog: state.eventLog, + overviewLogLines: state.overviewLogLines, + streamMode: state.streamMode, onSettingsChange: (next) => state.applySettings(next), onPasswordChange: (next) => (state.password = next), onSessionKeyChange: (next) => { @@ -240,6 +354,16 @@ export function renderApp(state: AppViewState) { }, onConnect: () => state.connect(), onRefresh: () => state.loadOverview(), + onNavigate: (tab) => state.setTab(tab as import("./navigation.ts").Tab), + onRefreshLogs: () => state.loadOverview(), + onToggleStreamMode: () => { + state.streamMode = !state.streamMode; + try { + localStorage.setItem("openclaw:stream-mode", String(state.streamMode)); + } catch { + /* */ + } + }, }) : nothing } @@ -290,6 +414,7 @@ export function renderApp(state: AppViewState) { entries: state.presenceEntries, lastError: state.presenceError, statusMessage: state.presenceStatus, + streamMode: state.streamMode, onRefresh: () => loadPresence(state), }) : nothing @@ -358,33 +483,47 @@ export function renderApp(state: AppViewState) { agentsList: state.agentsList, selectedAgentId: resolvedAgentId, activePanel: state.agentsPanel, - configForm: configValue, - configLoading: state.configLoading, - configSaving: state.configSaving, - configDirty: state.configFormDirty, - channelsLoading: state.channelsLoading, - channelsError: state.channelsError, - channelsSnapshot: state.channelsSnapshot, - channelsLastSuccess: state.channelsLastSuccess, - cronLoading: state.cronLoading, - cronStatus: state.cronStatus, - cronJobs: state.cronJobs, - cronError: state.cronError, - agentFilesLoading: state.agentFilesLoading, - agentFilesError: state.agentFilesError, - agentFilesList: state.agentFilesList, - agentFileActive: state.agentFileActive, - agentFileContents: state.agentFileContents, - agentFileDrafts: state.agentFileDrafts, - agentFileSaving: state.agentFileSaving, + config: { + form: configValue, + loading: state.configLoading, + saving: state.configSaving, + dirty: state.configFormDirty, + }, + channels: { + snapshot: state.channelsSnapshot, + loading: state.channelsLoading, + error: state.channelsError, + lastSuccess: state.channelsLastSuccess, + }, + cron: { + status: state.cronStatus, + jobs: state.cronJobs, + loading: state.cronLoading, + error: state.cronError, + }, + agentFiles: { + list: state.agentFilesList, + loading: state.agentFilesLoading, + error: state.agentFilesError, + active: state.agentFileActive, + contents: state.agentFileContents, + drafts: state.agentFileDrafts, + saving: state.agentFileSaving, + }, agentIdentityLoading: state.agentIdentityLoading, agentIdentityError: state.agentIdentityError, agentIdentityById: state.agentIdentityById, - agentSkillsLoading: state.agentSkillsLoading, - agentSkillsReport: state.agentSkillsReport, - agentSkillsError: state.agentSkillsError, - agentSkillsAgentId: state.agentSkillsAgentId, - skillsFilter: state.skillsFilter, + agentSkills: { + report: state.agentSkillsReport, + loading: state.agentSkillsLoading, + error: state.agentSkillsError, + agentId: state.agentSkillsAgentId, + filter: state.skillsFilter, + }, + sidebarFilter: state.agentsSidebarFilter, + onSidebarFilterChange: (value) => { + state.agentsSidebarFilter = value; + }, onRefresh: async () => { await loadAgents(state); const agentIds = state.agentsList?.agents?.map((entry) => entry.id) ?? []; @@ -523,6 +662,9 @@ export function renderApp(state: AppViewState) { onConfigSave: () => saveConfig(state), onChannelsRefresh: () => loadChannels(state, false), onCronRefresh: () => state.loadCron(), + onCronRunNow: (_jobId) => { + // Stub: backend support pending + }, onSkillsFilterChange: (next) => (state.skillsFilter = next), onSkillsRefresh: () => { if (resolvedAgentId) { @@ -692,6 +834,12 @@ export function renderApp(state: AppViewState) { : { fallbacks: normalized }; updateConfigFormValue(state, basePath, next); }, + onSetDefault: (agentId) => { + if (!configValue) { + return; + } + updateConfigFormValue(state, ["agents", "defaultId"], agentId); + }, }) : nothing } @@ -860,6 +1008,45 @@ export function renderApp(state: AppViewState) { onAbort: () => void state.handleAbortChat(), onQueueRemove: (id) => state.removeQueuedMessage(id), onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }), + onClearHistory: async () => { + if (!state.client || !state.connected) { + return; + } + try { + await state.client.request("sessions.reset", { key: state.sessionKey }); + state.chatMessages = []; + state.chatStream = null; + state.chatRunId = null; + await loadChatHistory(state); + } catch (err) { + state.lastError = String(err); + } + }, + agentsList: state.agentsList, + currentAgentId: resolvedAgentId ?? "main", + onAgentChange: (agentId: string) => { + state.sessionKey = buildAgentMainSessionKey({ agentId }); + state.chatMessages = []; + state.chatStream = null; + state.chatRunId = null; + state.applySettings({ + ...state.settings, + sessionKey: state.sessionKey, + lastActiveSessionKey: state.sessionKey, + }); + void loadChatHistory(state); + void state.loadAssistantIdentity(); + }, + onNavigateToAgent: () => { + state.agentsSelectedId = resolvedAgentId; + state.setTab("agents" as import("./navigation.ts").Tab); + }, + onSessionSelect: (key: string) => { + state.setSessionKey(key); + state.chatMessages = []; + void loadChatHistory(state); + void state.loadAssistantIdentity(); + }, showNewMessages: state.chatNewMessagesBelow && !state.chatManualRefreshInFlight, onScrollToBottom: () => state.scrollToBottom(), // Sidebar props for tool output viewing @@ -897,6 +1084,7 @@ export function renderApp(state: AppViewState) { searchQuery: state.configSearchQuery, activeSection: state.configActiveSection, activeSubsection: state.configActiveSubsection, + streamMode: state.streamMode, onRawChange: (next) => { state.configRaw = next; }, @@ -962,6 +1150,10 @@ export function renderApp(state: AppViewState) {
${renderExecApprovalPrompt(state)} ${renderGatewayUrlConfirmation(state)} + ${renderBottomTabs({ + activeTab: state.tab, + onTabChange: (tab) => state.setTab(tab), + })}
`; } diff --git a/ui/src/ui/app-settings.test.ts b/ui/src/ui/app-settings.test.ts index 48411bbe5..e1b057913 100644 --- a/ui/src/ui/app-settings.test.ts +++ b/ui/src/ui/app-settings.test.ts @@ -13,14 +13,14 @@ const createHost = (tab: Tab): SettingsHost => ({ token: "", sessionKey: "main", lastActiveSessionKey: "main", - theme: "system", + theme: "dark", chatFocusMode: false, chatShowThinking: true, splitRatio: 0.6, navCollapsed: false, navGroupsCollapsed: {}, }, - theme: "system", + theme: "dark", themeResolved: "dark", applySessionKey: "main", sessionKey: "main", @@ -31,8 +31,6 @@ const createHost = (tab: Tab): SettingsHost => ({ eventLog: [], eventLogBuffer: [], basePath: "", - themeMedia: null, - themeMediaHandler: null, logsPollInterval: null, debugPollInterval: null, }); diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 7415e468e..1d50cd985 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -21,6 +21,7 @@ import { loadNodes } from "./controllers/nodes.ts"; import { loadPresence } from "./controllers/presence.ts"; import { loadSessions } from "./controllers/sessions.ts"; import { loadSkills } from "./controllers/skills.ts"; +import { loadUsage } from "./controllers/usage.ts"; import { inferBasePathFromPathname, normalizeBasePath, @@ -32,7 +33,7 @@ import { import { saveSettings, type UiSettings } from "./storage.ts"; import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition.ts"; import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme.ts"; -import type { AgentsListResult } from "./types.ts"; +import type { AgentsListResult, AttentionItem } from "./types.ts"; type SettingsHost = { settings: UiSettings; @@ -51,8 +52,6 @@ type SettingsHost = { agentsList?: AgentsListResult | null; agentsSelectedId?: string | null; agentsPanel?: "overview" | "files" | "tools" | "skills" | "channels" | "cron"; - themeMedia: MediaQueryList | null; - themeMediaHandler: ((event: MediaQueryListEvent) => void) | null; pendingGatewayUrl?: string | null; }; @@ -259,7 +258,7 @@ export function inferBasePath() { } export function syncThemeWithSettings(host: SettingsHost) { - host.theme = host.settings.theme ?? "system"; + host.theme = host.settings.theme ?? "dark"; applyResolvedTheme(host, resolveTheme(host.theme)); } @@ -270,44 +269,7 @@ export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) } const root = document.documentElement; root.dataset.theme = resolved; - root.style.colorScheme = resolved; -} - -export function attachThemeListener(host: SettingsHost) { - if (typeof window === "undefined" || typeof window.matchMedia !== "function") { - return; - } - host.themeMedia = window.matchMedia("(prefers-color-scheme: dark)"); - host.themeMediaHandler = (event) => { - if (host.theme !== "system") { - return; - } - applyResolvedTheme(host, event.matches ? "dark" : "light"); - }; - if (typeof host.themeMedia.addEventListener === "function") { - host.themeMedia.addEventListener("change", host.themeMediaHandler); - return; - } - const legacy = host.themeMedia as MediaQueryList & { - addListener: (cb: (event: MediaQueryListEvent) => void) => void; - }; - legacy.addListener(host.themeMediaHandler); -} - -export function detachThemeListener(host: SettingsHost) { - if (!host.themeMedia || !host.themeMediaHandler) { - return; - } - if (typeof host.themeMedia.removeEventListener === "function") { - host.themeMedia.removeEventListener("change", host.themeMediaHandler); - return; - } - const legacy = host.themeMedia as MediaQueryList & { - removeListener: (cb: (event: MediaQueryListEvent) => void) => void; - }; - legacy.removeListener(host.themeMediaHandler); - host.themeMedia = null; - host.themeMediaHandler = null; + root.style.colorScheme = "dark"; } export function syncTabWithLocation(host: SettingsHost, replace: boolean) { @@ -403,13 +365,121 @@ export function syncUrlWithSessionKey(host: SettingsHost, sessionKey: string, re } export async function loadOverview(host: SettingsHost) { - await Promise.all([ - loadChannels(host as unknown as OpenClawApp, false), - loadPresence(host as unknown as OpenClawApp), - loadSessions(host as unknown as OpenClawApp), - loadCronStatus(host as unknown as OpenClawApp), - loadDebug(host as unknown as OpenClawApp), + const app = host as unknown as OpenClawApp; + await Promise.allSettled([ + loadChannels(app, false), + loadPresence(app), + loadSessions(app), + loadCronStatus(app), + loadCronJobs(app), + loadDebug(app), + loadSkills(app), + loadUsage(app), + loadOverviewLogs(app), ]); + buildAttentionItems(app); +} + +async function loadOverviewLogs(host: OpenClawApp) { + if (!host.client || !host.connected) { + return; + } + try { + const res = await host.client.request("logs.tail", { + cursor: host.overviewLogCursor || undefined, + limit: 100, + maxBytes: 50_000, + }); + const payload = res as { + cursor?: number; + lines?: unknown; + }; + const lines = Array.isArray(payload.lines) + ? payload.lines.filter((line): line is string => typeof line === "string") + : []; + host.overviewLogLines = [...host.overviewLogLines, ...lines].slice(-500); + if (typeof payload.cursor === "number") { + host.overviewLogCursor = payload.cursor; + } + } catch { + /* non-critical */ + } +} + +function buildAttentionItems(host: OpenClawApp) { + const items: AttentionItem[] = []; + + if (host.lastError) { + items.push({ + severity: "error", + icon: "x", + title: "Gateway Error", + description: host.lastError, + }); + } + + const hello = host.hello; + const auth = (hello as { auth?: { scopes?: string[] } } | null)?.auth; + if (auth?.scopes && !auth.scopes.includes("operator.read")) { + items.push({ + severity: "warning", + icon: "key", + title: "Missing operator.read scope", + description: + "This connection does not have the operator.read scope. Some features may be unavailable.", + href: "https://docs.openclaw.ai/web/dashboard", + external: true, + }); + } + + const skills = host.skillsReport?.skills ?? []; + const missingDeps = skills.filter((s) => !s.disabled && Object.keys(s.missing).length > 0); + if (missingDeps.length > 0) { + const names = missingDeps.slice(0, 3).map((s) => s.name); + const more = missingDeps.length > 3 ? ` +${missingDeps.length - 3} more` : ""; + items.push({ + severity: "warning", + icon: "zap", + title: "Skills with missing dependencies", + description: `${names.join(", ")}${more}`, + }); + } + + const blocked = skills.filter((s) => s.blockedByAllowlist); + if (blocked.length > 0) { + items.push({ + severity: "warning", + icon: "shield", + title: `${blocked.length} skill${blocked.length > 1 ? "s" : ""} blocked`, + description: blocked.map((s) => s.name).join(", "), + }); + } + + const cronJobs = host.cronJobs ?? []; + const failedCron = cronJobs.filter((j) => j.state?.lastStatus === "error"); + if (failedCron.length > 0) { + items.push({ + severity: "error", + icon: "clock", + title: `${failedCron.length} cron job${failedCron.length > 1 ? "s" : ""} failed`, + description: failedCron.map((j) => j.name).join(", "), + }); + } + + const now = Date.now(); + const overdue = cronJobs.filter( + (j) => j.enabled && j.state?.nextRunAtMs != null && now - j.state.nextRunAtMs > 300_000, + ); + if (overdue.length > 0) { + items.push({ + severity: "warning", + icon: "clock", + title: `${overdue.length} overdue job${overdue.length > 1 ? "s" : ""}`, + description: overdue.map((j) => j.name).join(", "), + }); + } + + host.attentionItems = items; } export async function loadChannelsTab(host: SettingsHost) { diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index e7c7735c8..5ee23477b 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -8,20 +8,22 @@ import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts"; import type { Tab } from "./navigation.ts"; import type { UiSettings } from "./storage.ts"; import type { ThemeTransitionContext } from "./theme-transition.ts"; -import type { ThemeMode } from "./theme.ts"; +import type { ResolvedTheme, ThemeMode } from "./theme.ts"; import type { AgentsListResult, AgentsFilesListResult, AgentIdentityResult, + AttentionItem, ChannelsStatusSnapshot, ConfigSnapshot, ConfigUiHints, CronJob, CronRunLogEntry, CronStatus, - HealthSnapshot, + HealthSummary, LogEntry, LogLevel, + ModelCatalogEntry, NostrProfile, PresenceEntry, SessionsUsageResult, @@ -43,7 +45,8 @@ export type AppViewState = { basePath: string; connected: boolean; theme: ThemeMode; - themeResolved: "light" | "dark"; + themeResolved: ResolvedTheme; + themeOrder: ThemeMode[]; hello: GatewayHelloOk | null; lastError: string | null; eventLog: EventLogEntry[]; @@ -143,6 +146,7 @@ export type AppViewState = { agentSkillsError: string | null; agentSkillsReport: SkillStatusReport | null; agentSkillsAgentId: string | null; + agentsSidebarFilter: string; sessionsLoading: boolean; sessionsResult: SessionsListResult | null; sessionsError: string | null; @@ -200,10 +204,13 @@ export type AppViewState = { skillEdits: Record; skillMessages: Record; skillsBusyKey: string | null; + healthLoading: boolean; + healthResult: HealthSummary | null; + healthError: string | null; debugLoading: boolean; debugStatus: StatusSummary | null; - debugHealth: HealthSnapshot | null; - debugModels: unknown[]; + debugHealth: HealthSummary | null; + debugModels: ModelCatalogEntry[]; debugHeartbeat: unknown; debugCallMethod: string; debugCallParams: string; @@ -223,6 +230,12 @@ export type AppViewState = { logsMaxBytes: number; logsAtBottom: boolean; updateAvailable: import("./types.js").UpdateAvailable | null; + // Overview dashboard state + attentionItems: AttentionItem[]; + paletteOpen: boolean; + streamMode: boolean; + overviewLogLines: string[]; + overviewLogCursor: number; client: GatewayBrowserClient | null; refreshSessionsAfterChat: Set; connect: () => void; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index db4b290b1..1c284079c 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -60,7 +60,7 @@ import type { SkillMessage } from "./controllers/skills.ts"; import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts"; import type { Tab } from "./navigation.ts"; import { loadSettings, type UiSettings } from "./storage.ts"; -import type { ResolvedTheme, ThemeMode } from "./theme.ts"; +import { VALID_THEMES, type ResolvedTheme, type ThemeMode } from "./theme.ts"; import type { AgentsListResult, AgentsFilesListResult, @@ -70,9 +70,10 @@ import type { CronJob, CronRunLogEntry, CronStatus, - HealthSnapshot, + HealthSummary, LogEntry, LogLevel, + ModelCatalogEntry, PresenceEntry, ChannelsStatusSnapshot, SessionsListResult, @@ -118,8 +119,9 @@ export class OpenClawApp extends LitElement { @state() tab: Tab = "chat"; @state() onboarding = resolveOnboardingMode(); @state() connected = false; - @state() theme: ThemeMode = this.settings.theme ?? "system"; + @state() theme: ThemeMode = this.settings.theme ?? "dark"; @state() themeResolved: ResolvedTheme = "dark"; + @state() themeOrder: ThemeMode[] = this.buildThemeOrder(this.theme); @state() hello: GatewayHelloOk | null = null; @state() lastError: string | null = null; @state() eventLog: EventLogEntry[] = []; @@ -229,6 +231,7 @@ export class OpenClawApp extends LitElement { @state() agentSkillsError: string | null = null; @state() agentSkillsReport: SkillStatusReport | null = null; @state() agentSkillsAgentId: string | null = null; + @state() agentsSidebarFilter = ""; @state() sessionsLoading = false; @state() sessionsResult: SessionsListResult | null = null; @@ -304,6 +307,23 @@ export class OpenClawApp extends LitElement { @state() updateAvailable: import("./types.js").UpdateAvailable | null = null; + // Overview dashboard state + @state() attentionItems: import("./types.js").AttentionItem[] = []; + @state() paletteOpen = false; + paletteQuery = ""; + paletteActiveIndex = 0; + @state() streamMode = (() => { + try { + const stored = localStorage.getItem("openclaw:stream-mode"); + // Default to true (redacted) unless explicitly disabled + return stored === null ? true : stored === "true"; + } catch { + return true; + } + })(); + @state() overviewLogLines: string[] = []; + @state() overviewLogCursor = 0; + @state() skillsLoading = false; @state() skillsReport: SkillStatusReport | null = null; @state() skillsError: string | null = null; @@ -312,10 +332,14 @@ export class OpenClawApp extends LitElement { @state() skillsBusyKey: string | null = null; @state() skillMessages: Record = {}; + @state() healthLoading = false; + @state() healthResult: HealthSummary | null = null; + @state() healthError: string | null = null; + @state() debugLoading = false; @state() debugStatus: StatusSummary | null = null; - @state() debugHealth: HealthSnapshot | null = null; - @state() debugModels: unknown[] = []; + @state() debugHealth: HealthSummary | null = null; + @state() debugModels: ModelCatalogEntry[] = []; @state() debugHeartbeat: unknown = null; @state() debugCallMethod = ""; @state() debugCallParams = "{}"; @@ -354,8 +378,6 @@ export class OpenClawApp extends LitElement { basePath = ""; private popStateHandler = () => onPopStateInternal(this as unknown as Parameters[0]); - private themeMedia: MediaQueryList | null = null; - private themeMediaHandler: ((event: MediaQueryListEvent) => void) | null = null; private topbarObserver: ResizeObserver | null = null; createRenderRoot() { @@ -433,6 +455,19 @@ export class OpenClawApp extends LitElement { setTheme(next: ThemeMode, context?: Parameters[2]) { setThemeInternal(this as unknown as Parameters[0], next, context); + this.themeOrder = this.buildThemeOrder(next); + } + + buildThemeOrder(active: ThemeMode): ThemeMode[] { + const all = [...VALID_THEMES]; + const rest = all.filter((id) => id !== active); + return [active, ...rest]; + } + + handleThemeToggleCollapse() { + setTimeout(() => { + this.themeOrder = this.buildThemeOrder(this.theme); + }, 80); } async loadOverview() { diff --git a/ui/src/ui/chat/deleted-messages.ts b/ui/src/ui/chat/deleted-messages.ts new file mode 100644 index 000000000..fd3916d78 --- /dev/null +++ b/ui/src/ui/chat/deleted-messages.ts @@ -0,0 +1,49 @@ +const PREFIX = "openclaw:deleted:"; + +export class DeletedMessages { + private key: string; + private _keys = new Set(); + + constructor(sessionKey: string) { + this.key = PREFIX + sessionKey; + this.load(); + } + + has(key: string): boolean { + return this._keys.has(key); + } + + delete(key: string): void { + this._keys.add(key); + this.save(); + } + + restore(key: string): void { + this._keys.delete(key); + this.save(); + } + + clear(): void { + this._keys.clear(); + this.save(); + } + + private load(): void { + try { + const raw = localStorage.getItem(this.key); + if (!raw) { + return; + } + const arr = JSON.parse(raw); + if (Array.isArray(arr)) { + this._keys = new Set(arr.filter((s) => typeof s === "string")); + } + } catch { + // ignore + } + } + + private save(): void { + localStorage.setItem(this.key, JSON.stringify([...this._keys])); + } +} diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index 7c36713c3..0eb3f2251 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -1,9 +1,10 @@ import { html, nothing } from "lit"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import type { AssistantIdentity } from "../assistant-identity.ts"; +import { icons } from "../icons.ts"; import { toSanitizedMarkdownHtml } from "../markdown.ts"; import { detectTextDirection } from "../text-direction.ts"; -import type { MessageGroup } from "../types/chat-types.ts"; +import type { MessageGroup, ToolCard } from "../types/chat-types.ts"; import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts"; import { extractTextCached, @@ -111,6 +112,7 @@ export function renderMessageGroup( showReasoning: boolean; assistantName?: string; assistantAvatar?: string | null; + onDelete?: () => void; }, ) { const normalizedRole = normalizeRoleForGrouping(group.role); @@ -148,6 +150,16 @@ export function renderMessageGroup(
@@ -216,6 +228,66 @@ function renderMessageImages(images: ImageBlock[]) { `; } +/** Render tool cards inside a collapsed `
` element. */ +function renderCollapsedToolCards( + toolCards: ToolCard[], + onOpenSidebar?: (content: string) => void, +) { + const calls = toolCards.filter((c) => c.kind === "call"); + const results = toolCards.filter((c) => c.kind === "result"); + const totalTools = Math.max(calls.length, results.length) || toolCards.length; + const toolNames = [...new Set(toolCards.map((c) => c.name))]; + const summaryLabel = + toolNames.length <= 3 + ? toolNames.join(", ") + : `${toolNames.slice(0, 2).join(", ")} +${toolNames.length - 2} more`; + + return html` +
+ + ${icons.zap} + ${totalTools} tool${totalTools === 1 ? "" : "s"} + ${summaryLabel} + +
+ ${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))} +
+
+ `; +} + +/** + * Detect whether a trimmed string is a JSON object or array. + * Must start with `{`/`[` and end with `}`/`]` and parse successfully. + */ +function detectJson(text: string): { parsed: unknown; pretty: string } | null { + const t = text.trim(); + if ((t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"))) { + try { + const parsed = JSON.parse(t); + return { parsed, pretty: JSON.stringify(parsed, null, 2) }; + } catch { + return null; + } + } + return null; +} + +/** Build a short summary label for collapsed JSON (type + key count or array length). */ +function jsonSummaryLabel(parsed: unknown): string { + if (Array.isArray(parsed)) { + return `Array (${parsed.length} item${parsed.length === 1 ? "" : "s"})`; + } + if (parsed && typeof parsed === "object") { + const keys = Object.keys(parsed as Record); + if (keys.length <= 4) { + return `{ ${keys.join(", ")} }`; + } + return `Object (${keys.length} keys)`; + } + return "JSON"; +} + function renderGroupedMessage( message: unknown, opts: { isStreaming: boolean; showReasoning: boolean }, @@ -243,6 +315,9 @@ function renderGroupedMessage( const markdown = markdownBase; const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim()); + // Detect pure-JSON messages and render as collapsible block + const jsonResult = markdown && !opts.isStreaming ? detectJson(markdown) : null; + const bubbleClasses = [ "chat-bubble", canCopyMarkdown ? "has-copy" : "", @@ -253,7 +328,7 @@ function renderGroupedMessage( .join(" "); if (!markdown && hasToolCards && isToolResult) { - return html`${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}`; + return renderCollapsedToolCards(toolCards, onOpenSidebar); } if (!markdown && !hasToolCards && !hasImages) { @@ -272,11 +347,19 @@ function renderGroupedMessage( : nothing } ${ - markdown - ? html`
${unsafeHTML(toSanitizedMarkdownHtml(markdown))}
` - : nothing + jsonResult + ? html`
+ + JSON + ${jsonSummaryLabel(jsonResult.parsed)} + +
${jsonResult.pretty}
+
` + : markdown + ? html`
${unsafeHTML(toSanitizedMarkdownHtml(markdown))}
` + : nothing } - ${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))} + ${hasToolCards ? renderCollapsedToolCards(toolCards, onOpenSidebar) : nothing} `; } diff --git a/ui/src/ui/chat/input-history.ts b/ui/src/ui/chat/input-history.ts new file mode 100644 index 000000000..34d8806d0 --- /dev/null +++ b/ui/src/ui/chat/input-history.ts @@ -0,0 +1,49 @@ +const MAX = 50; + +export class InputHistory { + private items: string[] = []; + private cursor = -1; + + push(text: string): void { + const trimmed = text.trim(); + if (!trimmed) { + return; + } + if (this.items[this.items.length - 1] === trimmed) { + return; + } + this.items.push(trimmed); + if (this.items.length > MAX) { + this.items.shift(); + } + this.cursor = -1; + } + + up(): string | null { + if (this.items.length === 0) { + return null; + } + if (this.cursor < 0) { + this.cursor = this.items.length - 1; + } else if (this.cursor > 0) { + this.cursor--; + } + return this.items[this.cursor] ?? null; + } + + down(): string | null { + if (this.cursor < 0) { + return null; + } + this.cursor++; + if (this.cursor >= this.items.length) { + this.cursor = -1; + return null; + } + return this.items[this.cursor] ?? null; + } + + reset(): void { + this.cursor = -1; + } +} diff --git a/ui/src/ui/chat/pinned-messages.ts b/ui/src/ui/chat/pinned-messages.ts new file mode 100644 index 000000000..4914b0db3 --- /dev/null +++ b/ui/src/ui/chat/pinned-messages.ts @@ -0,0 +1,61 @@ +const PREFIX = "openclaw:pinned:"; + +export class PinnedMessages { + private key: string; + private _indices = new Set(); + + constructor(sessionKey: string) { + this.key = PREFIX + sessionKey; + this.load(); + } + + get indices(): Set { + return this._indices; + } + + has(index: number): boolean { + return this._indices.has(index); + } + + pin(index: number): void { + this._indices.add(index); + this.save(); + } + + unpin(index: number): void { + this._indices.delete(index); + this.save(); + } + + toggle(index: number): void { + if (this._indices.has(index)) { + this.unpin(index); + } else { + this.pin(index); + } + } + + clear(): void { + this._indices.clear(); + this.save(); + } + + private load(): void { + try { + const raw = localStorage.getItem(this.key); + if (!raw) { + return; + } + const arr = JSON.parse(raw); + if (Array.isArray(arr)) { + this._indices = new Set(arr.filter((n) => typeof n === "number")); + } + } catch { + // ignore + } + } + + private save(): void { + localStorage.setItem(this.key, JSON.stringify([...this._indices])); + } +} diff --git a/ui/src/ui/chat/slash-commands.ts b/ui/src/ui/chat/slash-commands.ts new file mode 100644 index 000000000..48e6c8388 --- /dev/null +++ b/ui/src/ui/chat/slash-commands.ts @@ -0,0 +1,84 @@ +import type { IconName } from "../icons.ts"; + +export type SlashCommandCategory = "session" | "model" | "agents" | "tools"; + +export type SlashCommandDef = { + name: string; + description: string; + args?: string; + icon?: IconName; + category?: SlashCommandCategory; +}; + +export const SLASH_COMMANDS: SlashCommandDef[] = [ + { name: "help", description: "Show available commands", icon: "book", category: "session" }, + { name: "status", description: "Show current status", icon: "barChart", category: "session" }, + { name: "reset", description: "Reset session", icon: "refresh", category: "session" }, + { name: "compact", description: "Compact session context", icon: "loader", category: "session" }, + { name: "stop", description: "Stop current run", icon: "stop", category: "session" }, + { + name: "model", + description: "Show/set model", + args: "", + icon: "brain", + category: "model", + }, + { + name: "think", + description: "Set thinking level", + args: "", + icon: "brain", + category: "model", + }, + { + name: "verbose", + description: "Toggle verbose mode", + args: "", + icon: "terminal", + category: "model", + }, + { name: "export", description: "Export session to HTML", icon: "download", category: "tools" }, + { + name: "skill", + description: "Run a skill", + args: "", + icon: "zap", + category: "tools", + }, + { name: "agents", description: "List agents", icon: "monitor", category: "agents" }, + { + name: "kill", + description: "Abort sub-agents", + args: "", + icon: "x", + category: "agents", + }, + { + name: "steer", + description: "Steer a sub-agent", + args: " ", + icon: "send", + category: "agents", + }, + { name: "usage", description: "Show token usage", icon: "barChart", category: "tools" }, +]; + +const CATEGORY_ORDER: SlashCommandCategory[] = ["session", "model", "agents", "tools"]; + +export const CATEGORY_LABELS: Record = { + session: "Session", + model: "Model", + agents: "Agents", + tools: "Tools", +}; + +export function getSlashCommandCompletions(filter: string): SlashCommandDef[] { + const commands = filter + ? SLASH_COMMANDS.filter((cmd) => cmd.name.startsWith(filter.toLowerCase())) + : SLASH_COMMANDS; + return commands.toSorted((a, b) => { + const ai = CATEGORY_ORDER.indexOf(a.category ?? "session"); + const bi = CATEGORY_ORDER.indexOf(b.category ?? "session"); + return ai - bi; + }); +} diff --git a/ui/src/ui/components/dashboard-header.ts b/ui/src/ui/components/dashboard-header.ts new file mode 100644 index 000000000..cf5f9795c --- /dev/null +++ b/ui/src/ui/components/dashboard-header.ts @@ -0,0 +1,34 @@ +import { LitElement, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { titleForTab, type Tab } from "../navigation.js"; + +@customElement("dashboard-header") +export class DashboardHeader extends LitElement { + override createRenderRoot() { + return this; + } + + @property() tab: Tab = "overview"; + + override render() { + const label = titleForTab(this.tab); + + return html` +
+
+ this.dispatchEvent(new CustomEvent("navigate", { detail: "overview", bubbles: true, composed: true }))} + > + ClawDash + + + ${label} +
+
+ +
+
+ `; + } +} diff --git a/ui/src/ui/config-form.browser.test.ts b/ui/src/ui/config-form.browser.test.ts index 292c5780b..b391a27f9 100644 --- a/ui/src/ui/config-form.browser.test.ts +++ b/ui/src/ui/config-form.browser.test.ts @@ -197,7 +197,7 @@ describe("config form renderer", () => { expect(container.textContent).toContain("Plugin Enabled"); }); - it("flags unsupported unions", () => { + it("passes mixed unions through for JSON fallback rendering", () => { const schema = { type: "object", properties: { @@ -207,7 +207,7 @@ describe("config form renderer", () => { }, }; const analysis = analyzeConfigSchema(schema); - expect(analysis.unsupportedPaths).toContain("mixed"); + expect(analysis.unsupportedPaths).not.toContain("mixed"); }); it("supports nullable types", () => { diff --git a/ui/src/ui/controllers/debug.ts b/ui/src/ui/controllers/debug.ts index b4dfa7ade..3fb743c56 100644 --- a/ui/src/ui/controllers/debug.ts +++ b/ui/src/ui/controllers/debug.ts @@ -1,18 +1,24 @@ import type { GatewayBrowserClient } from "../gateway.ts"; -import type { HealthSnapshot, StatusSummary } from "../types.ts"; +import type { HealthSummary, ModelCatalogEntry, StatusSummary } from "../types.ts"; +import { loadHealthState } from "./health.ts"; +import { loadModels } from "./models.ts"; export type DebugState = { client: GatewayBrowserClient | null; connected: boolean; debugLoading: boolean; debugStatus: StatusSummary | null; - debugHealth: HealthSnapshot | null; - debugModels: unknown[]; + debugHealth: HealthSummary | null; + debugModels: ModelCatalogEntry[]; debugHeartbeat: unknown; debugCallMethod: string; debugCallParams: string; debugCallResult: string | null; debugCallError: string | null; + /** Shared health state fields (written by {@link loadHealthState}). */ + healthLoading: boolean; + healthResult: HealthSummary | null; + healthError: string | null; }; export async function loadDebug(state: DebugState) { @@ -24,16 +30,16 @@ export async function loadDebug(state: DebugState) { } state.debugLoading = true; try { - const [status, health, models, heartbeat] = await Promise.all([ + const [status, , models, heartbeat] = await Promise.all([ state.client.request("status", {}), - state.client.request("health", {}), - state.client.request("models.list", {}), + loadHealthState(state), + loadModels(state.client), state.client.request("last-heartbeat", {}), ]); state.debugStatus = status as StatusSummary; - state.debugHealth = health as HealthSnapshot; - const modelPayload = models as { models?: unknown[] } | undefined; - state.debugModels = Array.isArray(modelPayload?.models) ? modelPayload?.models : []; + // Sync debugHealth from the shared healthResult for backward compat. + state.debugHealth = state.healthResult; + state.debugModels = models; state.debugHeartbeat = heartbeat; } catch (err) { state.debugCallError = String(err); diff --git a/ui/src/ui/controllers/health.ts b/ui/src/ui/controllers/health.ts new file mode 100644 index 000000000..b077794d6 --- /dev/null +++ b/ui/src/ui/controllers/health.ts @@ -0,0 +1,62 @@ +import type { GatewayBrowserClient } from "../gateway.ts"; +import type { HealthSummary } from "../types.ts"; + +/** Default fallback returned when the gateway is unreachable or returns null. */ +const HEALTH_FALLBACK: HealthSummary = { + ok: false, + ts: 0, + durationMs: 0, + heartbeatSeconds: 0, + defaultAgentId: "", + agents: [], + sessions: { path: "", count: 0, recent: [] }, +}; + +/** State slice consumed by {@link loadHealthState}. Follows the agents/sessions convention. */ +export type HealthState = { + client: GatewayBrowserClient | null; + connected: boolean; + healthLoading: boolean; + healthResult: HealthSummary | null; + healthError: string | null; +}; + +/** + * Fetch the gateway health summary. + * + * Accepts a {@link GatewayBrowserClient} (matching the existing ui/ controller + * convention). Returns a fully-typed {@link HealthSummary}; on failure the + * caller receives a safe fallback with `ok: false` rather than `null`. + */ +export async function loadHealth(client: GatewayBrowserClient): Promise { + try { + const result = await client.request("health", {}); + return result ?? HEALTH_FALLBACK; + } catch { + return HEALTH_FALLBACK; + } +} + +/** + * State-mutating health loader (same pattern as {@link import("./agents.ts").loadAgents}). + * + * Populates `healthResult` / `healthError` on the provided state slice and + * toggles `healthLoading` around the request. + */ +export async function loadHealthState(state: HealthState): Promise { + if (!state.client || !state.connected) { + return; + } + if (state.healthLoading) { + return; + } + state.healthLoading = true; + state.healthError = null; + try { + state.healthResult = await loadHealth(state.client); + } catch (err) { + state.healthError = String(err); + } finally { + state.healthLoading = false; + } +} diff --git a/ui/src/ui/controllers/models.ts b/ui/src/ui/controllers/models.ts new file mode 100644 index 000000000..d9e119c5c --- /dev/null +++ b/ui/src/ui/controllers/models.ts @@ -0,0 +1,18 @@ +import type { GatewayBrowserClient } from "../gateway.ts"; +import type { ModelCatalogEntry } from "../types.ts"; + +/** + * Fetch the model catalog from the gateway. + * + * Accepts a {@link GatewayBrowserClient} (matching the existing ui/ controller + * convention). Returns an array of {@link ModelCatalogEntry}; on failure the + * caller receives an empty array rather than throwing. + */ +export async function loadModels(client: GatewayBrowserClient): Promise { + try { + const result = await client.request<{ models: ModelCatalogEntry[] }>("models.list", {}); + return result?.models ?? []; + } catch { + return []; + } +} diff --git a/ui/src/ui/format.ts b/ui/src/ui/format.ts index da3d544f1..e0c92baba 100644 --- a/ui/src/ui/format.ts +++ b/ui/src/ui/format.ts @@ -58,3 +58,41 @@ export function parseList(input: string): string[] { export function stripThinkingTags(value: string): string { return stripReasoningTagsFromText(value, { mode: "preserve", trim: "start" }); } + +export function formatCost(cost: number | null | undefined, fallback = "$0.00"): string { + if (cost == null || !Number.isFinite(cost)) { + return fallback; + } + if (cost === 0) { + return "$0.00"; + } + if (cost < 0.01) { + return `$${cost.toFixed(4)}`; + } + if (cost < 1) { + return `$${cost.toFixed(3)}`; + } + return `$${cost.toFixed(2)}`; +} + +export function formatTokens(tokens: number | null | undefined, fallback = "0"): string { + if (tokens == null || !Number.isFinite(tokens)) { + return fallback; + } + if (tokens < 1000) { + return String(Math.round(tokens)); + } + if (tokens < 1_000_000) { + const k = tokens / 1000; + return k < 10 ? `${k.toFixed(1)}k` : `${Math.round(k)}k`; + } + const m = tokens / 1_000_000; + return m < 10 ? `${m.toFixed(1)}M` : `${Math.round(m)}M`; +} + +export function formatPercent(value: number | null | undefined, fallback = "—"): string { + if (value == null || !Number.isFinite(value)) { + return fallback; + } + return `${(value * 100).toFixed(1)}%`; +} diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index 27f212c24..36abce4c8 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -61,6 +61,13 @@ export type GatewayBrowserClientOptions = { // 4008 = application-defined code (browser rejects 1008 "Policy Violation") const CONNECT_FAILED_CLOSE_CODE = 4008; +const DEFAULT_OPERATOR_CONNECT_SCOPES = [ + "operator.admin", + "operator.read", + "operator.write", + "operator.approvals", + "operator.pairing", +]; export class GatewayBrowserClient { private ws: WebSocket | null = null; @@ -102,6 +109,13 @@ export class GatewayBrowserClient { this.ws = null; this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`)); this.opts.onClose?.({ code: ev.code, reason }); + // 1008 = Policy Violation (gateway auth rejection). + // Don't auto-reconnect on auth failures — surface the login gate + // so the user can fix their token/password instead of looping. + if (ev.code === 1008) { + this.closed = true; + return; + } this.scheduleReconnect(); }); this.ws.addEventListener("error", () => { @@ -145,10 +159,9 @@ export class GatewayBrowserClient { // Gateways may reject this unless gateway.controlUi.allowInsecureAuth is enabled. const isSecureContext = typeof crypto !== "undefined" && !!crypto.subtle; - const scopes = ["operator.admin", "operator.approvals", "operator.pairing"]; + const scopes = DEFAULT_OPERATOR_CONNECT_SCOPES; const role = "operator"; let deviceIdentity: Awaited> | null = null; - let canFallbackToShared = false; let authToken = this.opts.token; if (isSecureContext) { @@ -158,7 +171,6 @@ export class GatewayBrowserClient { role, })?.token; authToken = storedToken ?? this.opts.token; - canFallbackToShared = Boolean(storedToken && this.opts.token); } const auth = authToken || this.opts.password @@ -232,7 +244,11 @@ export class GatewayBrowserClient { this.opts.onHello?.(hello); }) .catch(() => { - if (canFallbackToShared && deviceIdentity) { + // Clear stale device token on any connect failure so the next attempt + // falls back to the shared gateway token (if present) or retries without + // a cached device token. Without this, a rotated/revoked device token + // causes an infinite mismatch loop when no shared token is configured. + if (deviceIdentity) { clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role }); } this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed"); diff --git a/ui/src/ui/icons.ts b/ui/src/ui/icons.ts index 1682dcfa9..5a42ef891 100644 --- a/ui/src/ui/icons.ts +++ b/ui/src/ui/icons.ts @@ -228,6 +228,147 @@ export const icons = { /> `, + panelLeftClose: html` + + + + + + `, + panelLeftOpen: html` + + + + + + `, + chevronDown: html` + + + + `, + chevronRight: html` + + + + `, + externalLink: html` + + + + + `, + send: html` + + + + + `, + stop: html` + + `, + pin: html` + + + + + `, + pinOff: html` + + + + + + `, + download: html` + + + + + + `, + mic: html` + + + + + + `, + micOff: html` + + + + + + + + + `, + bookmark: html` + + `, + plus: html` + + + + + `, + terminal: html` + + + + + `, + spark: html` + + + + `, + refresh: html` + + + + + `, + trash: html` + + + + + + + + `, + eye: html` + + + + + `, + eyeOff: html` + + + + + + + `, } as const; export type IconName = keyof typeof icons; diff --git a/ui/src/ui/markdown.ts b/ui/src/ui/markdown.ts index 1867b0eda..f7f5602ce 100644 --- a/ui/src/ui/markdown.ts +++ b/ui/src/ui/markdown.ts @@ -14,6 +14,7 @@ const allowedTags = [ "br", "code", "del", + "details", "em", "h1", "h2", @@ -26,6 +27,7 @@ const allowedTags = [ "p", "pre", "strong", + "summary", "table", "tbody", "td", @@ -132,6 +134,35 @@ export function toSanitizedMarkdownHtml(markdown: string): string { const htmlEscapeRenderer = new marked.Renderer(); htmlEscapeRenderer.html = ({ text }: { text: string }) => escapeHtml(text); +htmlEscapeRenderer.code = ({ + text, + lang, + escaped, +}: { + text: string; + lang?: string; + escaped?: boolean; +}) => { + const langClass = lang ? ` class="language-${lang}"` : ""; + const safeText = escaped ? text : escapeHtml(text); + const codeBlock = `
${safeText}
`; + + const trimmed = text.trim(); + const isJson = + lang === "json" || + (!lang && + ((trimmed.startsWith("{") && trimmed.endsWith("}")) || + (trimmed.startsWith("[") && trimmed.endsWith("]")))); + + if (isJson) { + const lineCount = text.split("\n").length; + const label = lineCount > 1 ? `JSON · ${lineCount} lines` : "JSON"; + return `
${label}${codeBlock}
`; + } + + return codeBlock; +}; + function escapeHtml(value: string): string { return value .replace(/&/g, "&") diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index b32e6c3c5..e98030885 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -1,7 +1,7 @@ const KEY = "openclaw.control.settings.v1"; import { isSupportedLocale } from "../i18n/index.ts"; -import type { ThemeMode } from "./theme.ts"; +import { VALID_THEMES, type ThemeMode } from "./theme.ts"; export type UiSettings = { gatewayUrl: string; @@ -28,7 +28,7 @@ export function loadSettings(): UiSettings { token: "", sessionKey: "main", lastActiveSessionKey: "main", - theme: "system", + theme: "dark", chatFocusMode: false, chatShowThinking: true, splitRatio: 0.6, @@ -57,10 +57,9 @@ export function loadSettings(): UiSettings { ? parsed.lastActiveSessionKey.trim() : (typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()) || defaults.lastActiveSessionKey, - theme: - parsed.theme === "light" || parsed.theme === "dark" || parsed.theme === "system" - ? parsed.theme - : defaults.theme, + theme: VALID_THEMES.has(parsed.theme as ThemeMode) + ? (parsed.theme as ThemeMode) + : defaults.theme, chatFocusMode: typeof parsed.chatFocusMode === "boolean" ? parsed.chatFocusMode : defaults.chatFocusMode, chatShowThinking: diff --git a/ui/src/ui/theme.ts b/ui/src/ui/theme.ts index 480f9dbe5..77d060b78 100644 --- a/ui/src/ui/theme.ts +++ b/ui/src/ui/theme.ts @@ -1,16 +1,25 @@ -export type ThemeMode = "system" | "light" | "dark"; -export type ResolvedTheme = "light" | "dark"; +export type ThemeMode = "dark" | "light" | "openknot" | "fieldmanual" | "clawdash"; +export type ResolvedTheme = ThemeMode; -export function getSystemTheme(): ResolvedTheme { - if (typeof window === "undefined" || typeof window.matchMedia !== "function") { - return "dark"; - } - return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; -} +export const VALID_THEMES = new Set([ + "dark", + "light", + "openknot", + "fieldmanual", + "clawdash", +]); -export function resolveTheme(mode: ThemeMode): ResolvedTheme { - if (mode === "system") { - return getSystemTheme(); +const LEGACY_MAP: Record = { + defaultTheme: "dark", + docsTheme: "light", + lightTheme: "openknot", + landingTheme: "openknot", + newTheme: "openknot", +}; + +export function resolveTheme(mode: string): ResolvedTheme { + if (VALID_THEMES.has(mode as ThemeMode)) { + return mode as ThemeMode; } - return mode; + return LEGACY_MAP[mode] ?? "dark"; } diff --git a/ui/src/ui/tool-labels.ts b/ui/src/ui/tool-labels.ts new file mode 100644 index 000000000..e4818c493 --- /dev/null +++ b/ui/src/ui/tool-labels.ts @@ -0,0 +1,39 @@ +/** + * Map raw tool names to human-friendly labels for the chat UI. + * Unknown tools are title-cased with underscores replaced by spaces. + */ + +export const TOOL_LABELS: Record = { + exec: "Run Command", + bash: "Run Command", + read: "Read File", + write: "Write File", + edit: "Edit File", + apply_patch: "Apply Patch", + web_search: "Web Search", + web_fetch: "Fetch Page", + browser: "Browser", + message: "Send Message", + image: "Generate Image", + canvas: "Canvas", + cron: "Cron", + gateway: "Gateway", + nodes: "Nodes", + memory_search: "Search Memory", + memory_get: "Get Memory", + session_status: "Session Status", + sessions_list: "List Sessions", + sessions_history: "Session History", + sessions_send: "Send to Session", + sessions_spawn: "Spawn Session", + agents_list: "List Agents", +}; + +export function friendlyToolName(raw: string): string { + const mapped = TOOL_LABELS[raw]; + if (mapped) { + return mapped; + } + // Title-case fallback: "some_tool_name" → "Some Tool Name" + return raw.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 307bae938..eaf7ca063 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -556,6 +556,35 @@ export type StatusSummary = Record; export type HealthSnapshot = Record; +/** Strongly-typed health response from the gateway (richer than HealthSnapshot). */ +export type HealthSummary = { + ok: boolean; + ts: number; + durationMs: number; + heartbeatSeconds: number; + defaultAgentId: string; + agents: Array<{ id: string; name?: string }>; + sessions: { + path: string; + count: number; + recent: Array<{ + key: string; + updatedAt: number | null; + age: number | null; + }>; + }; +}; + +/** A model entry returned by the gateway model-catalog endpoint. */ +export type ModelCatalogEntry = { + id: string; + name: string; + provider: string; + contextWindow?: number; + reasoning?: boolean; + input?: Array<"text" | "image">; +}; + export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal"; export type LogEntry = { @@ -566,3 +595,16 @@ export type LogEntry = { message?: string | null; meta?: Record | null; }; + +// ── Attention ─────────────────────────────────────── + +export type AttentionSeverity = "error" | "warning" | "info"; + +export type AttentionItem = { + severity: AttentionSeverity; + icon: string; + title: string; + description: string; + href?: string; + external?: boolean; +}; diff --git a/ui/src/ui/views/agents-panels-overview.ts b/ui/src/ui/views/agents-panels-overview.ts new file mode 100644 index 000000000..47811b789 --- /dev/null +++ b/ui/src/ui/views/agents-panels-overview.ts @@ -0,0 +1,192 @@ +import { html, nothing } from "lit"; +import type { AgentsFilesListResult, AgentsListResult } from "../types.ts"; +import { + buildModelOptions, + normalizeModelValue, + parseFallbackList, + resolveAgentConfig, + resolveModelFallbacks, + resolveModelLabel, + resolveModelPrimary, +} from "./agents-utils.ts"; +import type { AgentsPanel } from "./agents.ts"; + +export function renderAgentOverview(params: { + agent: AgentsListResult["agents"][number]; + defaultId: string | null; + configForm: Record | null; + agentFilesList: AgentsFilesListResult | null; + configLoading: boolean; + configSaving: boolean; + configDirty: boolean; + onConfigReload: () => void; + onConfigSave: () => void; + onModelChange: (agentId: string, modelId: string | null) => void; + onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void; + onSelectPanel: (panel: AgentsPanel) => void; +}) { + const { + agent, + configForm, + agentFilesList, + configLoading, + configSaving, + configDirty, + onConfigReload, + onConfigSave, + onModelChange, + onModelFallbacksChange, + onSelectPanel, + } = params; + const config = resolveAgentConfig(configForm, agent.id); + const workspaceFromFiles = + agentFilesList && agentFilesList.agentId === agent.id ? agentFilesList.workspace : null; + const workspace = + workspaceFromFiles || config.entry?.workspace || config.defaults?.workspace || "default"; + const model = config.entry?.model + ? resolveModelLabel(config.entry?.model) + : resolveModelLabel(config.defaults?.model); + const defaultModel = resolveModelLabel(config.defaults?.model); + const modelPrimary = + resolveModelPrimary(config.entry?.model) || (model !== "-" ? normalizeModelValue(model) : null); + const defaultPrimary = + resolveModelPrimary(config.defaults?.model) || + (defaultModel !== "-" ? normalizeModelValue(defaultModel) : null); + const effectivePrimary = modelPrimary ?? defaultPrimary ?? null; + const modelFallbacks = resolveModelFallbacks(config.entry?.model); + const fallbackChips = modelFallbacks ?? []; + const skillFilter = Array.isArray(config.entry?.skills) ? config.entry?.skills : null; + const skillCount = skillFilter?.length ?? null; + const isDefault = Boolean(params.defaultId && agent.id === params.defaultId); + const disabled = !configForm || configLoading || configSaving; + + const removeChip = (index: number) => { + const next = fallbackChips.filter((_, i) => i !== index); + onModelFallbacksChange(agent.id, next); + }; + + const handleChipKeydown = (e: KeyboardEvent) => { + const input = e.target as HTMLInputElement; + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + const parsed = parseFallbackList(input.value); + if (parsed.length > 0) { + onModelFallbacksChange(agent.id, [...fallbackChips, ...parsed]); + input.value = ""; + } + } + }; + + const fallbackSummary = fallbackChips.length > 0 ? `${fallbackChips.length} configured` : "none"; + + return html` +
+
+
+
Workspace
+
+ +
+
+
+
Primary Model
+
${model}
+
+
+
Skills Filter
+
${skillFilter ? `${skillCount} selected` : "all skills"}
+
+
+
Fallbacks
+
${fallbackSummary}
+
+
+ + ${ + configDirty + ? html` +
You have unsaved config changes.
+ ` + : nothing + } + +
+
+ +
+ Fallbacks +
{ + const container = e.currentTarget as HTMLElement; + const input = container.querySelector("input"); + if (input) { + input.focus(); + } + }}> + ${fallbackChips.map( + (chip, i) => html` + + ${chip} + + + `, + )} + { + const input = e.target as HTMLInputElement; + const parsed = parseFallbackList(input.value); + if (parsed.length > 0) { + onModelFallbacksChange(agent.id, [...fallbackChips, ...parsed]); + input.value = ""; + } + }} + /> +
+
+
+ + +
+
+
+
+ `; +} diff --git a/ui/src/ui/views/agents-panels-status-files.ts b/ui/src/ui/views/agents-panels-status-files.ts index 23de4cb96..58ff34782 100644 --- a/ui/src/ui/views/agents-panels-status-files.ts +++ b/ui/src/ui/views/agents-panels-status-files.ts @@ -230,7 +230,7 @@ export function renderAgentChannels(params: { const status = summary.total ? `${summary.connected}/${summary.total} connected` : "no accounts"; - const config = summary.configured + const configLabel = summary.configured ? `${summary.configured} configured` : "not configured"; const enabled = summary.total ? `${summary.enabled} enabled` : "disabled"; @@ -243,8 +243,23 @@ export function renderAgentChannels(params: {
${status}
-
${config}
+
${configLabel}
${enabled}
+ ${ + summary.configured === 0 + ? html` + + ` + : nothing + } ${ extras.length > 0 ? extras.map( @@ -272,6 +287,7 @@ export function renderAgentCron(params: { loading: boolean; error: string | null; onRefresh: () => void; + onRunNow: (jobId: string) => void; }) { const jobs = params.jobs.filter((job) => job.agentId === params.agentId); return html` @@ -341,6 +357,12 @@ export function renderAgentCron(params: {
${formatCronState(job)}
${formatCronPayload(job)}
+
`, diff --git a/ui/src/ui/views/agents-panels-tools-skills.ts b/ui/src/ui/views/agents-panels-tools-skills.ts index 687ec749a..49da26f34 100644 --- a/ui/src/ui/views/agents-panels-tools-skills.ts +++ b/ui/src/ui/views/agents-panels-tools-skills.ts @@ -301,17 +301,27 @@ export function renderAgentSkills(params: { } -
- - +
+
+ + + +
diff --git a/ui/src/ui/views/agents-utils.ts b/ui/src/ui/views/agents-utils.ts index ecd2c90f1..8f24b9d0a 100644 --- a/ui/src/ui/views/agents-utils.ts +++ b/ui/src/ui/views/agents-utils.ts @@ -138,6 +138,30 @@ export function normalizeAgentLabel(agent: { return agent.name?.trim() || agent.identity?.name?.trim() || agent.id; } +const AVATAR_URL_RE = /^(https?:\/\/|data:image\/|\/)/i; + +export function resolveAgentAvatarUrl( + agent: { identity?: { avatar?: string; avatarUrl?: string } }, + agentIdentity?: AgentIdentityResult | null, +): string | null { + const url = + agentIdentity?.avatar?.trim() ?? + agent.identity?.avatarUrl?.trim() ?? + agent.identity?.avatar?.trim(); + if (!url) { + return null; + } + if (AVATAR_URL_RE.test(url)) { + return url; + } + return null; +} + +export function agentLogoUrl(basePath: string): string { + const base = basePath?.trim() ? basePath.replace(/\/$/, "") : ""; + return base ? `${base}/favicon.svg` : "/favicon.svg"; +} + function isLikelyEmoji(value: string) { const trimmed = value.trim(); if (!trimmed) { @@ -189,6 +213,14 @@ export function agentBadgeText(agentId: string, defaultId: string | null) { return defaultId && agentId === defaultId ? "default" : null; } +export function agentAvatarHue(id: string): number { + let hash = 0; + for (let i = 0; i < id.length; i += 1) { + hash = (hash * 31 + id.charCodeAt(i)) | 0; + } + return ((hash % 360) + 360) % 360; +} + export function formatBytes(bytes?: number) { if (bytes == null || !Number.isFinite(bytes)) { return "-"; @@ -221,7 +253,7 @@ export type AgentContext = { workspace: string; model: string; identityName: string; - identityEmoji: string; + identityAvatar: string; skillsLabel: string; isDefault: boolean; }; @@ -247,14 +279,14 @@ export function buildAgentContext( agent.name?.trim() || config.entry?.name || agent.id; - const identityEmoji = resolveAgentEmoji(agent, agentIdentity) || "-"; + const identityAvatar = resolveAgentAvatarUrl(agent, agentIdentity) ? "custom" : "—"; const skillFilter = Array.isArray(config.entry?.skills) ? config.entry?.skills : null; const skillCount = skillFilter?.length ?? null; return { workspace, model: modelLabel, identityName, - identityEmoji, + identityAvatar, skillsLabel: skillFilter ? `${skillCount} selected` : "all skills", isDefault: Boolean(defaultId && agent.id === defaultId), }; @@ -379,7 +411,10 @@ export function buildModelOptions( `; } - return options.map((option) => html``); + return options.map( + (option) => + html``, + ); } type CompiledPattern = diff --git a/ui/src/ui/views/agents.ts b/ui/src/ui/views/agents.ts index f8cf5cb5f..3d6fd3b80 100644 --- a/ui/src/ui/views/agents.ts +++ b/ui/src/ui/views/agents.ts @@ -8,6 +8,7 @@ import type { CronStatus, SkillStatusReport, } from "../types.ts"; +import { renderAgentOverview } from "./agents-panels-overview.ts"; import { renderAgentFiles, renderAgentChannels, @@ -15,54 +16,72 @@ import { } from "./agents-panels-status-files.ts"; import { renderAgentTools, renderAgentSkills } from "./agents-panels-tools-skills.ts"; import { + agentAvatarHue, agentBadgeText, + agentLogoUrl, buildAgentContext, - buildModelOptions, normalizeAgentLabel, - normalizeModelValue, - parseFallbackList, - resolveAgentConfig, - resolveAgentEmoji, - resolveModelFallbacks, - resolveModelLabel, - resolveModelPrimary, + resolveAgentAvatarUrl, } from "./agents-utils.ts"; export type AgentsPanel = "overview" | "files" | "tools" | "skills" | "channels" | "cron"; +export type ConfigState = { + form: Record | null; + loading: boolean; + saving: boolean; + dirty: boolean; +}; + +export type ChannelsState = { + snapshot: ChannelsStatusSnapshot | null; + loading: boolean; + error: string | null; + lastSuccess: number | null; +}; + +export type CronState = { + status: CronStatus | null; + jobs: CronJob[]; + loading: boolean; + error: string | null; +}; + +export type AgentFilesState = { + list: AgentsFilesListResult | null; + loading: boolean; + error: string | null; + active: string | null; + contents: Record; + drafts: Record; + saving: boolean; +}; + +export type AgentSkillsState = { + report: SkillStatusReport | null; + loading: boolean; + error: string | null; + agentId: string | null; + filter: string; +}; + export type AgentsProps = { + basePath: string; loading: boolean; error: string | null; agentsList: AgentsListResult | null; selectedAgentId: string | null; activePanel: AgentsPanel; - configForm: Record | null; - configLoading: boolean; - configSaving: boolean; - configDirty: boolean; - channelsLoading: boolean; - channelsError: string | null; - channelsSnapshot: ChannelsStatusSnapshot | null; - channelsLastSuccess: number | null; - cronLoading: boolean; - cronStatus: CronStatus | null; - cronJobs: CronJob[]; - cronError: string | null; - agentFilesLoading: boolean; - agentFilesError: string | null; - agentFilesList: AgentsFilesListResult | null; - agentFileActive: string | null; - agentFileContents: Record; - agentFileDrafts: Record; - agentFileSaving: boolean; + config: ConfigState; + channels: ChannelsState; + cron: CronState; + agentFiles: AgentFilesState; agentIdentityLoading: boolean; agentIdentityError: string | null; agentIdentityById: Record; - agentSkillsLoading: boolean; - agentSkillsReport: SkillStatusReport | null; - agentSkillsError: string | null; - agentSkillsAgentId: string | null; - skillsFilter: string; + agentSkills: AgentSkillsState; + sidebarFilter: string; + onSidebarFilterChange: (value: string) => void; onRefresh: () => void; onSelectAgent: (agentId: string) => void; onSelectPanel: (panel: AgentsPanel) => void; @@ -79,20 +98,13 @@ export type AgentsProps = { onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void; onChannelsRefresh: () => void; onCronRefresh: () => void; + onCronRunNow: (jobId: string) => void; onSkillsFilterChange: (next: string) => void; onSkillsRefresh: () => void; onAgentSkillToggle: (agentId: string, skillName: string, enabled: boolean) => void; onAgentSkillsClear: (agentId: string) => void; onAgentSkillsDisableAll: (agentId: string) => void; -}; - -export type AgentContext = { - workspace: string; - model: string; - identityName: string; - identityEmoji: string; - skillsLabel: string; - isDefault: boolean; + onSetDefault: (agentId: string) => void; }; export function renderAgents(props: AgentsProps) { @@ -103,6 +115,27 @@ export function renderAgents(props: AgentsProps) { ? (agents.find((agent) => agent.id === selectedId) ?? null) : null; + const sidebarFilter = props.sidebarFilter.trim().toLowerCase(); + const filteredAgents = sidebarFilter + ? agents.filter((agent) => { + const label = normalizeAgentLabel(agent).toLowerCase(); + return label.includes(sidebarFilter) || agent.id.toLowerCase().includes(sidebarFilter); + }) + : agents; + + const channelEntryCount = props.channels.snapshot + ? Object.keys(props.channels.snapshot.channelAccounts ?? {}).length + : null; + const cronJobCount = selectedId + ? props.cron.jobs.filter((j) => j.agentId === selectedId).length + : null; + const tabCounts: Record = { + files: props.agentFiles.list?.files?.length ?? null, + skills: props.agentSkills.report?.skills?.length ?? null, + channels: channelEntryCount, + cron: cronJobCount || null, + }; + return html`
@@ -115,6 +148,21 @@ export function renderAgents(props: AgentsProps) { ${props.loading ? "Loading…" : "Refresh"}
+ ${ + agents.length > 1 + ? html` + + props.onSidebarFilterChange((e.target as HTMLInputElement).value)} + style="margin-top: 8px;" + /> + ` + : nothing + } ${ props.error ? html`
${props.error}
` @@ -122,20 +170,31 @@ export function renderAgents(props: AgentsProps) { }
${ - agents.length === 0 + filteredAgents.length === 0 ? html` -
No agents found.
+
${sidebarFilter ? "No matching agents." : "No agents found."}
` - : agents.map((agent) => { + : filteredAgents.map((agent) => { const badge = agentBadgeText(agent.id, defaultId); - const emoji = resolveAgentEmoji(agent, props.agentIdentityById[agent.id] ?? null); + const avatarUrl = resolveAgentAvatarUrl( + agent, + props.agentIdentityById[agent.id] ?? null, + ); + const hue = agentAvatarHue(agent.id); + const logoUrl = agentLogoUrl(props.basePath); return html` + ${ + actionsMenuOpen + ? html` +
+ + +
+ ` + : nothing + } +
+
`; } -function renderAgentTabs(active: AgentsPanel, onSelect: (panel: AgentsPanel) => void) { +function renderAgentTabs( + active: AgentsPanel, + onSelect: (panel: AgentsPanel) => void, + counts: Record, +) { const tabs: Array<{ id: AgentsPanel; label: string }> = [ { id: "overview", label: "Overview" }, { id: "files", label: "Files" }, @@ -329,161 +442,10 @@ function renderAgentTabs(active: AgentsPanel, onSelect: (panel: AgentsPanel) => type="button" @click=${() => onSelect(tab.id)} > - ${tab.label} + ${tab.label}${counts[tab.id] != null ? html`${counts[tab.id]}` : nothing} `, )} `; } - -function renderAgentOverview(params: { - agent: AgentsListResult["agents"][number]; - defaultId: string | null; - configForm: Record | null; - agentFilesList: AgentsFilesListResult | null; - agentIdentity: AgentIdentityResult | null; - agentIdentityLoading: boolean; - agentIdentityError: string | null; - configLoading: boolean; - configSaving: boolean; - configDirty: boolean; - onConfigReload: () => void; - onConfigSave: () => void; - onModelChange: (agentId: string, modelId: string | null) => void; - onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void; -}) { - const { - agent, - configForm, - agentFilesList, - agentIdentity, - agentIdentityLoading, - agentIdentityError, - configLoading, - configSaving, - configDirty, - onConfigReload, - onConfigSave, - onModelChange, - onModelFallbacksChange, - } = params; - const config = resolveAgentConfig(configForm, agent.id); - const workspaceFromFiles = - agentFilesList && agentFilesList.agentId === agent.id ? agentFilesList.workspace : null; - const workspace = - workspaceFromFiles || config.entry?.workspace || config.defaults?.workspace || "default"; - const model = config.entry?.model - ? resolveModelLabel(config.entry?.model) - : resolveModelLabel(config.defaults?.model); - const defaultModel = resolveModelLabel(config.defaults?.model); - const modelPrimary = - resolveModelPrimary(config.entry?.model) || (model !== "-" ? normalizeModelValue(model) : null); - const defaultPrimary = - resolveModelPrimary(config.defaults?.model) || - (defaultModel !== "-" ? normalizeModelValue(defaultModel) : null); - const effectivePrimary = modelPrimary ?? defaultPrimary ?? null; - const modelFallbacks = resolveModelFallbacks(config.entry?.model); - const fallbackText = modelFallbacks ? modelFallbacks.join(", ") : ""; - const identityName = - agentIdentity?.name?.trim() || - agent.identity?.name?.trim() || - agent.name?.trim() || - config.entry?.name || - "-"; - const resolvedEmoji = resolveAgentEmoji(agent, agentIdentity); - const identityEmoji = resolvedEmoji || "-"; - const skillFilter = Array.isArray(config.entry?.skills) ? config.entry?.skills : null; - const skillCount = skillFilter?.length ?? null; - const identityStatus = agentIdentityLoading - ? "Loading…" - : agentIdentityError - ? "Unavailable" - : ""; - const isDefault = Boolean(params.defaultId && agent.id === params.defaultId); - - return html` -
-
Overview
-
Workspace paths and identity metadata.
-
-
-
Workspace
-
${workspace}
-
-
-
Primary Model
-
${model}
-
-
-
Identity Name
-
${identityName}
- ${identityStatus ? html`
${identityStatus}
` : nothing} -
-
-
Default
-
${isDefault ? "yes" : "no"}
-
-
-
Identity Emoji
-
${identityEmoji}
-
-
-
Skills Filter
-
${skillFilter ? `${skillCount} selected` : "all skills"}
-
-
- -
-
Model Selection
-
- - -
-
- - -
-
-
- `; -} diff --git a/ui/src/ui/views/bottom-tabs.ts b/ui/src/ui/views/bottom-tabs.ts new file mode 100644 index 000000000..b8dfbebf3 --- /dev/null +++ b/ui/src/ui/views/bottom-tabs.ts @@ -0,0 +1,33 @@ +import { html } from "lit"; +import { icons } from "../icons.ts"; +import type { Tab } from "../navigation.ts"; + +export type BottomTabsProps = { + activeTab: Tab; + onTabChange: (tab: Tab) => void; +}; + +const BOTTOM_TABS: Array<{ id: Tab; label: string; icon: keyof typeof icons }> = [ + { id: "overview", label: "Dashboard", icon: "barChart" }, + { id: "chat", label: "Chat", icon: "messageSquare" }, + { id: "sessions", label: "Sessions", icon: "fileText" }, + { id: "config", label: "Settings", icon: "settings" }, +]; + +export function renderBottomTabs(props: BottomTabsProps) { + return html` + + `; +} diff --git a/ui/src/ui/views/channels.nostr-profile-form.ts b/ui/src/ui/views/channels.nostr-profile-form.ts index 62e4669f3..244236eba 100644 --- a/ui/src/ui/views/channels.nostr-profile-form.ts +++ b/ui/src/ui/views/channels.nostr-profile-form.ts @@ -247,7 +247,7 @@ export function renderNostrProfileForm(params: { @click=${callbacks.onSave} ?disabled=${state.saving || !isDirty} > - ${state.saving ? "Saving..." : "Save & Publish"} + ${state.saving ? "Saving..." : "Save"} + >× `, )} @@ -237,6 +328,265 @@ function renderAttachmentPreview(props: ChatProps) { `; } +function updateSlashMenu(value: string, requestUpdate: () => void): void { + const match = value.match(/^\/(\S*)$/); + if (match) { + const items = getSlashCommandCompletions(match[1]); + slashMenuItems = items; + slashMenuOpen = items.length > 0; + slashMenuIndex = 0; + } else { + slashMenuOpen = false; + slashMenuItems = []; + } + requestUpdate(); +} + +function selectSlashCommand( + cmd: SlashCommandDef, + props: ChatProps, + requestUpdate: () => void, +): void { + const text = `/${cmd.name} `; + props.onDraftChange(text); + slashMenuOpen = false; + slashMenuItems = []; + requestUpdate(); +} + +function tokenEstimate(draft: string): string | null { + if (draft.length < 100) { + return null; + } + return `~${Math.ceil(draft.length / 4)} tokens`; +} + +function startVoice(props: ChatProps, requestUpdate: () => void): void { + const SR = + (window as unknown as Record).webkitSpeechRecognition ?? + (window as unknown as Record).SpeechRecognition; + if (!SR) { + return; + } + const rec = new (SR as new () => Record)(); + rec.continuous = false; + rec.interimResults = true; + rec.lang = "en-US"; + rec.onresult = (event: Record) => { + let transcript = ""; + const results = ( + event as { results: { length: number; [i: number]: { 0: { transcript: string } } } } + ).results; + for (let i = 0; i < results.length; i++) { + transcript += results[i][0].transcript; + } + props.onDraftChange(transcript); + }; + (rec as unknown as EventTarget).addEventListener("end", () => { + voiceActive = false; + recognition = null; + requestUpdate(); + }); + (rec as unknown as EventTarget).addEventListener("error", () => { + voiceActive = false; + recognition = null; + requestUpdate(); + }); + (rec as { start: () => void }).start(); + recognition = rec; + voiceActive = true; + requestUpdate(); +} + +function stopVoice(requestUpdate: () => void): void { + if (recognition && typeof recognition.stop === "function") { + recognition.stop(); + } + recognition = null; + voiceActive = false; + requestUpdate(); +} + +function exportMarkdown(props: ChatProps): void { + const history = Array.isArray(props.messages) ? props.messages : []; + if (history.length === 0) { + return; + } + const lines: string[] = [`# Chat with ${props.assistantName}`, ""]; + for (const msg of history) { + const m = msg as Record; + const role = m.role === "user" ? "You" : m.role === "assistant" ? props.assistantName : "Tool"; + const content = typeof m.content === "string" ? m.content : ""; + const ts = typeof m.timestamp === "number" ? new Date(m.timestamp).toISOString() : ""; + lines.push(`## ${role}${ts ? ` (${ts})` : ""}`, "", content, ""); + } + const blob = new Blob([lines.join("\n")], { type: "text/markdown" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `chat-${props.assistantName}-${Date.now()}.md`; + a.click(); + URL.revokeObjectURL(url); +} + +function renderWelcomeState(props: ChatProps): TemplateResult { + const name = props.assistantName || "Assistant"; + const avatar = props.assistantAvatar ?? props.assistantAvatarUrl; + const initials = name.slice(0, 2).toUpperCase(); + + return html` +
+
+ ${ + avatar + ? html`${name}` + : html`
${initials}
` + } +

${name}

+
+ ${icons.spark} Ready to chat +
+

+ Type a message below · / for commands +

+
+ `; +} + +function renderSearchBar(requestUpdate: () => void): TemplateResult | typeof nothing { + if (!searchOpen) { + return nothing; + } + return html` + + `; +} + +function renderPinnedSection( + props: ChatProps, + pinned: PinnedMessages, + requestUpdate: () => void, +): TemplateResult | typeof nothing { + const messages = Array.isArray(props.messages) ? props.messages : []; + const entries: Array<{ index: number; text: string; role: string }> = []; + for (const idx of pinned.indices) { + const msg = messages[idx] as Record | undefined; + if (!msg) { + continue; + } + const text = typeof msg.content === "string" ? msg.content : ""; + const role = typeof msg.role === "string" ? msg.role : "unknown"; + entries.push({ index: idx, text, role }); + } + if (entries.length === 0) { + return nothing; + } + return html` +
+ + ${ + pinnedExpanded + ? html` +
+ ${entries.map( + ({ index, text, role }) => html` +
+ ${role === "user" ? "You" : "Assistant"} + ${text.slice(0, 100)}${text.length > 100 ? "..." : ""} + +
+ `, + )} +
+ ` + : nothing + } +
+ `; +} + +function renderSlashMenu( + requestUpdate: () => void, + props: ChatProps, +): TemplateResult | typeof nothing { + if (!slashMenuOpen || slashMenuItems.length === 0) { + return nothing; + } + + const grouped = new Map< + SlashCommandCategory, + Array<{ cmd: SlashCommandDef; globalIdx: number }> + >(); + for (let i = 0; i < slashMenuItems.length; i++) { + const cmd = slashMenuItems[i]; + const cat = cmd.category ?? "session"; + let list = grouped.get(cat); + if (!list) { + list = []; + grouped.set(cat, list); + } + list.push({ cmd, globalIdx: i }); + } + + const sections: TemplateResult[] = []; + for (const [cat, entries] of grouped) { + sections.push(html` +
+
${CATEGORY_LABELS[cat]}
+ ${entries.map( + ({ cmd, globalIdx }) => html` +
selectSlashCommand(cmd, props, requestUpdate)} + @mouseenter=${() => { + slashMenuIndex = globalIdx; + requestUpdate(); + }} + > + ${cmd.icon ? html`${icons[cmd.icon]}` : nothing} + /${cmd.name} + ${cmd.args ? html`${cmd.args}` : nothing} + ${cmd.description} +
+ `, + )} +
+ `); + } + + return html`
${sections}
`; +} + export function renderChat(props: ChatProps) { const canCompose = props.connected; const isBusy = props.sending || props.stream !== null; @@ -248,16 +598,35 @@ export function renderChat(props: ChatProps) { name: props.assistantName, avatar: props.assistantAvatar ?? props.assistantAvatarUrl ?? null, }; - + const pinned = getPinnedMessages(props.sessionKey); + const deleted = getDeletedMessages(props.sessionKey); + const inputHistory = getInputHistory(props.sessionKey); const hasAttachments = (props.attachments?.length ?? 0) > 0; - const composePlaceholder = props.connected + const tokens = tokenEstimate(props.draft); + + const hasVoice = + typeof (window as unknown as Record).webkitSpeechRecognition !== "undefined" || + typeof (window as unknown as Record).SpeechRecognition !== "undefined"; + + const placeholder = props.connected ? hasAttachments ? "Add a message or paste more images..." - : "Message (↩ to send, Shift+↩ for line breaks, paste images)" - : "Connect to the gateway to start chatting…"; + : `Message ${props.assistantName || "agent"} (Enter to send)` + : "Connect to the gateway to start chatting..."; + + // We need a requestUpdate shim since we're in functional mode: + // the host Lit component will re-render on state change anyway, + // so we trigger by calling onDraftChange with current value. + const requestUpdate = () => { + props.onDraftChange(props.draft); + }; const splitRatio = props.splitRatio ?? 0.6; const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar); + + const chatItems = buildChatItems(props); + const isEmpty = chatItems.length === 0 && !props.loading; + const thread = html`
Loading chat…
+
Loading chat...
+ ` + : nothing + } + ${isEmpty && !searchOpen ? renderWelcomeState(props) : nothing} + ${ + isEmpty && searchOpen + ? html` +
No matching messages
` : nothing } ${repeat( - buildChatItems(props), + chatItems, (item) => item.key, (item) => { if (item.kind === "divider") { @@ -285,11 +662,9 @@ export function renderChat(props: ChatProps) { `; } - if (item.kind === "reading-indicator") { return renderReadingIndicatorGroup(assistantIdentity); } - if (item.kind === "stream") { return renderStreamingGroup( item.text, @@ -298,26 +673,120 @@ export function renderChat(props: ChatProps) { assistantIdentity, ); } - if (item.kind === "group") { + if (deleted.has(item.key)) { + return nothing; + } return renderMessageGroup(item, { onOpenSidebar: props.onOpenSidebar, showReasoning, assistantName: props.assistantName, assistantAvatar: assistantIdentity.avatar, + onDelete: () => { + deleted.delete(item.key); + requestUpdate(); + }, }); } - return nothing; }, )} `; - return html` -
- ${props.disabledReason ? html`
${props.disabledReason}
` : nothing} + const handleKeyDown = (e: KeyboardEvent) => { + // Slash menu navigation + if (slashMenuOpen && slashMenuItems.length > 0) { + const len = slashMenuItems.length; + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + slashMenuIndex = (slashMenuIndex + 1) % len; + requestUpdate(); + return; + case "ArrowUp": + e.preventDefault(); + slashMenuIndex = (slashMenuIndex - 1 + len) % len; + requestUpdate(); + return; + case "Enter": + case "Tab": + e.preventDefault(); + selectSlashCommand(slashMenuItems[slashMenuIndex], props, requestUpdate); + return; + case "Escape": + e.preventDefault(); + slashMenuOpen = false; + requestUpdate(); + return; + } + } + // Input history (only when input is empty) + if (!props.draft.trim()) { + if (e.key === "ArrowUp") { + const prev = inputHistory.up(); + if (prev !== null) { + e.preventDefault(); + props.onDraftChange(prev); + } + return; + } + if (e.key === "ArrowDown") { + const next = inputHistory.down(); + e.preventDefault(); + props.onDraftChange(next ?? ""); + return; + } + } + + // Cmd+F for search + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key === "f") { + e.preventDefault(); + searchOpen = !searchOpen; + if (!searchOpen) { + searchQuery = ""; + } + requestUpdate(); + return; + } + + // Send on Enter (without shift) + if (e.key === "Enter" && !e.shiftKey) { + if (e.isComposing || e.keyCode === 229) { + return; + } + if (!props.connected) { + return; + } + e.preventDefault(); + if (canCompose) { + if (props.draft.trim()) { + inputHistory.push(props.draft); + } + props.onSend(); + } + } + }; + + const handleInput = (e: Event) => { + const target = e.target as HTMLTextAreaElement; + adjustTextareaHeight(target); + updateSlashMenu(target.value, requestUpdate); + inputHistory.reset(); + // onDraftChange must be last: requestUpdate() inside updateSlashMenu + // uses the stale render-time props.draft, overwriting chatMessage. + // Calling onDraftChange last ensures the correct DOM value wins. + props.onDraftChange(target.value); + }; + + return html` +
handleDrop(e, props)} + @dragover=${(e: DragEvent) => e.preventDefault()} + > + ${props.disabledReason ? html`
${props.disabledReason}
` : nothing} ${props.error ? html`
${props.error}
` : nothing} ${ @@ -336,9 +805,12 @@ export function renderChat(props: ChatProps) { : nothing } -
+ ${renderSearchBar(requestUpdate)} + ${renderPinnedSection(props, pinned, requestUpdate)} + + ${renderAgentBar(props)} + +
- New messages ${icons.arrowDown} + ${icons.arrowDown} New messages ` : nothing } -
+ +
+ ${renderSlashMenu(requestUpdate, props)} ${renderAttachmentPreview(props)} -
- -
+ + handleFileSelect(e, props)} + /> + + + +
+
- + + ${ + hasVoice + ? html` + + ` + : nothing + } + + ${tokens ? html`${tokens}` : nothing} +
+ +
+ + + + ${ + canAbort && isBusy + ? html` + + ` + : html` + + ` + }
@@ -479,6 +997,83 @@ export function renderChat(props: ChatProps) { `; } +function renderAgentBar(props: ChatProps) { + const agents = props.agentsList?.agents ?? []; + if (agents.length <= 1 && !props.sessions?.sessions?.length) { + return nothing; + } + + // Filter sessions for current agent + const agentSessions = (props.sessions?.sessions ?? []).filter((s) => { + const key = s.key ?? ""; + return ( + key.includes(`:${props.currentAgentId}:`) || key.startsWith(`agent:${props.currentAgentId}:`) + ); + }); + + return html` +
+
+ ${ + agents.length > 1 + ? html` + + ` + : html`${agents[0]?.identity?.name || agents[0]?.name || props.currentAgentId}` + } + ${ + agentSessions.length > 0 + ? html` +
+ + ${icons.fileText} + Sessions (${agentSessions.length}) + +
+ ${agentSessions.map( + (s) => html` + + `, + )} +
+
+ ` + : nothing + } +
+
+ ${ + props.onNavigateToAgent + ? html` + + ` + : nothing + } +
+
+ `; +} + const CHAT_HISTORY_RENDER_LIMIT = 200; function groupMessages(items: ChatItem[]): Array { @@ -560,6 +1155,14 @@ function buildChatItems(props: ChatProps): Array { continue; } + // Apply search filter if active + if (searchOpen && searchQuery.trim()) { + const text = typeof normalized.content === "string" ? normalized.content : ""; + if (!text.toLowerCase().includes(searchQuery.toLowerCase())) { + continue; + } + } + items.push({ kind: "message", key: messageKey(msg, i), diff --git a/ui/src/ui/views/command-palette.ts b/ui/src/ui/views/command-palette.ts new file mode 100644 index 000000000..639af836a --- /dev/null +++ b/ui/src/ui/views/command-palette.ts @@ -0,0 +1,244 @@ +import { html, nothing } from "lit"; +import { t } from "../../i18n/index.ts"; +import { icons, type IconName } from "../icons.ts"; + +type PaletteItem = { + id: string; + label: string; + icon: IconName; + category: "search" | "navigation" | "skills"; + action: string; + description?: string; +}; + +const PALETTE_ITEMS: PaletteItem[] = [ + { + id: "status", + label: "/status", + icon: "radio", + category: "search", + action: "/status", + description: "Show current status", + }, + { + id: "models", + label: "/model", + icon: "monitor", + category: "search", + action: "/model", + description: "Show/set model", + }, + { + id: "usage", + label: "/usage", + icon: "barChart", + category: "search", + action: "/usage", + description: "Show usage", + }, + { + id: "think", + label: "/think", + icon: "brain", + category: "search", + action: "/think", + description: "Set thinking level", + }, + { + id: "reset", + label: "/reset", + icon: "loader", + category: "search", + action: "/reset", + description: "Reset session", + }, + { + id: "help", + label: "/help", + icon: "book", + category: "search", + action: "/help", + description: "Show help", + }, + { + id: "nav-overview", + label: "Overview", + icon: "barChart", + category: "navigation", + action: "nav:overview", + }, + { + id: "nav-sessions", + label: "Sessions", + icon: "fileText", + category: "navigation", + action: "nav:sessions", + }, + { + id: "nav-cron", + label: "Scheduled", + icon: "scrollText", + category: "navigation", + action: "nav:cron", + }, + { id: "nav-skills", label: "Skills", icon: "zap", category: "navigation", action: "nav:skills" }, + { + id: "nav-config", + label: "Settings", + icon: "settings", + category: "navigation", + action: "nav:config", + }, + { + id: "nav-agents", + label: "Agents", + icon: "folder", + category: "navigation", + action: "nav:agents", + }, + { + id: "skill-shell", + label: "Shell Command", + icon: "monitor", + category: "skills", + action: "/skill shell", + description: "Run shell", + }, + { + id: "skill-debug", + label: "Debug Mode", + icon: "bug", + category: "skills", + action: "/verbose full", + description: "Toggle debug", + }, +]; + +export type CommandPaletteProps = { + open: boolean; + query: string; + activeIndex: number; + onToggle: () => void; + onQueryChange: (query: string) => void; + onActiveIndexChange: (index: number) => void; + onNavigate: (tab: string) => void; + onSlashCommand: (command: string) => void; +}; + +function filteredItems(query: string): PaletteItem[] { + if (!query) { + return PALETTE_ITEMS; + } + const q = query.toLowerCase(); + return PALETTE_ITEMS.filter( + (item) => + item.label.toLowerCase().includes(q) || + (item.description?.toLowerCase().includes(q) ?? false), + ); +} + +function groupItems(items: PaletteItem[]): Array<[string, PaletteItem[]]> { + const map = new Map(); + for (const item of items) { + const group = map.get(item.category) ?? []; + group.push(item); + map.set(item.category, group); + } + return [...map.entries()]; +} + +function selectItem(item: PaletteItem, props: CommandPaletteProps) { + if (item.action.startsWith("nav:")) { + props.onNavigate(item.action.slice(4)); + } else { + props.onSlashCommand(item.action); + } + props.onToggle(); +} + +function handleKeydown(e: KeyboardEvent, props: CommandPaletteProps) { + const items = filteredItems(props.query); + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + props.onActiveIndexChange(Math.min(props.activeIndex + 1, items.length - 1)); + break; + case "ArrowUp": + e.preventDefault(); + props.onActiveIndexChange(Math.max(props.activeIndex - 1, 0)); + break; + case "Enter": + e.preventDefault(); + if (items[props.activeIndex]) { + selectItem(items[props.activeIndex], props); + } + break; + case "Escape": + e.preventDefault(); + props.onToggle(); + break; + } +} + +const CATEGORY_LABELS: Record = { + search: "Search", + navigation: "Navigation", + skills: "Skills", +}; + +export function renderCommandPalette(props: CommandPaletteProps) { + if (!props.open) { + return nothing; + } + + const items = filteredItems(props.query); + const grouped = groupItems(items); + + return html` +
props.onToggle()}> +
e.stopPropagation()}> + { + props.onQueryChange((e.target as HTMLInputElement).value); + props.onActiveIndexChange(0); + }} + @keydown=${(e: KeyboardEvent) => handleKeydown(e, props)} + autofocus + /> +
+ ${ + grouped.length === 0 + ? html`
${t("overview.palette.noResults")}
` + : grouped.map( + ([category, groupedItems]) => html` +
${CATEGORY_LABELS[category] ?? category}
+ ${groupedItems.map((item) => { + const globalIndex = items.indexOf(item); + const isActive = globalIndex === props.activeIndex; + return html` +
selectItem(item, props)} + @mouseenter=${() => props.onActiveIndexChange(globalIndex)} + > + ${icons[item.icon]} + ${item.label} + ${ + item.description + ? html`${item.description}` + : nothing + } +
+ `; + })} + `, + ) + } +
+
+
+ `; +} diff --git a/ui/src/ui/views/config-form.analyze.ts b/ui/src/ui/views/config-form.analyze.ts index 9bf17dcde..261f4fc16 100644 --- a/ui/src/ui/views/config-form.analyze.ts +++ b/ui/src/ui/views/config-form.analyze.ts @@ -118,12 +118,47 @@ function normalizeSchemaNode( }; } +function mergeAllOf(schema: JsonSchema, path: Array): ConfigSchemaAnalysis | null { + const branches = schema.allOf; + if (!branches || branches.length === 0) { + return null; + } + const merged: JsonSchema = { ...schema, allOf: undefined }; + for (const branch of branches) { + if (!branch || typeof branch !== "object") { + return null; + } + if (branch.type) { + merged.type = merged.type ?? branch.type; + } + if (branch.properties) { + merged.properties = { ...merged.properties, ...branch.properties }; + } + if (branch.items && !merged.items) { + merged.items = branch.items; + } + if (branch.enum) { + merged.enum = branch.enum; + } + if (branch.description && !merged.description) { + merged.description = branch.description; + } + if (branch.title && !merged.title) { + merged.title = branch.title; + } + if (branch.default !== undefined && merged.default === undefined) { + merged.default = branch.default; + } + } + return normalizeSchemaNode(merged, path); +} + function normalizeUnion( schema: JsonSchema, path: Array, ): ConfigSchemaAnalysis | null { if (schema.allOf) { - return null; + return mergeAllOf(schema, path); } const union = schema.anyOf ?? schema.oneOf; if (!union) { @@ -181,7 +216,7 @@ function normalizeUnion( }; } - if (remaining.length === 1) { + if (remaining.length === 1 && literals.length === 0) { const res = normalizeSchemaNode(remaining[0], path); if (res.schema) { res.schema.nullable = nullable || res.schema.nullable; @@ -189,6 +224,41 @@ function normalizeUnion( return res; } + // Literals + single typed remainder (e.g. boolean | enum["off","partial"]): + // merge literals into an enum on the combined schema so segmented/select renders all options. + if (remaining.length === 1 && literals.length > 0) { + const remType = schemaType(remaining[0]); + if (remType === "boolean") { + const all = [true, false, ...literals]; + const unique: unknown[] = []; + for (const v of all) { + if (!unique.some((e) => Object.is(e, v))) { + unique.push(v); + } + } + return { + schema: { + ...schema, + enum: unique, + nullable, + anyOf: undefined, + oneOf: undefined, + allOf: undefined, + }, + unsupportedPaths: [], + }; + } + // Single remaining primitive — pass through as-is so the renderer picks the right widget + const primitiveTypes = new Set(["string", "number", "integer"]); + if (remType && primitiveTypes.has(remType)) { + const res = normalizeSchemaNode(remaining[0], path); + if (res.schema) { + res.schema.nullable = nullable || res.schema.nullable; + } + return res; + } + } + const primitiveTypes = new Set(["string", "number", "integer", "boolean"]); if ( remaining.length > 0 && @@ -204,5 +274,9 @@ function normalizeUnion( }; } - return null; + // Fallback: pass the schema through and let the renderer show a JSON textarea + return { + schema: { ...schema, nullable }, + unsupportedPaths: [], + }; } diff --git a/ui/src/ui/views/config-form.node.ts b/ui/src/ui/views/config-form.node.ts index cd567d5e6..ff24a861f 100644 --- a/ui/src/ui/views/config-form.node.ts +++ b/ui/src/ui/views/config-form.node.ts @@ -27,6 +27,44 @@ function jsonValue(value: unknown): string { } } +function renderJsonFallback(params: { + label: string; + help: string | undefined; + value: unknown; + path: Array; + disabled: boolean; + showLabel: boolean; + onPatch: (path: Array, value: unknown) => void; +}): TemplateResult { + const { label, help, value, path, disabled, showLabel, onPatch } = params; + const display = jsonValue(value); + return html` +
+ ${showLabel ? html`` : nothing} + ${help ? html`
${help}
` : nothing} + +
+ `; +} + // SVG Icons as template literals const icons = { chevronDown: html` @@ -113,10 +151,7 @@ export function renderNode(params: { const key = pathKey(path); if (unsupported.has(key)) { - return html`
-
${label}
-
Unsupported schema node. Use Raw mode.
-
`; + return renderJsonFallback({ label, help, value, path, disabled, onPatch, showLabel }); } // Handle anyOf/oneOf unions @@ -282,13 +317,8 @@ export function renderNode(params: { return renderTextInput({ ...params, inputType: "text" }); } - // Fallback - return html` -
-
${label}
-
Unsupported type: ${type}. Use Raw mode.
-
- `; + // Fallback — render a JSON textarea for types the form renderer doesn't know about + return renderJsonFallback({ label, help, value, path, disabled, onPatch, showLabel }); } function renderTextInput(params: { diff --git a/ui/src/ui/views/config.browser.test.ts b/ui/src/ui/views/config.browser.test.ts index cdb7fc195..809692723 100644 --- a/ui/src/ui/views/config.browser.test.ts +++ b/ui/src/ui/views/config.browser.test.ts @@ -25,6 +25,7 @@ describe("config view", () => { searchQuery: "", activeSection: null, activeSubsection: null, + streamMode: false, onRawChange: vi.fn(), onFormModeChange: vi.fn(), onFormPatch: vi.fn(), @@ -37,7 +38,7 @@ describe("config view", () => { onSubsectionChange: vi.fn(), }); - it("allows save when form is unsafe", () => { + it("allows save with mixed union schemas", () => { const container = document.createElement("div"); render( renderConfig({ diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index 221f31e00..0be5a47d3 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -1,4 +1,5 @@ import { html, nothing } from "lit"; +import { icons } from "../icons.ts"; import type { ConfigUiHints } from "../types.ts"; import { hintForPath, humanize, schemaType, type JsonSchema } from "./config-form.shared.ts"; import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form.ts"; @@ -22,6 +23,7 @@ export type ConfigProps = { searchQuery: string; activeSection: string | null; activeSubsection: string | null; + streamMode: boolean; onRawChange: (next: string) => void; onFormModeChange: (mode: "form" | "raw") => void; onFormPatch: (path: Array, value: unknown) => void; @@ -383,6 +385,44 @@ function truncateValue(value: unknown, maxLen = 40): string { return str.slice(0, maxLen - 3) + "..."; } +const SENSITIVE_KEY_RE = /token|password|secret|api.?key/i; +const SENSITIVE_KEY_WHITELIST_RE = + /maxtokens|maxoutputtokens|maxinputtokens|maxcompletiontokens|contexttokens|totaltokens|tokencount|tokenlimit|tokenbudget|passwordfile/i; + +function countSensitiveValues(formValue: Record | null): number { + if (!formValue) { + return 0; + } + let count = 0; + function walk(obj: unknown, key?: string) { + if (obj == null) { + return; + } + if (typeof obj === "object" && !Array.isArray(obj)) { + for (const [k, v] of Object.entries(obj as Record)) { + walk(v, k); + } + } else if (Array.isArray(obj)) { + for (const item of obj) { + walk(item); + } + } else if ( + key && + typeof obj === "string" && + SENSITIVE_KEY_RE.test(key) && + !SENSITIVE_KEY_WHITELIST_RE.test(key) + ) { + if (obj.trim() && !/^\$\{[^}]*\}$/.test(obj.trim())) { + count++; + } + } + } + walk(formValue); + return count; +} + +let rawRevealed = false; + export function renderConfig(props: ConfigProps) { const validity = props.valid == null ? "unknown" : props.valid ? "valid" : "invalid"; const analysis = analyzeConfigSchema(props.schema); @@ -649,6 +689,32 @@ export function renderConfig(props: ConfigProps) { : nothing }
+ ${ + props.activeSection === "env" + ? html` + + ` + : nothing + }
` : nothing @@ -682,7 +748,7 @@ export function renderConfig(props: ConfigProps) { } -
+
${ props.formMode === "form" ? html` @@ -716,16 +782,43 @@ export function renderConfig(props: ConfigProps) { : nothing } ` - : html` - - ` + : (() => { + const sensitiveCount = countSensitiveValues(props.formValue); + const blurred = sensitiveCount > 0 && (props.streamMode || !rawRevealed); + return html` + + `; + })() }
diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index e5cc32408..89527f83a 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -333,7 +333,7 @@ export function renderCron(props: CronProps) {
No runs yet.
` : html` -
+
${orderedRuns.map((entry) => renderRun(entry, props.basePath))}
` diff --git a/ui/src/ui/views/debug.ts b/ui/src/ui/views/debug.ts index 22ee3bce2..f5acb089a 100644 --- a/ui/src/ui/views/debug.ts +++ b/ui/src/ui/views/debug.ts @@ -1,12 +1,13 @@ import { html, nothing } from "lit"; import type { EventLogEntry } from "../app-events.ts"; import { formatEventPayload } from "../presenter.ts"; +import type { HealthSummary, ModelCatalogEntry } from "../types.ts"; export type DebugProps = { loading: boolean; status: Record | null; - health: Record | null; - models: unknown[]; + health: HealthSummary | null; + models: ModelCatalogEntry[]; heartbeat: unknown; eventLog: EventLogEntry[]; callMethod: string; @@ -119,26 +120,49 @@ export function renderDebug(props: DebugProps) {
-
Event Log
-
Latest gateway events.
+
+
+
Event Log
+
Latest gateway events.
+
+ ${ + props.eventLog.length > 0 + ? html`` + : nothing + } +
${ props.eventLog.length === 0 ? html`
No events yet.
` : html` -
+
${props.eventLog.map( (evt) => html` -
-
-
${evt.event}
-
${new Date(evt.ts).toLocaleTimeString()}
-
-
-
${formatEventPayload(evt.payload)}
-
-
+
+ + ${evt.event} + ${new Date(evt.ts).toLocaleTimeString()} + + ${ + evt.payload + ? html`
${formatEventPayload(evt.payload)}
` + : html` +
No payload.
+ ` + } +
`, )}
diff --git a/ui/src/ui/views/instances.ts b/ui/src/ui/views/instances.ts index df5fe5fd4..b805b7ea4 100644 --- a/ui/src/ui/views/instances.ts +++ b/ui/src/ui/views/instances.ts @@ -1,5 +1,6 @@ import { html, nothing } from "lit"; -import { formatPresenceAge, formatPresenceSummary } from "../presenter.ts"; +import { icons } from "../icons.ts"; +import { formatPresenceAge } from "../presenter.ts"; import type { PresenceEntry } from "../types.ts"; export type InstancesProps = { @@ -7,10 +8,15 @@ export type InstancesProps = { entries: PresenceEntry[]; lastError: string | null; statusMessage: string | null; + streamMode: boolean; onRefresh: () => void; }; +let hostsRevealed = false; + export function renderInstances(props: InstancesProps) { + const masked = props.streamMode || !hostsRevealed; + return html`
@@ -18,9 +24,24 @@ export function renderInstances(props: InstancesProps) {
Connected Instances
Presence beacons from the gateway and clients.
- +
+ + +
${ props.lastError @@ -42,16 +63,18 @@ export function renderInstances(props: InstancesProps) { ? html`
No instances reported yet.
` - : props.entries.map((entry) => renderEntry(entry)) + : props.entries.map((entry) => renderEntry(entry, masked)) }
`; } -function renderEntry(entry: PresenceEntry) { +function renderEntry(entry: PresenceEntry, masked: boolean) { const lastInput = entry.lastInputSeconds != null ? `${entry.lastInputSeconds}s ago` : "n/a"; const mode = entry.mode ?? "unknown"; + const host = entry.host ?? "unknown host"; + const ip = entry.ip ?? null; const roles = Array.isArray(entry.roles) ? entry.roles.filter(Boolean) : []; const scopes = Array.isArray(entry.scopes) ? entry.scopes.filter(Boolean) : []; const scopesLabel = @@ -63,8 +86,12 @@ function renderEntry(entry: PresenceEntry) { return html`
-
${entry.host ?? "unknown host"}
-
${formatPresenceSummary(entry)}
+
+ ${host} +
+
+ ${ip ? html`${ip} ` : nothing}${mode} ${entry.version ?? ""} +
${mode} ${roles.map((role) => html`${role}`)} diff --git a/ui/src/ui/views/login-gate.ts b/ui/src/ui/views/login-gate.ts new file mode 100644 index 000000000..624da9050 --- /dev/null +++ b/ui/src/ui/views/login-gate.ts @@ -0,0 +1,87 @@ +import { html } from "lit"; +import { t } from "../../i18n/index.ts"; +import { renderThemeToggle } from "../app-render.helpers.ts"; +import type { AppViewState } from "../app-view-state.ts"; +import { normalizeBasePath } from "../navigation.ts"; + +export function renderLoginGate(state: AppViewState) { + const basePath = normalizeBasePath(state.basePath ?? ""); + const faviconSrc = basePath ? `${basePath}/favicon.svg` : "/favicon.svg"; + + return html` + + `; +} diff --git a/ui/src/ui/views/overview-attention.ts b/ui/src/ui/views/overview-attention.ts new file mode 100644 index 000000000..e6762f3e2 --- /dev/null +++ b/ui/src/ui/views/overview-attention.ts @@ -0,0 +1,60 @@ +import { html, nothing } from "lit"; +import { t } from "../../i18n/index.ts"; +import { icons, type IconName } from "../icons.ts"; +import type { AttentionItem } from "../types.ts"; + +export type OverviewAttentionProps = { + items: AttentionItem[]; +}; + +function severityClass(severity: string) { + if (severity === "error") { + return "danger"; + } + if (severity === "warning") { + return "warn"; + } + return ""; +} + +function attentionIcon(name: string) { + if (name in icons) { + return icons[name as IconName]; + } + return icons.radio; +} + +export function renderOverviewAttention(props: OverviewAttentionProps) { + if (props.items.length === 0) { + return nothing; + } + + return html` +
+
${t("overview.attention.title")}
+
+ ${props.items.map( + (item) => html` +
+ ${attentionIcon(item.icon)} +
+
${item.title}
+
${item.description}
+
+ ${ + item.href + ? html`${t("common.docs")}` + : nothing + } +
+ `, + )} +
+
+ `; +} diff --git a/ui/src/ui/views/overview-cards.ts b/ui/src/ui/views/overview-cards.ts new file mode 100644 index 000000000..3d394a1df --- /dev/null +++ b/ui/src/ui/views/overview-cards.ts @@ -0,0 +1,129 @@ +import { html, nothing, type TemplateResult } from "lit"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import { t } from "../../i18n/index.ts"; +import { formatCost, formatTokens, formatRelativeTimestamp } from "../format.ts"; +import { icons } from "../icons.ts"; +import { formatNextRun } from "../presenter.ts"; +import type { + SessionsUsageResult, + SessionsListResult, + SkillStatusReport, + CronJob, + CronStatus, +} from "../types.ts"; + +export type OverviewCardsProps = { + usageResult: SessionsUsageResult | null; + sessionsResult: SessionsListResult | null; + skillsReport: SkillStatusReport | null; + cronJobs: CronJob[]; + cronStatus: CronStatus | null; + presenceCount: number; + redacted: boolean; + onNavigate: (tab: string) => void; +}; + +function redact(value: string, redacted: boolean) { + return redacted ? "••••••" : value; +} + +const DIGIT_RUN = /\d{3,}/g; + +function blurDigits(value: string): TemplateResult { + const escaped = value.replace(/&/g, "&").replace(//g, ">"); + const blurred = escaped.replace(DIGIT_RUN, (m) => `${m}`); + return html`${unsafeHTML(blurred)}`; +} + +export function renderOverviewCards(props: OverviewCardsProps) { + const totals = props.usageResult?.totals; + const totalCost = formatCost(totals?.totalCost); + const totalTokens = formatTokens(totals?.totalTokens); + const totalMessages = totals ? String(props.usageResult?.aggregates?.messages?.total ?? 0) : "0"; + const sessionCount = props.sessionsResult?.count ?? null; + + const skills = props.skillsReport?.skills ?? []; + const enabledSkills = skills.filter((s) => !s.disabled).length; + const blockedSkills = skills.filter((s) => s.blockedByAllowlist).length; + const totalSkills = skills.length; + + const cronEnabled = props.cronStatus?.enabled ?? null; + const cronNext = props.cronStatus?.nextWakeAtMs ?? null; + const cronJobCount = props.cronJobs.length; + const failedCronCount = props.cronJobs.filter((j) => j.state?.lastStatus === "error").length; + + return html` +
+
props.onNavigate("usage")}> +
+
${icons.barChart}
+
+
${t("overview.cards.cost")}
+
${redact(totalCost, props.redacted)}
+
${redact(`${totalTokens} tokens · ${totalMessages} msgs`, props.redacted)}
+
+
+
+
props.onNavigate("sessions")}> +
+
${icons.fileText}
+
+
${t("overview.stats.sessions")}
+
${sessionCount ?? t("common.na")}
+
${t("overview.stats.sessionsHint")}
+
+
+
+
props.onNavigate("skills")}> +
+
${icons.zap}
+
+
${t("overview.cards.skills")}
+
${enabledSkills}/${totalSkills}
+
${blockedSkills > 0 ? `${blockedSkills} blocked` : `${enabledSkills} active`}
+
+
+
+
props.onNavigate("cron")}> +
+
${icons.scrollText}
+
+
${t("overview.stats.cron")}
+
+ ${cronEnabled == null ? t("common.na") : cronEnabled ? `${cronJobCount} jobs` : t("common.disabled")} +
+
+ ${ + failedCronCount > 0 + ? html`${failedCronCount} failed` + : nothing + } + ${cronNext ? t("overview.stats.cronNext", { time: formatNextRun(cronNext) }) : ""} +
+
+
+
+
+ + ${ + props.sessionsResult && props.sessionsResult.sessions.length > 0 + ? html` +
+
${t("overview.cards.recentSessions")}
+
+ ${props.sessionsResult.sessions.slice(0, 5).map( + (s) => html` +
+ ${props.redacted ? redact(s.displayName || s.label || s.key, true) : blurDigits(s.displayName || s.label || s.key)} + ${s.model ?? ""} + ${s.updatedAt ? formatRelativeTimestamp(s.updatedAt) : ""} +
+ `, + )} +
+
+ ` + : nothing + } + `; +} diff --git a/ui/src/ui/views/overview-event-log.ts b/ui/src/ui/views/overview-event-log.ts new file mode 100644 index 000000000..f4636d3ec --- /dev/null +++ b/ui/src/ui/views/overview-event-log.ts @@ -0,0 +1,43 @@ +import { html, nothing } from "lit"; +import { t } from "../../i18n/index.ts"; +import type { EventLogEntry } from "../app-events.ts"; +import { icons } from "../icons.ts"; +import { formatEventPayload } from "../presenter.ts"; + +export type OverviewEventLogProps = { + events: EventLogEntry[]; + redacted: boolean; +}; + +export function renderOverviewEventLog(props: OverviewEventLogProps) { + if (props.events.length === 0) { + return nothing; + } + + const visible = props.events.slice(0, 20); + + return html` +
+ + ${icons.radio} + ${t("overview.eventLog.title")} + ${props.events.length} + +
+ ${visible.map( + (entry) => html` +
+ ${new Date(entry.ts).toLocaleTimeString()} + ${entry.event} + ${ + entry.payload + ? html`${formatEventPayload(entry.payload).slice(0, 120)}` + : nothing + } +
+ `, + )} +
+
+ `; +} diff --git a/ui/src/ui/views/overview-log-tail.ts b/ui/src/ui/views/overview-log-tail.ts new file mode 100644 index 000000000..7252f5905 --- /dev/null +++ b/ui/src/ui/views/overview-log-tail.ts @@ -0,0 +1,47 @@ +import { html, nothing } from "lit"; +import { t } from "../../i18n/index.ts"; +import { icons } from "../icons.ts"; + +/** Strip ANSI escape codes (SGR, OSC-8) for readable log display. */ +function stripAnsi(text: string): string { + /* eslint-disable no-control-regex -- stripping ANSI escape sequences requires matching ESC */ + return text.replace(/\x1b\]8;;.*?\x1b\\|\x1b\]8;;\x1b\\/g, "").replace(/\x1b\[[0-9;]*m/g, ""); +} + +export type OverviewLogTailProps = { + lines: string[]; + redacted: boolean; + onRefreshLogs: () => void; +}; + +export function renderOverviewLogTail(props: OverviewLogTailProps) { + if (props.lines.length === 0) { + return nothing; + } + + const displayLines = props.redacted + ? "[log hidden]" + : props.lines + .slice(-50) + .map((line) => stripAnsi(line)) + .join("\n"); + + return html` +
+ + ${icons.scrollText} + ${t("overview.logTail.title")} + ${props.lines.length} + { + e.preventDefault(); + e.stopPropagation(); + props.onRefreshLogs(); + }} + >${icons.loader} + +
${displayLines}
+
+ `; +} diff --git a/ui/src/ui/views/overview-quick-actions.ts b/ui/src/ui/views/overview-quick-actions.ts new file mode 100644 index 000000000..b1358ca2e --- /dev/null +++ b/ui/src/ui/views/overview-quick-actions.ts @@ -0,0 +1,31 @@ +import { html } from "lit"; +import { t } from "../../i18n/index.ts"; +import { icons } from "../icons.ts"; + +export type OverviewQuickActionsProps = { + onNavigate: (tab: string) => void; + onRefresh: () => void; +}; + +export function renderOverviewQuickActions(props: OverviewQuickActionsProps) { + return html` +
+ + + + +
+ `; +} diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts index 6d94ea1fd..946e4bfc8 100644 --- a/ui/src/ui/views/overview.ts +++ b/ui/src/ui/views/overview.ts @@ -1,9 +1,22 @@ -import { html } from "lit"; +import { html, nothing } from "lit"; import { t, i18n, type Locale } from "../../i18n/index.ts"; +import type { EventLogEntry } from "../app-events.ts"; import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts"; import type { GatewayHelloOk } from "../gateway.ts"; -import { formatNextRun } from "../presenter.ts"; +import { icons } from "../icons.ts"; import type { UiSettings } from "../storage.ts"; +import type { + AttentionItem, + CronJob, + CronStatus, + SessionsListResult, + SessionsUsageResult, + SkillStatusReport, +} from "../types.ts"; +import { renderOverviewAttention } from "./overview-attention.ts"; +import { renderOverviewCards } from "./overview-cards.ts"; +import { renderOverviewEventLog } from "./overview-event-log.ts"; +import { renderOverviewLogTail } from "./overview-log-tail.ts"; export type OverviewProps = { connected: boolean; @@ -16,11 +29,24 @@ export type OverviewProps = { cronEnabled: boolean | null; cronNext: number | null; lastChannelsRefresh: number | null; + // New dashboard data + usageResult: SessionsUsageResult | null; + sessionsResult: SessionsListResult | null; + skillsReport: SkillStatusReport | null; + cronJobs: CronJob[]; + cronStatus: CronStatus | null; + attentionItems: AttentionItem[]; + eventLog: EventLogEntry[]; + overviewLogLines: string[]; + streamMode: boolean; onSettingsChange: (next: UiSettings) => void; onPasswordChange: (next: string) => void; onSessionKeyChange: (next: string) => void; onConnect: () => void; onRefresh: () => void; + onNavigate: (tab: string) => void; + onRefreshLogs: () => void; + onToggleStreamMode: () => void; }; export function renderOverview(props: OverviewProps) { @@ -33,7 +59,7 @@ export function renderOverview(props: OverviewProps) { | undefined; const uptime = snapshot?.uptimeMs ? formatDurationHuman(snapshot.uptimeMs) : t("common.na"); const tick = snapshot?.policy?.tickIntervalMs - ? `${snapshot.policy.tickIntervalMs}ms` + ? `${(snapshot.policy.tickIntervalMs / 1000).toFixed(snapshot.policy.tickIntervalMs % 1000 === 0 ? 0 : 1)}s` : t("common.na"); const authMode = snapshot?.authMode; const isTrustedProxy = authMode === "trusted-proxy"; @@ -135,7 +161,7 @@ export function renderOverview(props: OverviewProps) {
${t("overview.access.title")}
${t("overview.access.subtitle")}
-
+
+ ${ + !props.connected + ? html` +
+
${t("overview.connection.title")}
+
    +
  1. ${t("overview.connection.step1")} +
    openclaw gateway run
    +
  2. +
  3. ${t("overview.connection.step2")} +
    openclaw dashboard --no-open
    +
  4. +
  5. ${t("overview.connection.step3")}
  6. +
  7. ${t("overview.connection.step4")} +
    openclaw doctor --generate-gateway-token
    +
  8. +
+
+ ${t("overview.connection.docsHint")} + ${t("overview.connection.docsLink")} +
+
+ ` + : nothing + }
@@ -253,45 +311,43 @@ export function renderOverview(props: OverviewProps) {
-
-
-
${t("overview.stats.instances")}
-
${props.presenceCount}
-
${t("overview.stats.instancesHint")}
-
-
-
${t("overview.stats.sessions")}
-
${props.sessionsCount ?? t("common.na")}
-
${t("overview.stats.sessionsHint")}
-
-
-
${t("overview.stats.cron")}
-
- ${props.cronEnabled == null ? t("common.na") : props.cronEnabled ? t("common.enabled") : t("common.disabled")} -
-
${t("overview.stats.cronNext", { time: formatNextRun(props.cronNext) })}
-
-
+ ${ + props.streamMode + ? html`
+ ${icons.radio} + ${t("overview.streamMode.active")} + +
` + : nothing + } + + ${renderOverviewCards({ + usageResult: props.usageResult, + sessionsResult: props.sessionsResult, + skillsReport: props.skillsReport, + cronJobs: props.cronJobs, + cronStatus: props.cronStatus, + presenceCount: props.presenceCount, + redacted: props.streamMode, + onNavigate: props.onNavigate, + })} + + ${renderOverviewAttention({ items: props.attentionItems })} + +
+ ${renderOverviewEventLog({ + events: props.eventLog, + redacted: props.streamMode, + })} + + ${renderOverviewLogTail({ + lines: props.overviewLogLines, + redacted: props.streamMode, + onRefreshLogs: props.onRefreshLogs, + })} +
-
-
${t("overview.notes.title")}
-
${t("overview.notes.subtitle")}
-
-
-
${t("overview.notes.tailscaleTitle")}
-
- ${t("overview.notes.tailscaleText")} -
-
-
-
${t("overview.notes.sessionTitle")}
-
${t("overview.notes.sessionText")}
-
-
-
${t("overview.notes.cronTitle")}
-
${t("overview.notes.cronText")}
-
-
-
`; } diff --git a/ui/src/ui/views/usage-styles/usageStyles-part1.ts b/ui/src/ui/views/usage-styles/usageStyles-part1.ts index 1df314e46..a6f595170 100644 --- a/ui/src/ui/views/usage-styles/usageStyles-part1.ts +++ b/ui/src/ui/views/usage-styles/usageStyles-part1.ts @@ -54,16 +54,16 @@ export const usageStylesPart1 = ` align-items: center; gap: 6px; padding: 4px 10px; - background: rgba(255, 77, 77, 0.1); + background: color-mix(in srgb, var(--accent) 10%, transparent); border-radius: 4px; font-size: 12px; - color: #ff4d4d; + color: var(--accent); } .usage-refresh-indicator::before { content: ""; width: 10px; height: 10px; - border: 2px solid #ff4d4d; + border: 2px solid var(--accent); border-top-color: transparent; border-radius: 50%; animation: usage-spin 0.6s linear infinite; @@ -161,36 +161,36 @@ export const usageStylesPart1 = ` border-color: var(--border-strong); } .usage-primary-btn { - background: #ff4d4d; + background: var(--accent); color: #fff; - border-color: #ff4d4d; + border-color: var(--accent); box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.12); } .btn.usage-primary-btn { - background: #ff4d4d !important; - border-color: #ff4d4d !important; + background: var(--accent) !important; + border-color: var(--accent) !important; color: #fff !important; } .usage-primary-btn:hover { - background: #e64545; - border-color: #e64545; + background: var(--accent-strong); + border-color: var(--accent-strong); } .btn.usage-primary-btn:hover { - background: #e64545 !important; - border-color: #e64545 !important; + background: var(--accent-strong) !important; + border-color: var(--accent-strong) !important; } .usage-primary-btn:disabled { - background: rgba(255, 77, 77, 0.18); - border-color: rgba(255, 77, 77, 0.3); - color: #ff4d4d; + background: color-mix(in srgb, var(--accent) 18%, transparent); + border-color: color-mix(in srgb, var(--accent) 30%, transparent); + color: var(--accent); box-shadow: none; cursor: default; opacity: 1; } .usage-primary-btn[disabled] { - background: rgba(255, 77, 77, 0.18) !important; - border-color: rgba(255, 77, 77, 0.3) !important; - color: #ff4d4d !important; + background: color-mix(in srgb, var(--accent) 18%, transparent) !important; + border-color: color-mix(in srgb, var(--accent) 30%, transparent) !important; + color: var(--accent) !important; opacity: 1 !important; } .usage-secondary-btn { @@ -533,8 +533,8 @@ export const usageStylesPart1 = ` border-radius: 8px; padding: 10px; color: var(--text); - background: rgba(255, 77, 77, 0.08); - border: 1px solid rgba(255, 77, 77, 0.2); + background: color-mix(in srgb, var(--accent) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent); display: flex; flex-direction: column; gap: 4px; @@ -554,14 +554,14 @@ export const usageStylesPart1 = ` .usage-hour-cell { height: 28px; border-radius: 6px; - background: rgba(255, 77, 77, 0.1); - border: 1px solid rgba(255, 77, 77, 0.2); + background: color-mix(in srgb, var(--accent) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent); cursor: pointer; transition: border-color 0.15s, box-shadow 0.15s; } .usage-hour-cell.selected { - border-color: rgba(255, 77, 77, 0.8); - box-shadow: 0 0 0 2px rgba(255, 77, 77, 0.2); + border-color: color-mix(in srgb, var(--accent) 80%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); } .usage-hour-labels { display: grid; @@ -584,8 +584,8 @@ export const usageStylesPart1 = ` width: 14px; height: 10px; border-radius: 4px; - background: rgba(255, 77, 77, 0.15); - border: 1px solid rgba(255, 77, 77, 0.2); + background: color-mix(in srgb, var(--accent) 15%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent); } .usage-calendar-labels { display: grid; @@ -603,8 +603,8 @@ export const usageStylesPart1 = ` .usage-calendar-cell { height: 18px; border-radius: 4px; - border: 1px solid rgba(255, 77, 77, 0.2); - background: rgba(255, 77, 77, 0.08); + border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent); + background: color-mix(in srgb, var(--accent) 8%, transparent); } .usage-calendar-cell.empty { background: transparent; diff --git a/ui/src/ui/views/usage-styles/usageStyles-part2.ts b/ui/src/ui/views/usage-styles/usageStyles-part2.ts index 75826aec3..98400390d 100644 --- a/ui/src/ui/views/usage-styles/usageStyles-part2.ts +++ b/ui/src/ui/views/usage-styles/usageStyles-part2.ts @@ -100,7 +100,7 @@ export const usageStylesPart2 = ` color: var(--text); } .chart-toggle .toggle-btn.active { - background: #ff4d4d; + background: var(--accent); color: white; } .chart-toggle.small .toggle-btn { @@ -157,14 +157,14 @@ export const usageStylesPart2 = ` .daily-bar { width: 100%; max-width: var(--bar-max-width, 32px); - background: #ff4d4d; + background: var(--accent); border-radius: 3px 3px 0 0; min-height: 2px; transition: all 0.15s; overflow: hidden; } .daily-bar-wrapper:hover .daily-bar { - background: #cc3d3d; + background: var(--accent-strong); } .daily-bar-label { position: absolute; @@ -282,7 +282,7 @@ export const usageStylesPart2 = ` background: #06b6d4; } .legend-dot.system { - background: #ff4d4d; + background: var(--accent); } .legend-dot.skills { background: #8b5cf6; @@ -360,7 +360,7 @@ export const usageStylesPart2 = ` } .session-bar-fill { height: 100%; - background: rgba(255, 77, 77, 0.7); + background: color-mix(in srgb, var(--accent) 70%, transparent); border-radius: 4px; transition: width 0.3s ease; } @@ -431,27 +431,27 @@ export const usageStylesPart2 = ` fill: var(--muted); } .timeseries-svg .ts-area { - fill: #ff4d4d; + fill: var(--accent); fill-opacity: 0.1; } .timeseries-svg .ts-line { fill: none; - stroke: #ff4d4d; + stroke: var(--accent); stroke-width: 2; } .timeseries-svg .ts-dot { - fill: #ff4d4d; + fill: var(--accent); transition: r 0.15s, fill 0.15s; } .timeseries-svg .ts-dot:hover { r: 5; } .timeseries-svg .ts-bar { - fill: #ff4d4d; + fill: var(--accent); transition: fill 0.15s; } .timeseries-svg .ts-bar:hover { - fill: #cc3d3d; + fill: var(--accent-strong); } .timeseries-svg .ts-bar.output { fill: #ef4444; } .timeseries-svg .ts-bar.input { fill: #f59e0b; } @@ -582,7 +582,7 @@ export const usageStylesPart2 = ` transition: width 0.3s ease; } .context-segment.system { - background: #ff4d4d; + background: var(--accent); } .context-segment.skills { background: #8b5cf6; diff --git a/ui/src/ui/views/usage-styles/usageStyles-part3.ts b/ui/src/ui/views/usage-styles/usageStyles-part3.ts index 8a114ab69..e78cfa63e 100644 --- a/ui/src/ui/views/usage-styles/usageStyles-part3.ts +++ b/ui/src/ui/views/usage-styles/usageStyles-part3.ts @@ -121,7 +121,7 @@ export const usageStylesPart3 = ` .sessions-card .session-bar-row.selected { border-color: var(--accent); background: var(--accent-subtle); - box-shadow: inset 0 0 0 1px rgba(255, 77, 77, 0.15); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 15%, transparent); } .sessions-card .session-bar-label { flex: 1 1 auto; @@ -139,7 +139,7 @@ export const usageStylesPart3 = ` opacity: 0.5; } .sessions-card .session-bar-fill { - background: rgba(255, 77, 77, 0.55); + background: color-mix(in srgb, var(--accent) 55%, transparent); } .sessions-clear-btn { margin-left: auto; diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 161cb9dae..988b439fd 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -34,7 +34,7 @@ export default defineConfig(() => { }, server: { host: true, - port: 5173, + port: 5174, strictPort: true, }, };