Merge branch 'main' into codex/cortex-openclaw-integration

This commit is contained in:
Marc J Saint-jour 2026-03-12 21:55:02 -04:00 committed by GitHub
commit 98ac50f798
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
157 changed files with 4143 additions and 1164 deletions

View File

@ -4,6 +4,8 @@ Docs: https://docs.openclaw.ai
## Unreleased
## 2026.3.12
### Changes
- Agents/subagents: add `sessions_yield` so orchestrators can end the current turn immediately, skip queued tool work, and carry a hidden follow-up payload into the next session turn. (#36537) thanks @jriff
@ -15,6 +17,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Models/OpenAI Codex Spark: keep `gpt-5.3-codex-spark` working on the `openai-codex/*` path via resolver fallbacks and clearer Codex-only handling, while continuing to suppress the stale direct `openai/*` Spark row that OpenAI rejects live.
- Ollama/Kimi Cloud: apply the Moonshot Kimi payload compatibility wrapper to Ollama-hosted Kimi models like `kimi-k2.5:cloud`, so tool routing no longer breaks when thinking is enabled. (#41519) Thanks @vincentkoc.
- Models/Kimi Coding: send the built-in `User-Agent: claude-code/0.1.0` header by default for `kimi-coding` while still allowing explicit provider headers to override it, so Kimi Code subscription auth can work without a local header-injection proxy. (#30099) Thanks @Amineelfarssi and @vincentkoc.
- Security/device pairing: switch `/pair` and `openclaw qr` setup codes to short-lived bootstrap tokens so the next release no longer embeds shared gateway credentials in chat or QR pairing payloads. Thanks @lintsinghua.
@ -38,6 +41,7 @@ Docs: https://docs.openclaw.ai
- Windows/native update: make package installs use the npm update path instead of the git path, carry portable Git into native Windows updates, and mirror the installer's Windows npm env so `openclaw update` no longer dies early on missing `git` or `node-llama-cpp` download setup.
- Sandbox/write: preserve pinned mutation-helper payload stdin so sandboxed `write` no longer reports success while creating empty files. (#43876) Thanks @glitch418x.
- Security/exec approvals: escape invisible Unicode format characters in approval prompts so zero-width command text renders as visible `\u{...}` escapes instead of spoofing the reviewed command. (`GHSA-pcqg-f7rg-xfvv`)(#43687) Thanks @EkiXu and @vincentkoc.
- Hooks/loader: fail closed when workspace hook paths cannot be resolved with `realpath`, so unreadable or broken internal hook paths are skipped instead of falling back to unresolved imports. (#44437) Thanks @vincentkoc.
- Hooks/agent deliveries: dedupe repeated hook requests by optional idempotency key so webhook retries can reuse the first run instead of launching duplicate agent executions. (#44438) Thanks @vincentkoc.
- Security/exec detection: normalize compatibility Unicode and strip invisible formatting code points before obfuscation checks so zero-width and fullwidth command tricks no longer suppress heuristic detection. (`GHSA-9r3v-37xh-2cf6`)(#44091) Thanks @wooluo and @vincentkoc.
- Security/exec allowlist: preserve POSIX case sensitivity and keep `?` within a single path segment so exact-looking allowlist patterns no longer overmatch executables across case or directory boundaries. (`GHSA-f8r2-vg7x-gh8m`)(#43798) Thanks @zpbrent and @vincentkoc.
@ -57,6 +61,8 @@ Docs: https://docs.openclaw.ai
- Security/Feishu reactions: preserve looked-up group chat typing and fail closed on ambiguous reaction context so group authorization and mention gating cannot be bypassed through synthetic `p2p` reactions. (`GHSA-m69h-jm2f-2pv8`)(#44088) Thanks @zpbrent and @vincentkoc.
- Security/LINE webhook: require signatures for empty-event POST probes too so unsigned requests no longer confirm webhook reachability with a `200` response. (`GHSA-mhxh-9pjm-w7q5`)(#44090) Thanks @TerminalsandCoffee and @vincentkoc.
- Security/Zalo webhook: rate limit invalid secret guesses before auth so weak webhook secrets cannot be brute-forced through unauthenticated churned requests without pre-auth `429` responses. (`GHSA-5m9r-p9g7-679c`)(#44173) Thanks @zpbrent and @vincentkoc.
- Security/Zalouser groups: require stable group IDs for allowlist auth by default and gate mutable group-name matching behind `channels.zalouser.dangerouslyAllowNameMatching`. Thanks @zpbrent.
- Security/Slack and Teams routing: require stable channel and team IDs for allowlist routing by default, with mutable name matching only via each channel's `dangerouslyAllowNameMatching` break-glass flag.
- Security/exec approvals: fail closed for ambiguous inline loader and shell-payload script execution, bind the real script after POSIX shell value-taking flags, and unwrap `pnpm`/`npm exec`/`npx` script runners before approval binding. (`GHSA-57jw-9722-6rf2`)(`GHSA-jvqh-rfmh-jh27`)(`GHSA-x7pp-23xv-mmr4`)(`GHSA-jc5j-vg4r-j5jx`)(#44247) Thanks @tdjackey and @vincentkoc.
- Doctor/gateway service audit: canonicalize service entrypoint paths before comparing them so symlink-vs-realpath installs no longer trigger false "entrypoint does not match the current install" repair prompts. (#43882) Thanks @ngutman.
- Doctor/gateway service audit: earlier groundwork for this fix landed in the superseded #28338 branch. Thanks @realriphub.
@ -65,12 +71,14 @@ Docs: https://docs.openclaw.ai
- Agents/failover: classify z.ai `network_error` stop reasons as retryable timeouts so provider connectivity failures trigger fallback instead of surfacing raw unhandled-stop-reason errors. (#43884) Thanks @hougangdev.
- Memory/session sync: add mode-aware post-compaction session reindexing with `agents.defaults.compaction.postIndexSync` plus `agents.defaults.memorySearch.sync.sessions.postCompactionForce`, so compacted session memory can refresh immediately without forcing every deployment into synchronous reindexing. (#25561) thanks @rodrigouroz.
- Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in `/models` button validation. (#40105) Thanks @avirweb.
- Telegram/native command sync: suppress expected `BOT_COMMANDS_TOO_MUCH` retry error noise, add a final fallback summary log, and document the difference between command-menu overflow and real Telegram network failures.
- Mattermost/reply media delivery: pass agent-scoped `mediaLocalRoots` through shared reply delivery so allowed local files upload correctly from button, slash-command, and model-picker replies. (#44021) Thanks @LyleLiu666.
- Plugins/env-scoped roots: fix plugin discovery/load caches and provenance tracking so same-process `HOME`/`OPENCLAW_HOME` changes no longer reuse stale plugin state or misreport `~/...` plugins as untracked. (#44046) thanks @gumadeiras.
- Gateway/session discovery: discover disk-only and retired ACP session stores under custom templated `session.store` roots so ACP reconciliation, session-id/session-label targeting, and run-id fallback keep working after restart. (#44176) thanks @gumadeiras.
- Models/OpenRouter native ids: canonicalize native OpenRouter model keys across config writes, runtime lookups, fallback management, and `models list --plain`, and migrate legacy duplicated `openrouter/openrouter/...` config entries forward on write.
- Gateway/hooks: bucket hook auth failures by forwarded client IP behind trusted proxies and warn when `hooks.allowedAgentIds` leaves hook routing unrestricted.
- Agents/compaction: skip the post-compaction `cache-ttl` marker write when a compaction completed in the same attempt, preventing the next turn from immediately triggering a second tiny compaction. (#28548) thanks @MoerAI.
- Native chat/macOS: add `/new`, `/reset`, and `/clear` reset triggers, keep shared main-session aliases aligned, and ignore stale model-selection completions so native chat state stays in sync across reset and fast model changes. (#10898) Thanks @Nachx639.
## 2026.3.11
@ -106,6 +114,8 @@ Docs: https://docs.openclaw.ai
### Fixes
- Windows/install: stop auto-installing `node-llama-cpp` during normal npm CLI installs so `openclaw@latest` no longer fails on Windows while building optional local-embedding dependencies.
- Windows/update: mirror the native installer environment during global npm updates, including portable Git fallback and Windows-safe npm shell settings, so `openclaw update` works again on native Windows installs.
- Gateway/status: expose `runtimeVersion` in gateway status output so install/update smoke tests can verify the running version before and after updates.
- Agents/text sanitization: strip leaked model control tokens (`<|...|>` and full-width `<...>` variants) from user-facing assistant text, preventing GLM-5 and DeepSeek internal delimiters from reaching end users. (#42173) Thanks @imwyvern.
- iOS/gateway foreground recovery: reconnect immediately on foreground return after stale background sockets are torn down, so the app no longer stays disconnected until a later wake path happens. (#41384) Thanks @mbelinky.
- Gateway/Control UI: keep dashboard auth tokens in session-scoped browser storage so same-tab refreshes preserve remote token auth without restoring long-lived localStorage token persistence, while scoping tokens to the selected gateway URL and fragment-only bootstrap flow. (#40892) thanks @velvet-shark.

View File

@ -63,8 +63,8 @@ android {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = 202603110
versionName = "2026.3.11"
versionCode = 202603120
versionName = "2026.3.12"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")

View File

@ -65,9 +65,9 @@ Release behavior:
- Beta release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, and `OpenClawPushAPNsEnvironment=production`.
- The beta flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
- Root `package.json.version` is the only version source for iOS.
- A root version like `2026.3.11-beta.1` becomes:
- `CFBundleShortVersionString = 2026.3.11`
- `CFBundleVersion = next TestFlight build number for 2026.3.11`
- A root version like `2026.3.12-beta.1` becomes:
- `CFBundleShortVersionString = 2026.3.12`
- `CFBundleVersion = next TestFlight build number for 2026.3.12`
Required env for beta builds:

View File

@ -39,6 +39,13 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
// (chat.subscribe is a node event, not an operator RPC method.)
}
func resetSession(sessionKey: String) async throws {
struct Params: Codable { var key: String }
let data = try JSONEncoder().encode(Params(key: sessionKey))
let json = String(data: data, encoding: .utf8)
_ = try await self.gateway.request(method: "sessions.reset", paramsJSON: json, timeoutSeconds: 10)
}
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
struct Params: Codable { var sessionKey: String }
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey))

View File

@ -26,5 +26,10 @@ import Testing
_ = try await transport.requestHealth(timeoutMs: 250)
Issue.record("Expected requestHealth to throw when gateway not connected")
} catch {}
do {
try await transport.resetSession(sessionKey: "node-test")
Issue.record("Expected resetSession to throw when gateway not connected")
} catch {}
}
}

View File

@ -99,7 +99,7 @@ def normalize_release_version(raw_value)
version = raw_value.to_s.strip.sub(/\Av/, "")
UI.user_error!("Missing root package.json version.") unless env_present?(version)
unless version.match?(/\A\d+\.\d+\.\d+(?:[.-]?beta[.-]\d+)?\z/i)
UI.user_error!("Invalid package.json version '#{raw_value}'. Expected 2026.3.11 or 2026.3.11-beta.1.")
UI.user_error!("Invalid package.json version '#{raw_value}'. Expected 2026.3.12 or 2026.3.12-beta.1.")
end
version

View File

@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.11</string>
<string>2026.3.12</string>
<key>CFBundleVersion</key>
<string>202603110</string>
<string>202603120</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>

View File

@ -59,7 +59,23 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
method: "sessions.list",
params: params,
timeoutMs: 15000)
return try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: data)
let decoded = try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: data)
let mainSessionKey = await GatewayConnection.shared.cachedMainSessionKey()
let defaults = decoded.defaults.map {
OpenClawChatSessionsDefaults(
model: $0.model,
contextTokens: $0.contextTokens,
mainSessionKey: mainSessionKey)
} ?? OpenClawChatSessionsDefaults(
model: nil,
contextTokens: nil,
mainSessionKey: mainSessionKey)
return OpenClawChatSessionsListResponse(
ts: decoded.ts,
path: decoded.path,
count: decoded.count,
defaults: defaults,
sessions: decoded.sessions)
}
func setSessionModel(sessionKey: String, model: String?) async throws {
@ -103,6 +119,13 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs)
}
func resetSession(sessionKey: String) async throws {
_ = try await GatewayConnection.shared.request(
method: "sessions.reset",
params: ["key": AnyCodable(sessionKey)],
timeoutMs: 10000)
}
func events() -> AsyncStream<OpenClawChatTransportEvent> {
AsyncStream { continuation in
let task = Task {

View File

@ -1322,6 +1322,7 @@ public struct SessionsPatchParams: Codable, Sendable {
public let key: String
public let label: AnyCodable?
public let thinkinglevel: AnyCodable?
public let fastmode: AnyCodable?
public let verboselevel: AnyCodable?
public let reasoninglevel: AnyCodable?
public let responseusage: AnyCodable?
@ -1343,6 +1344,7 @@ public struct SessionsPatchParams: Codable, Sendable {
key: String,
label: AnyCodable?,
thinkinglevel: AnyCodable?,
fastmode: AnyCodable?,
verboselevel: AnyCodable?,
reasoninglevel: AnyCodable?,
responseusage: AnyCodable?,
@ -1363,6 +1365,7 @@ public struct SessionsPatchParams: Codable, Sendable {
self.key = key
self.label = label
self.thinkinglevel = thinkinglevel
self.fastmode = fastmode
self.verboselevel = verboselevel
self.reasoninglevel = reasoninglevel
self.responseusage = responseusage
@ -1385,6 +1388,7 @@ public struct SessionsPatchParams: Codable, Sendable {
case key
case label
case thinkinglevel = "thinkingLevel"
case fastmode = "fastMode"
case verboselevel = "verboseLevel"
case reasoninglevel = "reasoningLevel"
case responseusage = "responseUsage"

View File

@ -34,6 +34,13 @@ public struct OpenClawChatModelChoice: Identifiable, Codable, Sendable, Hashable
public struct OpenClawChatSessionsDefaults: Codable, Sendable {
public let model: String?
public let contextTokens: Int?
public let mainSessionKey: String?
public init(model: String?, contextTokens: Int?, mainSessionKey: String? = nil) {
self.model = model
self.contextTokens = contextTokens
self.mainSessionKey = mainSessionKey
}
}
public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashable {
@ -69,4 +76,18 @@ public struct OpenClawChatSessionsListResponse: Codable, Sendable {
public let count: Int?
public let defaults: OpenClawChatSessionsDefaults?
public let sessions: [OpenClawChatSessionEntry]
public init(
ts: Double?,
path: String?,
count: Int?,
defaults: OpenClawChatSessionsDefaults?,
sessions: [OpenClawChatSessionEntry])
{
self.ts = ts
self.path = path
self.count = count
self.defaults = defaults
self.sessions = sessions
}
}

View File

@ -27,11 +27,19 @@ public protocol OpenClawChatTransport: Sendable {
func events() -> AsyncStream<OpenClawChatTransportEvent>
func setActiveSessionKey(_ sessionKey: String) async throws
func resetSession(sessionKey: String) async throws
}
extension OpenClawChatTransport {
public func setActiveSessionKey(_: String) async throws {}
public func resetSession(sessionKey _: String) async throws {
throw NSError(
domain: "OpenClawChatTransport",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "sessions.reset not supported by this transport"])
}
public func abortRun(sessionKey _: String, runId _: String) async throws {
throw NSError(
domain: "OpenClawChatTransport",

View File

@ -138,21 +138,23 @@ public final class OpenClawChatViewModel {
let now = Date().timeIntervalSince1970 * 1000
let cutoff = now - (24 * 60 * 60 * 1000)
let sorted = self.sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
let mainSessionKey = self.resolvedMainSessionKey
var result: [OpenClawChatSessionEntry] = []
var included = Set<String>()
// Always show the main session first, even if it hasn't been updated recently.
if let main = sorted.first(where: { $0.key == "main" }) {
// Always show the resolved main session first, even if it hasn't been updated recently.
if let main = sorted.first(where: { $0.key == mainSessionKey }) {
result.append(main)
included.insert(main.key)
} else {
result.append(self.placeholderSession(key: "main"))
included.insert("main")
result.append(self.placeholderSession(key: mainSessionKey))
included.insert(mainSessionKey)
}
for entry in sorted {
guard !included.contains(entry.key) else { continue }
guard entry.key == self.sessionKey || !Self.isHiddenInternalSession(entry.key) else { continue }
guard (entry.updatedAt ?? 0) >= cutoff else { continue }
result.append(entry)
included.insert(entry.key)
@ -169,6 +171,18 @@ public final class OpenClawChatViewModel {
return result
}
private var resolvedMainSessionKey: String {
let trimmed = self.sessionDefaults?.mainSessionKey?
.trimmingCharacters(in: .whitespacesAndNewlines)
return (trimmed?.isEmpty == false ? trimmed : nil) ?? "main"
}
private static func isHiddenInternalSession(_ key: String) -> Bool {
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
return trimmed == "onboarding" || trimmed.hasSuffix(":onboarding")
}
public var showsModelPicker: Bool {
!self.modelChoices.isEmpty
}
@ -365,10 +379,19 @@ public final class OpenClawChatViewModel {
return "\(message.role)|\(timestamp)|\(text)"
}
private static let resetTriggers: Set<String> = ["/new", "/reset", "/clear"]
private func performSend() async {
guard !self.isSending else { return }
let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty || !self.attachments.isEmpty else { return }
if Self.resetTriggers.contains(trimmed.lowercased()) {
self.input = ""
await self.performReset()
return
}
let sessionKey = self.sessionKey
guard self.healthOK else {
@ -499,6 +522,22 @@ public final class OpenClawChatViewModel {
await self.bootstrap()
}
private func performReset() async {
self.isLoading = true
self.errorText = nil
defer { self.isLoading = false }
do {
try await self.transport.resetSession(sessionKey: self.sessionKey)
} catch {
self.errorText = error.localizedDescription
chatUILogger.error("session reset failed \(error.localizedDescription, privacy: .public)")
return
}
await self.bootstrap()
}
private func performSelectThinkingLevel(_ level: String) async {
let next = Self.normalizedThinkingLevel(level) ?? "off"
guard next != self.thinkingLevel else { return }
@ -549,7 +588,9 @@ public final class OpenClawChatViewModel {
sessionKey: sessionKey,
model: nextModelRef)
guard requestID == self.latestModelSelectionRequestIDsBySession[sessionKey] else {
self.applySuccessfulModelSelection(next, sessionKey: sessionKey, syncSelection: false)
// Keep older successful patches as rollback state, but do not replay
// stale UI/session state over a newer in-flight or completed selection.
self.lastSuccessfulModelSelectionIDsBySession[sessionKey] = next
return
}
self.applySuccessfulModelSelection(next, sessionKey: sessionKey, syncSelection: true)

View File

@ -1322,6 +1322,7 @@ public struct SessionsPatchParams: Codable, Sendable {
public let key: String
public let label: AnyCodable?
public let thinkinglevel: AnyCodable?
public let fastmode: AnyCodable?
public let verboselevel: AnyCodable?
public let reasoninglevel: AnyCodable?
public let responseusage: AnyCodable?
@ -1343,6 +1344,7 @@ public struct SessionsPatchParams: Codable, Sendable {
key: String,
label: AnyCodable?,
thinkinglevel: AnyCodable?,
fastmode: AnyCodable?,
verboselevel: AnyCodable?,
reasoninglevel: AnyCodable?,
responseusage: AnyCodable?,
@ -1363,6 +1365,7 @@ public struct SessionsPatchParams: Codable, Sendable {
self.key = key
self.label = label
self.thinkinglevel = thinkinglevel
self.fastmode = fastmode
self.verboselevel = verboselevel
self.reasoninglevel = reasoninglevel
self.responseusage = responseusage
@ -1385,6 +1388,7 @@ public struct SessionsPatchParams: Codable, Sendable {
case key
case label
case thinkinglevel = "thinkingLevel"
case fastmode = "fastMode"
case verboselevel = "verboseLevel"
case reasoninglevel = "reasoningLevel"
case responseusage = "responseUsage"

View File

@ -83,6 +83,7 @@ private func makeViewModel(
historyResponses: [OpenClawChatHistoryPayload],
sessionsResponses: [OpenClawChatSessionsListResponse] = [],
modelResponses: [[OpenClawChatModelChoice]] = [],
resetSessionHook: (@Sendable (String) async throws -> Void)? = nil,
setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil,
setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil,
initialThinkingLevel: String? = nil,
@ -93,6 +94,7 @@ private func makeViewModel(
historyResponses: historyResponses,
sessionsResponses: sessionsResponses,
modelResponses: modelResponses,
resetSessionHook: resetSessionHook,
setSessionModelHook: setSessionModelHook,
setSessionThinkingHook: setSessionThinkingHook)
let vm = await MainActor.run {
@ -199,6 +201,7 @@ private actor TestChatTransportState {
var historyCallCount: Int = 0
var sessionsCallCount: Int = 0
var modelsCallCount: Int = 0
var resetSessionKeys: [String] = []
var sentRunIds: [String] = []
var sentThinkingLevels: [String] = []
var abortedRunIds: [String] = []
@ -211,6 +214,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
private let historyResponses: [OpenClawChatHistoryPayload]
private let sessionsResponses: [OpenClawChatSessionsListResponse]
private let modelResponses: [[OpenClawChatModelChoice]]
private let resetSessionHook: (@Sendable (String) async throws -> Void)?
private let setSessionModelHook: (@Sendable (String?) async throws -> Void)?
private let setSessionThinkingHook: (@Sendable (String) async throws -> Void)?
@ -221,12 +225,14 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
historyResponses: [OpenClawChatHistoryPayload],
sessionsResponses: [OpenClawChatSessionsListResponse] = [],
modelResponses: [[OpenClawChatModelChoice]] = [],
resetSessionHook: (@Sendable (String) async throws -> Void)? = nil,
setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil,
setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil)
{
self.historyResponses = historyResponses
self.sessionsResponses = sessionsResponses
self.modelResponses = modelResponses
self.resetSessionHook = resetSessionHook
self.setSessionModelHook = setSessionModelHook
self.setSessionThinkingHook = setSessionThinkingHook
var cont: AsyncStream<OpenClawChatTransportEvent>.Continuation!
@ -301,6 +307,13 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
}
}
func resetSession(sessionKey: String) async throws {
await self.state.resetSessionKeysAppend(sessionKey)
if let resetSessionHook = self.resetSessionHook {
try await resetSessionHook(sessionKey)
}
}
func setSessionThinking(sessionKey _: String, thinkingLevel: String) async throws {
await self.state.patchedThinkingLevelsAppend(thinkingLevel)
if let setSessionThinkingHook = self.setSessionThinkingHook {
@ -336,6 +349,10 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
func patchedThinkingLevels() async -> [String] {
await self.state.patchedThinkingLevels
}
func resetSessionKeys() async -> [String] {
await self.state.resetSessionKeys
}
}
extension TestChatTransportState {
@ -370,6 +387,10 @@ extension TestChatTransportState {
fileprivate func patchedThinkingLevelsAppend(_ v: String) {
self.patchedThinkingLevels.append(v)
}
fileprivate func resetSessionKeysAppend(_ v: String) {
self.resetSessionKeys.append(v)
}
}
@Suite struct ChatViewModelTests {
@ -592,6 +613,151 @@ extension TestChatTransportState {
#expect(keys == ["main", "custom"])
}
@Test func sessionChoicesUseResolvedMainSessionKeyInsteadOfLiteralMain() async throws {
let now = Date().timeIntervalSince1970 * 1000
let recent = now - (30 * 60 * 1000)
let recentOlder = now - (90 * 60 * 1000)
let history = historyPayload(sessionKey: "Lukes MacBook Pro", sessionId: "sess-main")
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 2,
defaults: OpenClawChatSessionsDefaults(
model: nil,
contextTokens: nil,
mainSessionKey: "Lukes MacBook Pro"),
sessions: [
OpenClawChatSessionEntry(
key: "Lukes MacBook Pro",
kind: nil,
displayName: "Lukes MacBook Pro",
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: recent,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: nil,
model: nil,
contextTokens: nil),
sessionEntry(key: "recent-1", updatedAt: recentOlder),
])
let (_, vm) = await makeViewModel(
sessionKey: "Lukes MacBook Pro",
historyResponses: [history],
sessionsResponses: [sessions])
await MainActor.run { vm.load() }
try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } }
let keys = await MainActor.run { vm.sessionChoices.map(\.key) }
#expect(keys == ["Lukes MacBook Pro", "recent-1"])
}
@Test func sessionChoicesHideInternalOnboardingSession() async throws {
let now = Date().timeIntervalSince1970 * 1000
let recent = now - (2 * 60 * 1000)
let recentOlder = now - (5 * 60 * 1000)
let history = historyPayload(sessionKey: "agent:main:main", sessionId: "sess-main")
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 2,
defaults: OpenClawChatSessionsDefaults(
model: nil,
contextTokens: nil,
mainSessionKey: "agent:main:main"),
sessions: [
OpenClawChatSessionEntry(
key: "agent:main:onboarding",
kind: nil,
displayName: "Lukes MacBook Pro",
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: recent,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: nil,
model: nil,
contextTokens: nil),
OpenClawChatSessionEntry(
key: "agent:main:main",
kind: nil,
displayName: "Lukes MacBook Pro",
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: recentOlder,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: nil,
model: nil,
contextTokens: nil),
])
let (_, vm) = await makeViewModel(
sessionKey: "agent:main:main",
historyResponses: [history],
sessionsResponses: [sessions])
await MainActor.run { vm.load() }
try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } }
let keys = await MainActor.run { vm.sessionChoices.map(\.key) }
#expect(keys == ["agent:main:main"])
}
@Test func resetTriggerResetsSessionAndReloadsHistory() async throws {
let before = historyPayload(
messages: [
chatTextMessage(role: "assistant", text: "before reset", timestamp: 1),
])
let after = historyPayload(
messages: [
chatTextMessage(role: "assistant", text: "after reset", timestamp: 2),
])
let (transport, vm) = await makeViewModel(historyResponses: [before, after])
try await loadAndWaitBootstrap(vm: vm)
try await waitUntil("initial history loaded") {
await MainActor.run { vm.messages.first?.content.first?.text == "before reset" }
}
await MainActor.run {
vm.input = "/new"
vm.send()
}
try await waitUntil("reset called") {
await transport.resetSessionKeys() == ["main"]
}
try await waitUntil("history reloaded") {
await MainActor.run { vm.messages.first?.content.first?.text == "after reset" }
}
#expect(await transport.lastSentRunId() == nil)
}
@Test func bootstrapsModelSelectionFromSessionAndDefaults() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
@ -758,7 +924,8 @@ extension TestChatTransportState {
}
#expect(await MainActor.run { vm.modelSelectionID } == "openai/gpt-5.4-pro")
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "openai/gpt-5.4-pro")
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "gpt-5.4-pro")
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.modelProvider } == "openai")
}
@Test func sendWaitsForInFlightModelPatchToFinish() async throws {
@ -852,11 +1019,15 @@ extension TestChatTransportState {
}
try await waitUntil("older model completion wins after latest failure") {
await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model == "openai/gpt-5.4" }
await MainActor.run {
vm.sessions.first(where: { $0.key == "main" })?.model == "gpt-5.4" &&
vm.sessions.first(where: { $0.key == "main" })?.modelProvider == "openai"
}
}
#expect(await MainActor.run { vm.modelSelectionID } == "openai/gpt-5.4")
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "openai/gpt-5.4")
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "gpt-5.4")
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.modelProvider } == "openai")
#expect(await transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"])
}
@ -1012,12 +1183,17 @@ extension TestChatTransportState {
}
try await waitUntil("late model completion updates only the original session") {
await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model == "openai/gpt-5.4" }
await MainActor.run {
vm.sessions.first(where: { $0.key == "main" })?.model == "gpt-5.4" &&
vm.sessions.first(where: { $0.key == "main" })?.modelProvider == "openai"
}
}
#expect(await MainActor.run { vm.modelSelectionID } == "openai/gpt-5.4")
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "openai/gpt-5.4")
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "gpt-5.4")
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.modelProvider } == "openai")
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "other" })?.model } == "openai/gpt-5.4-pro")
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "other" })?.modelProvider } == nil)
#expect(await transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"])
}

View File

@ -114,11 +114,11 @@ Example:
**Teams + channel allowlist**
- Scope group/channel replies by listing teams and channels under `channels.msteams.teams`.
- Keys can be team IDs or names; channel keys can be conversation IDs or names.
- Keys should use stable team IDs and channel conversation IDs.
- When `groupPolicy="allowlist"` and a teams allowlist is present, only listed teams/channels are accepted (mentiongated).
- The configure wizard accepts `Team/Channel` entries and stores them for you.
- On startup, OpenClaw resolves team/channel and user allowlist names to IDs (when Graph permissions allow)
and logs the mapping; unresolved entries are kept as typed.
and logs the mapping; unresolved team/channel names are kept as typed but ignored for routing by default unless `channels.msteams.dangerouslyAllowNameMatching: true` is enabled.
Example:
@ -457,7 +457,7 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
- `channels.msteams.webhook.path` (default `/api/messages`)
- `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing)
- `channels.msteams.allowFrom`: DM allowlist (AAD object IDs recommended). The wizard resolves names to IDs during setup when Graph access is available.
- `channels.msteams.dangerouslyAllowNameMatching`: break-glass toggle to re-enable mutable UPN/display-name matching.
- `channels.msteams.dangerouslyAllowNameMatching`: break-glass toggle to re-enable mutable UPN/display-name matching and direct team/channel name routing.
- `channels.msteams.textChunkLimit`: outbound text chunk size.
- `channels.msteams.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
- `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains).

View File

@ -169,15 +169,15 @@ For actions/directory reads, user token can be preferred when configured. For wr
- `allowlist`
- `disabled`
Channel allowlist lives under `channels.slack.channels`.
Channel allowlist lives under `channels.slack.channels` and should use stable channel IDs.
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:
- channel allowlist entries and DM allowlist entries are resolved at startup when token access allows
- unresolved entries are kept as configured
- inbound authorization matching is ID-first by default; direct username/slug matching requires `channels.slack.dangerouslyAllowNameMatching: true`
- unresolved channel-name entries are kept as configured but ignored for routing by default
- inbound authorization and channel routing are ID-first by default; direct username/slug matching requires `channels.slack.dangerouslyAllowNameMatching: true`
</Tab>
@ -190,7 +190,7 @@ For actions/directory reads, user token can be preferred when configured. For wr
- mention regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`)
- implicit reply-to-bot thread behavior
Per-channel controls (`channels.slack.channels.<id|name>`):
Per-channel controls (`channels.slack.channels.<id>`; names only via startup resolution or `dangerouslyAllowNameMatching`):
- `requireMention`
- `users` (allowlist)

View File

@ -335,9 +335,10 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
If native commands are disabled, built-ins are removed. Custom/plugin commands may still register if configured.
Common setup failure:
Common setup failures:
- `setMyCommands failed` usually means outbound DNS/HTTPS to `api.telegram.org` is blocked.
- `setMyCommands failed` with `BOT_COMMANDS_TOO_MUCH` means the Telegram menu still overflowed after trimming; reduce plugin/skill/custom commands or disable `channels.telegram.commands.native`.
- `setMyCommands failed` with network/fetch errors usually means outbound DNS/HTTPS to `api.telegram.org` is blocked.
### Device pairing commands (`device-pair` plugin)
@ -843,7 +844,8 @@ openclaw message poll --channel telegram --target -1001234567890:topic:42 \
- authorize your sender identity (pairing and/or numeric `allowFrom`)
- command authorization still applies even when group policy is `open`
- `setMyCommands failed` usually indicates DNS/HTTPS reachability issues to `api.telegram.org`
- `setMyCommands failed` with `BOT_COMMANDS_TOO_MUCH` means the native menu has too many entries; reduce plugin/skill/custom commands or disable native menus
- `setMyCommands failed` with network/fetch errors usually indicates DNS/HTTPS reachability issues to `api.telegram.org`
</Accordion>

View File

@ -44,12 +44,13 @@ Full troubleshooting: [/channels/whatsapp#troubleshooting-quick](/channels/whats
### Telegram failure signatures
| Symptom | Fastest check | Fix |
| --------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------- |
| `/start` but no usable reply flow | `openclaw pairing list telegram` | Approve pairing or change DM policy. |
| Bot online but group stays silent | Verify mention requirement and bot privacy mode | Disable privacy mode for group visibility or mention bot. |
| Send failures with network errors | Inspect logs for Telegram API call failures | Fix DNS/IPv6/proxy routing to `api.telegram.org`. |
| Upgraded and allowlist blocks you | `openclaw security audit` and config allowlists | Run `openclaw doctor --fix` or replace `@username` with numeric sender IDs. |
| Symptom | Fastest check | Fix |
| ----------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------- |
| `/start` but no usable reply flow | `openclaw pairing list telegram` | Approve pairing or change DM policy. |
| Bot online but group stays silent | Verify mention requirement and bot privacy mode | Disable privacy mode for group visibility or mention bot. |
| Send failures with network errors | Inspect logs for Telegram API call failures | Fix DNS/IPv6/proxy routing to `api.telegram.org`. |
| `setMyCommands` rejected at startup | Inspect logs for `BOT_COMMANDS_TOO_MUCH` | Reduce plugin/skill/custom Telegram commands or disable native menus. |
| Upgraded and allowlist blocks you | `openclaw security audit` and config allowlists | Run `openclaw doctor --fix` or replace `@username` with numeric sender IDs. |
Full troubleshooting: [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting)

View File

@ -86,11 +86,13 @@ Approve via:
- Default: `channels.zalouser.groupPolicy = "open"` (groups allowed). Use `channels.defaults.groupPolicy` to override the default when unset.
- Restrict to an allowlist with:
- `channels.zalouser.groupPolicy = "allowlist"`
- `channels.zalouser.groups` (keys are group IDs or names; controls which groups are allowed)
- `channels.zalouser.groups` (keys should be stable group IDs; names are resolved to IDs on startup when possible)
- `channels.zalouser.groupAllowFrom` (controls which senders in allowed groups can trigger the bot)
- Block all groups: `channels.zalouser.groupPolicy = "disabled"`.
- The configure wizard can prompt for group allowlists.
- On startup, OpenClaw resolves group/user names in allowlists to IDs and logs the mapping; unresolved entries are kept as typed.
- On startup, OpenClaw resolves group/user names in allowlists to IDs and logs the mapping.
- Group allowlist matching is ID-only by default. Unresolved names are ignored for auth unless `channels.zalouser.dangerouslyAllowNameMatching: true` is enabled.
- `channels.zalouser.dangerouslyAllowNameMatching: true` is a break-glass compatibility mode that re-enables mutable group-name matching.
- If `groupAllowFrom` is unset, runtime falls back to `allowFrom` for group sender checks.
- Sender checks apply to both normal group messages and control commands (for example `/new`, `/reset`).

View File

@ -48,6 +48,7 @@ OpenClaw ships with the piai catalog. These providers require **no**
- OpenAI Responses WebSocket warm-up defaults to enabled via `params.openaiWsWarmup` (`true`/`false`)
- OpenAI priority processing can be enabled via `agents.defaults.models["openai/<model>"].params.serviceTier`
- OpenAI fast mode can be enabled per model via `agents.defaults.models["<provider>/<model>"].params.fastMode`
- `openai/gpt-5.3-codex-spark` is intentionally suppressed in OpenClaw because the live OpenAI API rejects it; Spark is treated as Codex-only
```json5
{
@ -81,6 +82,7 @@ OpenClaw ships with the piai catalog. These providers require **no**
- Default transport is `auto` (WebSocket-first, SSE fallback)
- Override per model via `agents.defaults.models["openai-codex/<model>"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`)
- Shares the same `/fast` toggle and `params.fastMode` config as direct `openai/*`
- `openai-codex/gpt-5.3-codex-spark` remains available when the Codex OAuth catalog exposes it; entitlement-dependent
- Policy note: OpenAI Codex OAuth is explicitly supported for external tools/workflows like OpenClaw.
```json5

View File

@ -304,6 +304,7 @@ schema:
- `channels.googlechat.dangerouslyAllowNameMatching`
- `channels.googlechat.accounts.<accountId>.dangerouslyAllowNameMatching`
- `channels.msteams.dangerouslyAllowNameMatching`
- `channels.zalouser.dangerouslyAllowNameMatching` (extension channel)
- `channels.irc.dangerouslyAllowNameMatching` (extension channel)
- `channels.irc.accounts.<accountId>.dangerouslyAllowNameMatching` (extension channel)
- `channels.mattermost.dangerouslyAllowNameMatching` (extension channel)

View File

@ -179,7 +179,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [I closed my terminal on Windows - how do I restart OpenClaw?](#i-closed-my-terminal-on-windows-how-do-i-restart-openclaw)
- [The Gateway is up but replies never arrive. What should I check?](#the-gateway-is-up-but-replies-never-arrive-what-should-i-check)
- ["Disconnected from gateway: no reason" - what now?](#disconnected-from-gateway-no-reason-what-now)
- [Telegram setMyCommands fails with network errors. What should I check?](#telegram-setmycommands-fails-with-network-errors-what-should-i-check)
- [Telegram setMyCommands fails. What should I check?](#telegram-setmycommands-fails-what-should-i-check)
- [TUI shows no output. What should I check?](#tui-shows-no-output-what-should-i-check)
- [How do I completely stop then start the Gateway?](#how-do-i-completely-stop-then-start-the-gateway)
- [ELI5: `openclaw gateway restart` vs `openclaw gateway`](#eli5-openclaw-gateway-restart-vs-openclaw-gateway)
@ -2710,7 +2710,7 @@ openclaw logs --follow
Docs: [Dashboard](/web/dashboard), [Remote access](/gateway/remote), [Troubleshooting](/gateway/troubleshooting).
### Telegram setMyCommands fails with network errors What should I check
### Telegram setMyCommands fails What should I check
Start with logs and channel status:
@ -2719,7 +2719,11 @@ openclaw channels status
openclaw channels logs --channel telegram
```
If you are on a VPS or behind a proxy, confirm outbound HTTPS is allowed and DNS works.
Then match the error:
- `BOT_COMMANDS_TOO_MUCH`: the Telegram menu has too many entries. OpenClaw already trims to the Telegram limit and retries with fewer commands, but some menu entries still need to be dropped. Reduce plugin/skill/custom commands, or disable `channels.telegram.commands.native` if you do not need the menu.
- `TypeError: fetch failed`, `Network request for 'setMyCommands' failed!`, or similar network errors: if you are on a VPS or behind a proxy, confirm outbound HTTPS is allowed and DNS works for `api.telegram.org`.
If the Gateway is remote, make sure you are looking at logs on the Gateway host.
Docs: [Telegram](/channels/telegram), [Channel troubleshooting](/channels/troubleshooting).

View File

@ -39,7 +39,7 @@ Notes:
# Default is auto-derived from APP_VERSION when omitted.
SKIP_NOTARIZE=1 \
BUNDLE_ID=ai.openclaw.mac \
APP_VERSION=2026.3.11 \
APP_VERSION=2026.3.12 \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-dist.sh
@ -47,10 +47,10 @@ scripts/package-mac-dist.sh
# `package-mac-dist.sh` already creates the zip + DMG.
# If you used `package-mac-app.sh` directly instead, create them manually:
# If you want notarization/stapling in this step, use the NOTARIZE command below.
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.3.11.zip
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.3.12.zip
# Optional: build a styled DMG for humans (drag to /Applications)
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.11.dmg
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.12.dmg
# Recommended: build + notarize/staple zip + DMG
# First, create a keychain profile once:
@ -58,13 +58,13 @@ scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.11.dmg
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \
BUNDLE_ID=ai.openclaw.mac \
APP_VERSION=2026.3.11 \
APP_VERSION=2026.3.12 \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-dist.sh
# Optional: ship dSYM alongside the release
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.3.11.dSYM.zip
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.3.12.dSYM.zip
```
## Appcast entry
@ -72,7 +72,7 @@ ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenCl
Use the release note generator so Sparkle renders formatted HTML notes:
```bash
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.3.11.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.3.12.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
```
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
@ -80,7 +80,7 @@ Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when
## Publish & verify
- Upload `OpenClaw-2026.3.11.zip` (and `OpenClaw-2026.3.11.dSYM.zip`) to the GitHub release for tag `v2026.3.11`.
- Upload `OpenClaw-2026.3.12.zip` (and `OpenClaw-2026.3.12.dSYM.zip`) to the GitHub release for tag `v2026.3.12`.
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`.
- Sanity checks:
- `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200.

View File

@ -36,6 +36,12 @@ openclaw onboard --openai-api-key "$OPENAI_API_KEY"
OpenAI's current API model docs list `gpt-5.4` and `gpt-5.4-pro` for direct
OpenAI API usage. OpenClaw forwards both through the `openai/*` Responses path.
OpenClaw intentionally suppresses the stale `openai/gpt-5.3-codex-spark` row,
because direct OpenAI API calls reject it in live traffic.
OpenClaw does **not** expose `openai/gpt-5.3-codex-spark` on the direct OpenAI
API path. `pi-ai` still ships a built-in row for that model, but live OpenAI API
requests currently reject it. Spark is treated as Codex-only in OpenClaw.
## Option B: OpenAI Code (Codex) subscription
@ -63,6 +69,18 @@ openclaw models auth login --provider openai-codex
OpenAI's current Codex docs list `gpt-5.4` as the current Codex model. OpenClaw
maps that to `openai-codex/gpt-5.4` for ChatGPT/Codex OAuth usage.
If your Codex account is entitled to Codex Spark, OpenClaw also supports:
- `openai-codex/gpt-5.3-codex-spark`
OpenClaw treats Codex Spark as Codex-only. It does not expose a direct
`openai/gpt-5.3-codex-spark` API-key path.
OpenClaw also preserves `openai-codex/gpt-5.3-codex-spark` when `pi-ai`
discovers it. Treat it as entitlement-dependent and experimental: Codex Spark is
separate from GPT-5.4 `/fast`, and availability depends on the signed-in Codex /
ChatGPT account.
### Transport default
OpenClaw uses `pi-ai` for model streaming. For both `openai/*` and

View File

@ -771,18 +771,22 @@ are not just "OAuth helpers" anymore.
### Provider plugin lifecycle
A provider plugin can participate in four distinct phases:
A provider plugin can participate in five distinct phases:
1. **Auth**
`auth[].run(ctx)` performs OAuth, API-key capture, device code, or custom
setup and returns auth profiles plus optional config patches.
2. **Wizard integration**
2. **Non-interactive setup**
`auth[].runNonInteractive(ctx)` handles `openclaw onboard --non-interactive`
without prompts. Use this when the provider needs custom headless setup
beyond the built-in simple API-key paths.
3. **Wizard integration**
`wizard.onboarding` adds an entry to `openclaw onboard`.
`wizard.modelPicker` adds a setup entry to the model picker.
3. **Implicit discovery**
4. **Implicit discovery**
`discovery.run(ctx)` can contribute provider config automatically during
model resolution/listing.
4. **Post-selection follow-up**
5. **Post-selection follow-up**
`onModelSelected(ctx)` runs after a model is chosen. Use this for provider-
specific work such as downloading a local model.
@ -790,6 +794,7 @@ This is the recommended split because these phases have different lifecycle
requirements:
- auth is interactive and writes credentials/config
- non-interactive setup is flag/env-driven and must not prompt
- wizard metadata is static and UI-facing
- discovery should be safe, quick, and failure-tolerant
- post-select hooks are side effects tied to the chosen model
@ -814,6 +819,32 @@ Core then:
That means a provider plugin owns the provider-specific setup logic, while core
owns the generic persistence and config-merge path.
### Provider non-interactive contract
`auth[].runNonInteractive(ctx)` is optional. Implement it when the provider
needs headless setup that cannot be expressed through the built-in generic
API-key flows.
The non-interactive context includes:
- the current and base config
- parsed onboarding CLI options
- runtime logging/error helpers
- agent/workspace dirs
- `resolveApiKey(...)` to read provider keys from flags, env, or existing auth
profiles while honoring `--secret-input-mode`
- `toApiKeyCredential(...)` to convert a resolved key into an auth-profile
credential with the right plaintext vs secret-ref storage
Use this surface for providers such as:
- self-hosted OpenAI-compatible runtimes that need `--custom-base-url` +
`--custom-model-id`
- provider-specific non-interactive verification or config synthesis
Do not prompt from `runNonInteractive`. Reject missing inputs with actionable
errors instead.
### Provider wizard metadata
`wizard.onboarding` controls how the provider appears in grouped onboarding:
@ -836,6 +867,13 @@ entry in model selection:
When a provider has multiple auth methods, the wizard can either point at one
explicit method or let OpenClaw synthesize per-method choices.
OpenClaw validates provider wizard metadata when the plugin registers:
- duplicate or blank auth-method ids are rejected
- wizard metadata is ignored when the provider has no auth methods
- invalid `methodId` bindings are downgraded to warnings and fall back to the
provider's remaining auth methods
### Provider discovery contract
`discovery.run(ctx)` returns one of:
@ -970,6 +1008,9 @@ Notes:
- `run` receives a `ProviderAuthContext` with `prompter`, `runtime`,
`openUrl`, and `oauth.createVpsAwareHandlers` helpers.
- `runNonInteractive` receives a `ProviderAuthMethodNonInteractiveContext`
with `opts`, `resolveApiKey`, and `toApiKeyCredential` helpers for
headless onboarding.
- Return `configPatch` when you need to add default models or provider config.
- Return `defaultModel` so `--set-default` can update agent defaults.
- `wizard.onboarding` adds a provider choice to `openclaw onboard`.

View File

@ -1,10 +1,10 @@
{
"name": "@openclaw/acpx",
"version": "2026.3.11",
"version": "2026.3.12",
"description": "OpenClaw ACP runtime backend via acpx",
"type": "module",
"dependencies": {
"acpx": "0.2.0"
"acpx": "0.3.0"
},
"openclaw": {
"extensions": [

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/bluebubbles",
"version": "2026.3.11",
"version": "2026.3.12",
"description": "OpenClaw BlueBubbles channel plugin",
"type": "module",
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/copilot-proxy",
"version": "2026.3.11",
"version": "2026.3.12",
"private": true,
"description": "OpenClaw Copilot Proxy provider plugin",
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/diagnostics-otel",
"version": "2026.3.11",
"version": "2026.3.12",
"description": "OpenClaw diagnostics OpenTelemetry exporter",
"type": "module",
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/diffs",
"version": "2026.3.11",
"version": "2026.3.12",
"private": true,
"description": "OpenClaw diff viewer plugin",
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/discord",
"version": "2026.3.11",
"version": "2026.3.12",
"description": "OpenClaw Discord channel plugin",
"type": "module",
"openclaw": {

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/feishu",
"version": "2026.3.11",
"version": "2026.3.12",
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
"type": "module",
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/google-gemini-cli-auth",
"version": "2026.3.11",
"version": "2026.3.12",
"private": true,
"description": "OpenClaw Gemini CLI OAuth provider plugin",
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/googlechat",
"version": "2026.3.11",
"version": "2026.3.12",
"private": true,
"description": "OpenClaw Google Chat channel plugin",
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/imessage",
"version": "2026.3.11",
"version": "2026.3.12",
"private": true,
"description": "OpenClaw iMessage channel plugin",
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/irc",
"version": "2026.3.11",
"version": "2026.3.12",
"description": "OpenClaw IRC channel plugin",
"type": "module",
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/line",
"version": "2026.3.11",
"version": "2026.3.12",
"private": true,
"description": "OpenClaw LINE channel plugin",
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/llm-task",
"version": "2026.3.11",
"version": "2026.3.12",
"private": true,
"description": "OpenClaw JSON-only LLM task plugin",
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/lobster",
"version": "2026.3.11",
"version": "2026.3.12",
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
"type": "module",
"dependencies": {

View File

@ -1,5 +1,11 @@
# Changelog
## 2026.3.12
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.3.11
### Changes

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/matrix",
"version": "2026.3.11",
"version": "2026.3.12",
"description": "OpenClaw Matrix channel plugin",
"type": "module",
"dependencies": {
@ -8,7 +8,7 @@
"@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
"@vector-im/matrix-bot-sdk": "0.8.0-element.3",
"markdown-it": "14.1.1",
"music-metadata": "^11.12.1",
"music-metadata": "^11.12.3",
"zod": "^4.3.6"
},
"openclaw": {

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/mattermost",
"version": "2026.3.11",
"version": "2026.3.12",
"description": "OpenClaw Mattermost channel plugin",
"type": "module",
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/memory-core",
"version": "2026.3.11",
"version": "2026.3.12",
"private": true,
"description": "OpenClaw core memory search plugin",
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/memory-lancedb",
"version": "2026.3.11",
"version": "2026.3.12",
"private": true,
"description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture",
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/minimax-portal-auth",
"version": "2026.3.11",
"version": "2026.3.12",
"private": true,
"description": "OpenClaw MiniMax Portal OAuth provider plugin",
"type": "module",

View File

@ -1,5 +1,11 @@
# Changelog
## 2026.3.12
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.3.11
### Changes

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/msteams",
"version": "2026.3.11",
"version": "2026.3.12",
"description": "OpenClaw Microsoft Teams channel plugin",
"type": "module",
"dependencies": {

View File

@ -175,6 +175,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
teamName,
conversationId,
channelName,
allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
});
const senderGroupPolicy = resolveSenderScopedGroupPolicy({
groupPolicy,

View File

@ -50,7 +50,7 @@ describe("msteams policy", () => {
expect(res.allowed).toBe(false);
});
it("matches team and channel by name", () => {
it("blocks team and channel name matches by default", () => {
const cfg: MSTeamsConfig = {
teams: {
"My Team": {
@ -69,6 +69,31 @@ describe("msteams policy", () => {
conversationId: "ignored",
});
expect(res.teamConfig).toBeUndefined();
expect(res.channelConfig).toBeUndefined();
expect(res.allowed).toBe(false);
});
it("matches team and channel by name when dangerous name matching is enabled", () => {
const cfg: MSTeamsConfig = {
teams: {
"My Team": {
requireMention: true,
channels: {
"General Chat": { requireMention: false },
},
},
},
};
const res = resolveMSTeamsRouteConfig({
cfg,
teamName: "My Team",
channelName: "General Chat",
conversationId: "ignored",
allowNameMatching: true,
});
expect(res.teamConfig?.requireMention).toBe(true);
expect(res.channelConfig?.requireMention).toBe(false);
expect(res.allowed).toBe(true);

View File

@ -16,6 +16,7 @@ import {
resolveToolsBySender,
resolveChannelEntryMatchWithFallback,
resolveNestedAllowlistDecision,
isDangerousNameMatchingEnabled,
} from "openclaw/plugin-sdk/msteams";
export type MSTeamsResolvedRouteConfig = {
@ -35,6 +36,7 @@ export function resolveMSTeamsRouteConfig(params: {
teamName?: string | null | undefined;
conversationId?: string | null | undefined;
channelName?: string | null | undefined;
allowNameMatching?: boolean;
}): MSTeamsResolvedRouteConfig {
const teamId = params.teamId?.trim();
const teamName = params.teamName?.trim();
@ -44,8 +46,8 @@ export function resolveMSTeamsRouteConfig(params: {
const allowlistConfigured = Object.keys(teams).length > 0;
const teamCandidates = buildChannelKeyCandidates(
teamId,
teamName,
teamName ? normalizeChannelSlug(teamName) : undefined,
params.allowNameMatching ? teamName : undefined,
params.allowNameMatching && teamName ? normalizeChannelSlug(teamName) : undefined,
);
const teamMatch = resolveChannelEntryMatchWithFallback({
entries: teams,
@ -58,8 +60,8 @@ export function resolveMSTeamsRouteConfig(params: {
const channelAllowlistConfigured = Object.keys(channels).length > 0;
const channelCandidates = buildChannelKeyCandidates(
conversationId,
channelName,
channelName ? normalizeChannelSlug(channelName) : undefined,
params.allowNameMatching ? channelName : undefined,
params.allowNameMatching && channelName ? normalizeChannelSlug(channelName) : undefined,
);
const channelMatch = resolveChannelEntryMatchWithFallback({
entries: channels,
@ -101,6 +103,7 @@ export function resolveMSTeamsGroupToolPolicy(
const groupId = params.groupId?.trim();
const groupChannel = params.groupChannel?.trim();
const groupSpace = params.groupSpace?.trim();
const allowNameMatching = isDangerousNameMatchingEnabled(cfg);
const resolved = resolveMSTeamsRouteConfig({
cfg,
@ -108,6 +111,7 @@ export function resolveMSTeamsGroupToolPolicy(
teamName: groupSpace,
conversationId: groupId,
channelName: groupChannel,
allowNameMatching,
});
if (resolved.channelConfig) {
@ -158,8 +162,8 @@ export function resolveMSTeamsGroupToolPolicy(
const channelCandidates = buildChannelKeyCandidates(
groupId,
groupChannel,
groupChannel ? normalizeChannelSlug(groupChannel) : undefined,
allowNameMatching ? groupChannel : undefined,
allowNameMatching && groupChannel ? normalizeChannelSlug(groupChannel) : undefined,
);
for (const teamConfig of Object.values(cfg.teams ?? {})) {
const match = resolveChannelEntryMatchWithFallback({

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/nextcloud-talk",
"version": "2026.3.11",
"version": "2026.3.12",
"description": "OpenClaw Nextcloud Talk channel plugin",
"type": "module",
"dependencies": {

View File

@ -1,5 +1,11 @@
# Changelog
## 2026.3.12
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.3.11
### Changes

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/nostr",
"version": "2026.3.11",
"version": "2026.3.12",
"description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs",
"type": "module",
"dependencies": {

View File

@ -4,8 +4,10 @@ import {
ensureOllamaModelPulled,
OLLAMA_DEFAULT_BASE_URL,
promptAndConfigureOllama,
configureOllamaNonInteractive,
type OpenClawPluginApi,
type ProviderAuthContext,
type ProviderAuthMethodNonInteractiveContext,
type ProviderAuthResult,
type ProviderDiscoveryContext,
} from "openclaw/plugin-sdk/core";
@ -50,6 +52,12 @@ const ollamaPlugin = {
defaultModel: `ollama/${result.defaultModelId}`,
};
},
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) =>
configureOllamaNonInteractive({
nextConfig: ctx.config,
opts: ctx.opts,
runtime: ctx.runtime,
}),
},
],
discovery: {

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/open-prose",
"version": "2026.3.11",
"version": "2026.3.12",
"private": true,
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
"type": "module",

View File

@ -1,9 +1,11 @@
import {
buildSglangProvider,
configureOpenAICompatibleSelfHostedProviderNonInteractive,
emptyPluginConfigSchema,
promptAndConfigureOpenAICompatibleSelfHostedProvider,
type OpenClawPluginApi,
type ProviderAuthContext,
type ProviderAuthMethodNonInteractiveContext,
type ProviderAuthResult,
type ProviderDiscoveryContext,
} from "openclaw/plugin-sdk/core";
@ -49,6 +51,15 @@ const sglangPlugin = {
defaultModel: result.modelRef,
};
},
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) =>
configureOpenAICompatibleSelfHostedProviderNonInteractive({
ctx,
providerId: PROVIDER_ID,
providerLabel: "SGLang",
defaultBaseUrl: DEFAULT_BASE_URL,
defaultApiKeyEnvVar: "SGLANG_API_KEY",
modelPlaceholder: "Qwen/Qwen3-8B",
}),
},
],
discovery: {

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/signal",
"version": "2026.3.11",
"version": "2026.3.12",
"private": true,
"description": "OpenClaw Signal channel plugin",
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/slack",
"version": "2026.3.11",
"version": "2026.3.12",
"private": true,
"description": "OpenClaw Slack channel plugin",
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/synology-chat",
"version": "2026.3.11",
"version": "2026.3.12",
"description": "Synology Chat channel plugin for OpenClaw",
"type": "module",
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/telegram",
"version": "2026.3.11",
"version": "2026.3.12",
"private": true,
"description": "OpenClaw Telegram channel plugin",
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/tlon",
"version": "2026.3.11",
"version": "2026.3.12",
"description": "OpenClaw Tlon/Urbit channel plugin",
"type": "module",
"dependencies": {

View File

@ -1,5 +1,11 @@
# Changelog
## 2026.3.12
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.3.11
### Changes

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/twitch",
"version": "2026.3.11",
"version": "2026.3.12",
"description": "OpenClaw Twitch channel plugin",
"type": "module",
"dependencies": {

View File

@ -1,9 +1,11 @@
import {
buildVllmProvider,
configureOpenAICompatibleSelfHostedProviderNonInteractive,
emptyPluginConfigSchema,
promptAndConfigureOpenAICompatibleSelfHostedProvider,
type OpenClawPluginApi,
type ProviderAuthContext,
type ProviderAuthMethodNonInteractiveContext,
type ProviderAuthResult,
type ProviderDiscoveryContext,
} from "openclaw/plugin-sdk/core";
@ -49,6 +51,15 @@ const vllmPlugin = {
defaultModel: result.modelRef,
};
},
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) =>
configureOpenAICompatibleSelfHostedProviderNonInteractive({
ctx,
providerId: PROVIDER_ID,
providerLabel: "vLLM",
defaultBaseUrl: DEFAULT_BASE_URL,
defaultApiKeyEnvVar: "VLLM_API_KEY",
modelPlaceholder: "meta-llama/Meta-Llama-3-8B-Instruct",
}),
},
],
discovery: {

View File

@ -1,5 +1,11 @@
# Changelog
## 2026.3.12
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.3.11
### Changes

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/voice-call",
"version": "2026.3.11",
"version": "2026.3.12",
"description": "OpenClaw voice-call plugin",
"type": "module",
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/whatsapp",
"version": "2026.3.11",
"version": "2026.3.12",
"private": true,
"description": "OpenClaw WhatsApp channel plugin",
"type": "module",

View File

@ -1,5 +1,11 @@
# Changelog
## 2026.3.12
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.3.11
### Changes

View File

@ -1,10 +1,10 @@
{
"name": "@openclaw/zalo",
"version": "2026.3.11",
"version": "2026.3.12",
"description": "OpenClaw Zalo channel plugin",
"type": "module",
"dependencies": {
"undici": "7.22.0",
"undici": "7.24.0",
"zod": "^4.3.6"
},
"openclaw": {

View File

@ -1,5 +1,11 @@
# Changelog
## 2026.3.12
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.3.11
### Changes

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/zalouser",
"version": "2026.3.11",
"version": "2026.3.12",
"description": "OpenClaw Zalo Personal Account plugin via native zca-js integration",
"type": "module",
"dependencies": {

View File

@ -22,6 +22,7 @@ import {
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatAllowFromLowercase,
isDangerousNameMatchingEnabled,
isNumericTargetId,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
@ -216,6 +217,7 @@ function resolveZalouserGroupPolicyEntry(params: ChannelGroupContext) {
groupId: params.groupId,
groupChannel: params.groupChannel,
includeWildcard: true,
allowNameMatching: isDangerousNameMatchingEnabled(account.config),
}),
);
}

View File

@ -19,6 +19,7 @@ const zalouserAccountSchema = z.object({
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
profile: z.string().optional(),
dangerouslyAllowNameMatching: z.boolean().optional(),
dmPolicy: DmPolicySchema.optional(),
allowFrom: AllowFromListSchema,
historyLimit: z.number().int().min(0).optional(),

View File

@ -23,6 +23,18 @@ describe("zalouser group policy helpers", () => {
).toEqual(["123", "group:123", "chan-1", "Team Alpha", "team-alpha", "*"]);
});
it("builds id-only candidates when name matching is disabled", () => {
expect(
buildZalouserGroupCandidates({
groupId: "123",
groupChannel: "chan-1",
groupName: "Team Alpha",
includeGroupIdAlias: true,
allowNameMatching: false,
}),
).toEqual(["123", "group:123", "*"]);
});
it("finds the first matching group entry", () => {
const groups = {
"group:123": { allow: true },

View File

@ -23,6 +23,7 @@ export function buildZalouserGroupCandidates(params: {
groupName?: string | null;
includeGroupIdAlias?: boolean;
includeWildcard?: boolean;
allowNameMatching?: boolean;
}): string[] {
const seen = new Set<string>();
const out: string[] = [];
@ -43,10 +44,12 @@ export function buildZalouserGroupCandidates(params: {
if (params.includeGroupIdAlias === true && groupId) {
push(`group:${groupId}`);
}
push(groupChannel);
push(groupName);
if (groupName) {
push(normalizeZalouserGroupSlug(groupName));
if (params.allowNameMatching !== false) {
push(groupChannel);
push(groupName);
if (groupName) {
push(normalizeZalouserGroupSlug(groupName));
}
}
if (params.includeWildcard !== false) {
push("*");

View File

@ -424,6 +424,73 @@ describe("zalouser monitor group mention gating", () => {
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("does not accept a different group id by matching only the mutable group name by default", async () => {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
commandAuthorized: false,
});
await __testing.processMessage({
message: createGroupMessage({
threadId: "g-attacker-001",
groupName: "Trusted Team",
senderId: "666",
hasAnyMention: true,
wasExplicitlyMentioned: true,
content: "ping @bot",
}),
account: {
...createAccount(),
config: {
...createAccount().config,
groupPolicy: "allowlist",
groupAllowFrom: ["*"],
groups: {
"group:g-trusted-001": { allow: true },
"Trusted Team": { allow: true },
},
},
},
config: createConfig(),
runtime: createRuntimeEnv(),
});
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("accepts mutable group-name matches only when dangerouslyAllowNameMatching is enabled", async () => {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
commandAuthorized: false,
});
await __testing.processMessage({
message: createGroupMessage({
threadId: "g-attacker-001",
groupName: "Trusted Team",
senderId: "666",
hasAnyMention: true,
wasExplicitlyMentioned: true,
content: "ping @bot",
}),
account: {
...createAccount(),
config: {
...createAccount().config,
dangerouslyAllowNameMatching: true,
groupPolicy: "allowlist",
groupAllowFrom: ["*"],
groups: {
"group:g-trusted-001": { allow: true },
"Trusted Team": { allow: true },
},
},
},
config: createConfig(),
runtime: createRuntimeEnv(),
});
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
expect(callArg?.ctx?.To).toBe("zalouser:group:g-attacker-001");
});
it("allows group control commands when sender is in groupAllowFrom", async () => {
const { dispatchReplyWithBufferedBlockDispatcher, resolveCommandAuthorizedFromAuthorizers } =
installRuntime({

View File

@ -19,6 +19,7 @@ import {
createScopedPairingAccess,
createReplyPrefixOptions,
evaluateGroupRouteAccessForPolicy,
isDangerousNameMatchingEnabled,
issuePairingChallenge,
resolveOutboundMediaUrls,
mergeAllowlist,
@ -212,6 +213,7 @@ function resolveGroupRequireMention(params: {
groupId: string;
groupName?: string | null;
groups: Record<string, { allow?: boolean; enabled?: boolean; requireMention?: boolean }>;
allowNameMatching?: boolean;
}): boolean {
const entry = findZalouserGroupEntry(
params.groups ?? {},
@ -220,6 +222,7 @@ function resolveGroupRequireMention(params: {
groupName: params.groupName,
includeGroupIdAlias: true,
includeWildcard: true,
allowNameMatching: params.allowNameMatching,
}),
);
if (typeof entry?.requireMention === "boolean") {
@ -316,6 +319,7 @@ async function processMessage(
});
const groups = account.config.groups ?? {};
const allowNameMatching = isDangerousNameMatchingEnabled(account.config);
if (isGroup) {
const groupEntry = findZalouserGroupEntry(
groups,
@ -324,6 +328,7 @@ async function processMessage(
groupName,
includeGroupIdAlias: true,
includeWildcard: true,
allowNameMatching,
}),
);
const routeAccess = evaluateGroupRouteAccessForPolicy({
@ -466,6 +471,7 @@ async function processMessage(
groupId: chatId,
groupName,
groups,
allowNameMatching,
})
: false;
const mentionRegexes = core.channel.mentions.buildMentionRegexes(config, route.agentId);

View File

@ -97,6 +97,7 @@ type ZalouserSharedConfig = {
enabled?: boolean;
name?: string;
profile?: string;
dangerouslyAllowNameMatching?: boolean;
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
allowFrom?: Array<string | number>;
historyLimit?: number;

View File

@ -1,6 +1,6 @@
{
"name": "openclaw",
"version": "2026.3.11",
"version": "2026.3.12",
"description": "Multi-channel AI gateway with extensible messaging integrations",
"keywords": [],
"homepage": "https://github.com/openclaw/openclaw#readme",
@ -339,7 +339,7 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "0.16.1",
"@aws-sdk/client-bedrock": "^3.1007.0",
"@aws-sdk/client-bedrock": "^3.1008.0",
"@buape/carbon": "0.0.0-beta-20260216184201",
"@clack/prompts": "^1.1.0",
"@discordjs/voice": "^0.19.1",
@ -388,7 +388,7 @@
"sqlite-vec": "0.1.7-alpha.2",
"tar": "7.5.11",
"tslog": "^4.10.2",
"undici": "^7.22.0",
"undici": "^7.24.0",
"ws": "^8.19.0",
"yaml": "^2.8.2",
"zod": "^4.3.6"
@ -399,21 +399,21 @@
"@lit/context": "^1.1.6",
"@types/express": "^5.0.6",
"@types/markdown-it": "^14.1.2",
"@types/node": "^25.4.0",
"@types/node": "^25.5.0",
"@types/qrcode-terminal": "^0.12.2",
"@types/ws": "^8.18.1",
"@typescript/native-preview": "7.0.0-dev.20260311.1",
"@vitest/coverage-v8": "^4.0.18",
"@typescript/native-preview": "7.0.0-dev.20260312.1",
"@vitest/coverage-v8": "^4.1.0",
"jscpd": "4.0.8",
"lit": "^3.3.2",
"oxfmt": "0.38.0",
"oxlint": "^1.53.0",
"oxfmt": "0.40.0",
"oxlint": "^1.55.0",
"oxlint-tsgolint": "^0.16.0",
"signal-utils": "0.21.1",
"tsdown": "0.21.2",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
"vitest": "^4.1.0"
},
"peerDependencies": {
"@napi-rs/canvas": "^0.1.89",

2048
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -73,7 +73,7 @@ fi
if [[ "${PACKAGE_VERSION}" =~ ^([0-9]{4}\.[0-9]{1,2}\.[0-9]{1,2})([.-]?beta[.-][0-9]+)?$ ]]; then
MARKETING_VERSION="${BASH_REMATCH[1]}"
else
echo "Unsupported package.json.version '${PACKAGE_VERSION}'. Expected 2026.3.11 or 2026.3.11-beta.1." >&2
echo "Unsupported package.json.version '${PACKAGE_VERSION}'. Expected 2026.3.12 or 2026.3.12-beta.1." >&2
exit 1
fi

View File

@ -0,0 +1 @@
export { ensureAuthProfileStore } from "./auth-profiles.js";

View File

@ -157,7 +157,8 @@ function ensureContextWindowCacheLoaded(): Promise<void> {
}
try {
const { discoverAuthStorage, discoverModels } = await import("./pi-model-discovery.js");
const { discoverAuthStorage, discoverModels } =
await import("./pi-model-discovery-runtime.js");
const agentDir = resolveOpenClawAgentDir();
const authStorage = discoverAuthStorage(agentDir);
const modelRegistry = discoverModels(authStorage, agentDir) as unknown as ModelRegistryLike;

View File

@ -114,6 +114,55 @@ describe("loadModelCatalog", () => {
expect(spark?.reasoning).toBe(true);
});
it("filters stale openai gpt-5.3-codex-spark built-ins from the catalog", async () => {
mockPiDiscoveryModels([
{
id: "gpt-5.3-codex-spark",
provider: "openai",
name: "GPT-5.3 Codex Spark",
reasoning: true,
contextWindow: 128000,
input: ["text", "image"],
},
{
id: "gpt-5.3-codex-spark",
provider: "azure-openai-responses",
name: "GPT-5.3 Codex Spark",
reasoning: true,
contextWindow: 128000,
input: ["text", "image"],
},
{
id: "gpt-5.3-codex-spark",
provider: "openai-codex",
name: "GPT-5.3 Codex Spark",
reasoning: true,
contextWindow: 128000,
input: ["text"],
},
]);
const result = await loadModelCatalog({ config: {} as OpenClawConfig });
expect(result).not.toContainEqual(
expect.objectContaining({
provider: "openai",
id: "gpt-5.3-codex-spark",
}),
);
expect(result).not.toContainEqual(
expect.objectContaining({
provider: "azure-openai-responses",
id: "gpt-5.3-codex-spark",
}),
);
expect(result).toContainEqual(
expect.objectContaining({
provider: "openai-codex",
id: "gpt-5.3-codex-spark",
}),
);
});
it("adds gpt-5.4 forward-compat catalog entries when template models exist", async () => {
mockPiDiscoveryModels([
{

View File

@ -1,6 +1,7 @@
import { type OpenClawConfig, loadConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveOpenClawAgentDir } from "./agent-paths.js";
import { shouldSuppressBuiltInModel } from "./model-suppression.js";
import { ensureOpenClawModelsJson } from "./models-config.js";
const log = createSubsystemLogger("model-catalog");
@ -29,7 +30,7 @@ type PiSdkModule = typeof import("./pi-model-discovery.js");
let modelCatalogPromise: Promise<ModelCatalogEntry[]> | null = null;
let hasLoggedModelCatalogError = false;
const defaultImportPiSdk = () => import("./pi-model-discovery.js");
const defaultImportPiSdk = () => import("./pi-model-discovery-runtime.js");
let importPiSdk = defaultImportPiSdk;
const CODEX_PROVIDER = "openai-codex";
@ -242,6 +243,9 @@ export async function loadModelCatalog(params?: {
if (!provider) {
continue;
}
if (shouldSuppressBuiltInModel({ provider, id })) {
continue;
}
const name = String(entry?.name ?? id).trim() || id;
const contextWindow =
typeof entry?.contextWindow === "number" && entry.contextWindow > 0

View File

@ -16,6 +16,9 @@ const OPENAI_CODEX_GPT_54_CONTEXT_TOKENS = 1_050_000;
const OPENAI_CODEX_GPT_54_MAX_TOKENS = 128_000;
const OPENAI_CODEX_GPT_54_TEMPLATE_MODEL_IDS = ["gpt-5.3-codex", "gpt-5.2-codex"] as const;
const OPENAI_CODEX_GPT_53_MODEL_ID = "gpt-5.3-codex";
const OPENAI_CODEX_GPT_53_SPARK_MODEL_ID = "gpt-5.3-codex-spark";
const OPENAI_CODEX_GPT_53_SPARK_CONTEXT_TOKENS = 128_000;
const OPENAI_CODEX_GPT_53_SPARK_MAX_TOKENS = 128_000;
const OPENAI_CODEX_TEMPLATE_MODEL_IDS = ["gpt-5.2-codex"] as const;
const ANTHROPIC_OPUS_46_MODEL_ID = "claude-opus-4-6";
@ -133,6 +136,19 @@ function resolveOpenAICodexForwardCompatModel(
contextWindow: OPENAI_CODEX_GPT_54_CONTEXT_TOKENS,
maxTokens: OPENAI_CODEX_GPT_54_MAX_TOKENS,
};
} else if (lower === OPENAI_CODEX_GPT_53_SPARK_MODEL_ID) {
templateIds = [OPENAI_CODEX_GPT_53_MODEL_ID, ...OPENAI_CODEX_TEMPLATE_MODEL_IDS];
eligibleProviders = CODEX_GPT54_ELIGIBLE_PROVIDERS;
patch = {
api: "openai-codex-responses",
provider: normalizedProvider,
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: OPENAI_CODEX_GPT_53_SPARK_CONTEXT_TOKENS,
maxTokens: OPENAI_CODEX_GPT_53_SPARK_MAX_TOKENS,
};
} else if (lower === OPENAI_CODEX_GPT_53_MODEL_ID) {
templateIds = OPENAI_CODEX_TEMPLATE_MODEL_IDS;
eligibleProviders = CODEX_GPT53_ELIGIBLE_PROVIDERS;

View File

@ -0,0 +1,27 @@
import { normalizeProviderId } from "./model-selection.js";
const OPENAI_DIRECT_SPARK_MODEL_ID = "gpt-5.3-codex-spark";
const SUPPRESSED_SPARK_PROVIDERS = new Set(["openai", "azure-openai-responses"]);
export function shouldSuppressBuiltInModel(params: {
provider?: string | null;
id?: string | null;
}) {
const provider = normalizeProviderId(params.provider?.trim().toLowerCase() ?? "");
const id = params.id?.trim().toLowerCase() ?? "";
// pi-ai still ships non-Codex Spark rows, but OpenClaw treats Spark as
// Codex-only until upstream availability is proven on direct API paths.
return SUPPRESSED_SPARK_PROVIDERS.has(provider) && id === OPENAI_DIRECT_SPARK_MODEL_ID;
}
export function buildSuppressedBuiltInModelError(params: {
provider?: string | null;
id?: string | null;
}): string | undefined {
if (!shouldSuppressBuiltInModel(params)) {
return undefined;
}
const provider = normalizeProviderId(params.provider?.trim().toLowerCase() ?? "") || "openai";
return `Unknown model: ${provider}/${OPENAI_DIRECT_SPARK_MODEL_ID}. ${OPENAI_DIRECT_SPARK_MODEL_ID} is only supported via openai-codex OAuth. Use openai-codex/${OPENAI_DIRECT_SPARK_MODEL_ID}.`;
}

View File

@ -11,6 +11,7 @@ import {
} from "./live-auth-keys.js";
import { isModernModelRef } from "./live-model-filter.js";
import { getApiKeyForModel, requireApiKey } from "./model-auth.js";
import { shouldSuppressBuiltInModel } from "./model-suppression.js";
import { ensureOpenClawModelsJson } from "./models-config.js";
import { isRateLimitErrorMessage } from "./pi-embedded-helpers/errors.js";
import { discoverAuthStorage, discoverModels } from "./pi-model-discovery.js";
@ -202,6 +203,31 @@ function resolveTestReasoning(
return "low";
}
function resolveLiveSystemPrompt(model: Model<Api>): string | undefined {
if (model.provider === "openai-codex") {
return "You are a concise assistant. Follow the user's instruction exactly.";
}
return undefined;
}
describe("resolveLiveSystemPrompt", () => {
it("adds instructions for openai-codex probes", () => {
expect(
resolveLiveSystemPrompt({
provider: "openai-codex",
} as Model<Api>),
).toContain("Follow the user's instruction exactly.");
});
it("keeps other providers unchanged", () => {
expect(
resolveLiveSystemPrompt({
provider: "openai",
} as Model<Api>),
).toBeUndefined();
});
});
async function completeSimpleWithTimeout<TApi extends Api>(
model: Model<TApi>,
context: Parameters<typeof completeSimple<TApi>>[1],
@ -246,6 +272,7 @@ async function completeOkWithRetry(params: {
const res = await completeSimpleWithTimeout(
params.model,
{
systemPrompt: resolveLiveSystemPrompt(params.model),
messages: [
{
role: "user",
@ -317,6 +344,9 @@ describeLive("live models (profile keys)", () => {
}> = [];
for (const model of models) {
if (shouldSuppressBuiltInModel({ provider: model.provider, id: model.id })) {
continue;
}
if (providers && !providers.has(model.provider)) {
continue;
}

View File

@ -58,6 +58,16 @@ describe("pi embedded model e2e smoke", () => {
expect(result.model).toMatchObject(buildOpenAICodexForwardCompatExpectation("gpt-5.4"));
});
it("builds an openai-codex forward-compat fallback for gpt-5.3-codex-spark", () => {
mockOpenAICodexTemplateModel();
const result = resolveModel("openai-codex", "gpt-5.3-codex-spark", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject(
buildOpenAICodexForwardCompatExpectation("gpt-5.3-codex-spark"),
);
});
it("keeps unknown-model errors for non-forward-compat IDs", () => {
const result = resolveModel("openai-codex", "gpt-4.1-mini", "/tmp/agent");
expect(result.model).toBeUndefined();

View File

@ -35,15 +35,25 @@ export function mockOpenAICodexTemplateModel(): void {
export function buildOpenAICodexForwardCompatExpectation(
id: string = "gpt-5.3-codex",
): Partial<typeof OPENAI_CODEX_TEMPLATE_MODEL> & { provider: string; id: string } {
): Partial<ModelDefinitionConfig> & {
provider: string;
id: string;
api: string;
baseUrl: string;
} {
const isGpt54 = id === "gpt-5.4";
const isSpark = id === "gpt-5.3-codex-spark";
return {
provider: "openai-codex",
id,
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
contextWindow: isGpt54 ? 1_050_000 : 272000,
input: isSpark ? ["text"] : ["text", "image"],
cost: isSpark
? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
: OPENAI_CODEX_TEMPLATE_MODEL.cost,
contextWindow: isGpt54 ? 1_050_000 : isSpark ? 128_000 : 272000,
maxTokens: 128000,
};
}

View File

@ -546,6 +546,60 @@ describe("resolveModel", () => {
expect(result.model).toMatchObject(buildOpenAICodexForwardCompatExpectation("gpt-5.4"));
});
it("builds an openai-codex fallback for gpt-5.3-codex-spark", () => {
mockOpenAICodexTemplateModel();
const result = resolveModel("openai-codex", "gpt-5.3-codex-spark", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject(
buildOpenAICodexForwardCompatExpectation("gpt-5.3-codex-spark"),
);
});
it("keeps openai-codex gpt-5.3-codex-spark when discovery provides it", () => {
mockDiscoveredModel({
provider: "openai-codex",
modelId: "gpt-5.3-codex-spark",
templateModel: {
...buildOpenAICodexForwardCompatExpectation("gpt-5.3-codex-spark"),
name: "GPT-5.3 Codex Spark",
input: ["text"],
},
});
const result = resolveModel("openai-codex", "gpt-5.3-codex-spark", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
provider: "openai-codex",
id: "gpt-5.3-codex-spark",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
});
});
it("rejects stale direct openai gpt-5.3-codex-spark discovery rows", () => {
mockDiscoveredModel({
provider: "openai",
modelId: "gpt-5.3-codex-spark",
templateModel: buildForwardCompatTemplate({
id: "gpt-5.3-codex-spark",
name: "GPT-5.3 Codex Spark",
provider: "openai",
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
}),
});
const result = resolveModel("openai", "gpt-5.3-codex-spark", "/tmp/agent");
expect(result.model).toBeUndefined();
expect(result.error).toBe(
"Unknown model: openai/gpt-5.3-codex-spark. gpt-5.3-codex-spark is only supported via openai-codex OAuth. Use openai-codex/gpt-5.3-codex-spark.",
);
});
it("applies provider overrides to openai gpt-5.4 forward-compat models", () => {
mockDiscoveredModel({
provider: "openai",
@ -725,6 +779,24 @@ describe("resolveModel", () => {
expectUnknownModelError("openai-codex", "gpt-4.1-mini");
});
it("rejects direct openai gpt-5.3-codex-spark with a codex-only hint", () => {
const result = resolveModel("openai", "gpt-5.3-codex-spark", "/tmp/agent");
expect(result.model).toBeUndefined();
expect(result.error).toBe(
"Unknown model: openai/gpt-5.3-codex-spark. gpt-5.3-codex-spark is only supported via openai-codex OAuth. Use openai-codex/gpt-5.3-codex-spark.",
);
});
it("rejects azure openai gpt-5.3-codex-spark with a codex-only hint", () => {
const result = resolveModel("azure-openai-responses", "gpt-5.3-codex-spark", "/tmp/agent");
expect(result.model).toBeUndefined();
expect(result.error).toBe(
"Unknown model: azure-openai-responses/gpt-5.3-codex-spark. gpt-5.3-codex-spark is only supported via openai-codex OAuth. Use openai-codex/gpt-5.3-codex-spark.",
);
});
it("uses codex fallback even when openai-codex provider is configured", () => {
// This test verifies the ordering: codex fallback must fire BEFORE the generic providerCfg fallback.
// If ordering is wrong, the generic fallback would use api: "openai-responses" (the default)

View File

@ -8,6 +8,10 @@ import { buildModelAliasLines } from "../model-alias-lines.js";
import { isSecretRefHeaderValueMarker } from "../model-auth-markers.js";
import { resolveForwardCompatModel } from "../model-forward-compat.js";
import { findNormalizedProviderValue, normalizeProviderId } from "../model-selection.js";
import {
buildSuppressedBuiltInModelError,
shouldSuppressBuiltInModel,
} from "../model-suppression.js";
import { discoverAuthStorage, discoverModels } from "../pi-model-discovery.js";
import { normalizeResolvedProviderModel } from "./model.provider-normalization.js";
@ -159,6 +163,9 @@ export function resolveModelWithRegistry(params: {
cfg?: OpenClawConfig;
}): Model<Api> | undefined {
const { provider, modelId, modelRegistry, cfg } = params;
if (shouldSuppressBuiltInModel({ provider, id: modelId })) {
return undefined;
}
const providerConfig = resolveConfiguredProviderConfig(cfg, provider);
const model = modelRegistry.find(provider, modelId) as Model<Api> | null;
@ -303,6 +310,10 @@ const LOCAL_PROVIDER_HINTS: Record<string, string> = {
};
function buildUnknownModelError(provider: string, modelId: string): string {
const suppressed = buildSuppressedBuiltInModelError({ provider, id: modelId });
if (suppressed) {
return suppressed;
}
const base = `Unknown model: ${provider}/${modelId}`;
const hint = LOCAL_PROVIDER_HINTS[provider.toLowerCase()];
return hint ? `${base}. ${hint}` : base;

View File

@ -1 +1,6 @@
export { discoverAuthStorage, discoverModels } from "./pi-model-discovery.js";
export {
AuthStorage,
discoverAuthStorage,
discoverModels,
ModelRegistry,
} from "./pi-model-discovery.js";

View File

@ -365,7 +365,7 @@ export async function createModelSelectionState(params: {
}
if (sessionEntry && sessionStore && sessionKey && sessionEntry.authProfileOverride) {
const { ensureAuthProfileStore } = await import("../../agents/auth-profiles.js");
const { ensureAuthProfileStore } = await import("../../agents/auth-profiles.runtime.js");
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false,
});

View File

@ -512,13 +512,15 @@ describe("update-cli", () => {
call[0][1] === "i" &&
call[0][2] === "-g",
);
const mergedPath = updateCall?.[1]?.env?.Path ?? updateCall?.[1]?.env?.PATH ?? "";
const updateOptions =
typeof updateCall?.[1] === "object" && updateCall[1] !== null ? updateCall[1] : undefined;
const mergedPath = updateOptions?.env?.Path ?? updateOptions?.env?.PATH ?? "";
expect(mergedPath.split(path.delimiter).slice(0, 2)).toEqual([
portableGitMingw,
portableGitUsr,
]);
expect(updateCall?.[1]?.env?.NPM_CONFIG_SCRIPT_SHELL).toBe("cmd.exe");
expect(updateCall?.[1]?.env?.NODE_LLAMA_CPP_SKIP_DOWNLOAD).toBe("1");
expect(updateOptions?.env?.NPM_CONFIG_SCRIPT_SHELL).toBe("cmd.exe");
expect(updateOptions?.env?.NODE_LLAMA_CPP_SKIP_DOWNLOAD).toBe("1");
});
it("uses OPENCLAW_UPDATE_PACKAGE_SPEC for package updates", async () => {

View File

@ -107,6 +107,40 @@ describe("doctor config flow", () => {
).toBe(false);
});
it("warns on mutable Zalouser group entries when dangerous name matching is disabled", async () => {
const doctorWarnings = await collectDoctorWarnings({
channels: {
zalouser: {
groups: {
"Ops Room": { allow: true },
},
},
},
});
expect(
doctorWarnings.some(
(line) =>
line.includes("mutable allowlist") && line.includes("channels.zalouser.groups: Ops Room"),
),
).toBe(true);
});
it("does not warn on mutable Zalouser group entries when dangerous name matching is enabled", async () => {
const doctorWarnings = await collectDoctorWarnings({
channels: {
zalouser: {
dangerouslyAllowNameMatching: true,
groups: {
"Ops Room": { allow: true },
},
},
},
});
expect(doctorWarnings.some((line) => line.includes("channels.zalouser.groups"))).toBe(false);
});
it("warns when imessage group allowlist is empty even if allowFrom is set", async () => {
const doctorWarnings = await collectDoctorWarnings({
channels: {

View File

@ -44,6 +44,7 @@ import {
isMSTeamsMutableAllowEntry,
isMattermostMutableAllowEntry,
isSlackMutableAllowEntry,
isZalouserMutableGroupEntry,
} from "../security/mutable-allowlist-detectors.js";
import { inspectTelegramAccount } from "../telegram/account-inspect.js";
import { listTelegramAccountIds, resolveTelegramAccount } from "../telegram/accounts.js";
@ -885,6 +886,27 @@ function scanMutableAllowlistEntries(cfg: OpenClawConfig): MutableAllowlistHit[]
}
}
for (const scope of collectProviderDangerousNameMatchingScopes(cfg, "zalouser")) {
if (scope.dangerousNameMatchingEnabled) {
continue;
}
const groups = asObjectRecord(scope.account.groups);
if (!groups) {
continue;
}
for (const entry of Object.keys(groups)) {
if (!isZalouserMutableGroupEntry(entry)) {
continue;
}
hits.push({
channel: "zalouser",
path: `${scope.prefix}.groups`,
entry,
dangerousFlagPath: scope.dangerousFlagPath,
});
}
}
return hits;
}

View File

@ -163,6 +163,30 @@ describe("models list/status", () => {
baseUrl: "https://api.openai.com/v1",
contextWindow: 128000,
};
const OPENAI_SPARK_MODEL = {
provider: "openai",
id: "gpt-5.3-codex-spark",
name: "GPT-5.3 Codex Spark",
input: ["text", "image"],
baseUrl: "https://api.openai.com/v1",
contextWindow: 128000,
};
const OPENAI_CODEX_SPARK_MODEL = {
provider: "openai-codex",
id: "gpt-5.3-codex-spark",
name: "GPT-5.3 Codex Spark",
input: ["text"],
baseUrl: "https://chatgpt.com/backend-api",
contextWindow: 128000,
};
const AZURE_OPENAI_SPARK_MODEL = {
provider: "azure-openai-responses",
id: "gpt-5.3-codex-spark",
name: "GPT-5.3 Codex Spark",
input: ["text", "image"],
baseUrl: "https://example.openai.azure.com/openai/v1",
contextWindow: 128000,
};
const GOOGLE_ANTIGRAVITY_TEMPLATE_BASE = {
provider: "google-antigravity",
api: "google-gemini-cli",
@ -363,6 +387,34 @@ describe("models list/status", () => {
expect(ensureOpenClawModelsJson).not.toHaveBeenCalled();
});
it("filters stale direct OpenAI spark rows from models list and registry views", async () => {
setDefaultModel("openai-codex/gpt-5.3-codex-spark");
modelRegistryState.models = [
OPENAI_SPARK_MODEL,
AZURE_OPENAI_SPARK_MODEL,
OPENAI_CODEX_SPARK_MODEL,
];
modelRegistryState.available = [
OPENAI_SPARK_MODEL,
AZURE_OPENAI_SPARK_MODEL,
OPENAI_CODEX_SPARK_MODEL,
];
const runtime = makeRuntime();
await modelsListCommand({ all: true, json: true }, runtime);
const payload = parseJsonLog(runtime);
expect(payload.models.map((model: { key: string }) => model.key)).toEqual([
"openai-codex/gpt-5.3-codex-spark",
]);
const loaded = await loadModelRegistry({} as never);
expect(loaded.models.map((model) => `${model.provider}/${model.id}`)).toEqual([
"openai-codex/gpt-5.3-codex-spark",
]);
expect(Array.from(loaded.availableKeys ?? [])).toEqual(["openai-codex/gpt-5.3-codex-spark"]);
});
it("modelsListCommand persists using the write snapshot config when provided", async () => {
modelRegistryState.models = [OPENAI_MODEL];
modelRegistryState.available = [OPENAI_MODEL];

View File

@ -347,5 +347,55 @@ describe("modelsListCommand forward-compat", () => {
}),
]);
});
it("suppresses direct openai gpt-5.3-codex-spark rows in --all output", async () => {
mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] });
mocks.loadModelRegistry.mockResolvedValueOnce({
models: [
{
provider: "openai",
id: "gpt-5.3-codex-spark",
name: "GPT-5.3 Codex Spark",
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
input: ["text", "image"],
contextWindow: 128000,
maxTokens: 32000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
},
{
provider: "azure-openai-responses",
id: "gpt-5.3-codex-spark",
name: "GPT-5.3 Codex Spark",
api: "azure-openai-responses",
baseUrl: "https://example.openai.azure.com/openai/v1",
input: ["text", "image"],
contextWindow: 128000,
maxTokens: 32000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
},
{ ...OPENAI_CODEX_53_MODEL },
],
availableKeys: new Set([
"openai/gpt-5.3-codex-spark",
"azure-openai-responses/gpt-5.3-codex-spark",
"openai-codex/gpt-5.3-codex",
]),
registry: {
getAll: () => [{ ...OPENAI_CODEX_53_MODEL }],
},
});
mocks.loadModelCatalog.mockResolvedValueOnce([]);
const runtime = createRuntime();
await modelsListCommand({ all: true, json: true }, runtime as never);
expect(mocks.printModelTable).toHaveBeenCalled();
expect(lastPrintedRows<{ key: string }>()).toEqual([
expect.objectContaining({
key: "openai-codex/gpt-5.3-codex",
}),
]);
});
});
});

View File

@ -25,7 +25,7 @@ export async function modelsListCommand(
runtime: RuntimeEnv,
) {
ensureFlagCompatibility(opts);
const { ensureAuthProfileStore } = await import("../../agents/auth-profiles.js");
const { ensureAuthProfileStore } = await import("../../agents/auth-profiles.runtime.js");
const { ensureOpenClawModelsJson } = await import("../../agents/models-config.js");
const { sourceConfig, resolvedConfig: cfg } = await loadModelsConfigWithSource({
commandName: "models list",

Some files were not shown because too many files have changed in this diff Show More