Merge branch 'main' into main

This commit is contained in:
longman 2026-03-11 15:16:56 +08:00
commit afc6c1d834
176 changed files with 7446 additions and 2616 deletions

1
.github/FUNDING.yml vendored
View File

@ -1 +0,0 @@
custom: ["https://github.com/sponsors/steipete"]

View File

@ -13,6 +13,8 @@ Docs: https://docs.openclaw.ai
- iOS/Home canvas: add a bundled welcome screen with a live agent overview that refreshes on connect, reconnect, and foreground return, and move the compact connection pill off the top-left canvas overlay. (#42456) Thanks @ngutman.
- iOS/Home canvas: replace floating controls with a docked toolbar, make the bundled home scaffold adapt to smaller phones, and open chat in the resolved main session instead of a synthetic `ios` session. (#42456) Thanks @ngutman.
- Discord/auto threads: add `autoArchiveDuration` channel config for auto-created threads so Discord thread archiving can stay at 1 hour, 1 day, 3 days, or 1 week instead of always using the 1-hour default. (#35065) Thanks @davidguttman.
- OpenCode/onboarding: add new OpenCode Go provider, treat Zen and Go as one OpenCode setup in the wizard/docs while keeping the runtime providers split, store one shared OpenCode key for both profiles, and stop overriding the built-in `opencode-go` catalog routing. (#42313) Thanks @ImLukeF and @vincentkoc.
- macOS/chat UI: add a chat model picker, persist explicit thinking-level selections across relaunch, and harden provider-aware session model sync for the shared chat composer. (#42314) Thanks @ImLukeF.
### Breaking
@ -78,11 +80,18 @@ Docs: https://docs.openclaw.ai
- Secrets/SecretRef: reject exec SecretRef traversal ids across schema, runtime, and gateway. (#42370) Thanks @joshavant.
- Telegram/docs: clarify that `channels.telegram.groups` allowlists chats while `groupAllowFrom` allowlists users inside those chats, and point invalid negative chat IDs at the right config key. (#42451) Thanks @altaywtf.
- Models/Alibaba Cloud Model Studio: wire `MODELSTUDIO_API_KEY` through shared env auth, implicit provider discovery, and shell-env fallback so onboarding works outside the wizard too. (#40634) Thanks @pomelo-nwu.
- Subagents/authority: persist leaf vs orchestrator control scope at spawn time and route tool plus slash-command control through shared ownership checks, so leaf sessions cannot regain orchestration privileges after restore or flat-key lookups. Thanks @tdjackey.
- ACP/sessions_spawn: implicitly stream `mode="run"` ACP spawns to parent only for eligible subagent orchestrator sessions (heartbeat `target: "last"` with a usable session-local route), restoring parent progress relays without thread binding. (#42404) Thanks @davidguttman.
- Sessions/reset model recompute: clear stale runtime model, context-token, and system-prompt metadata before session resets recompute the replacement session, so resets pick up current defaults and explicit overrides instead of reusing old runtime model state. (#41173) thanks @PonyX-lab.
- Browser/Browserbase 429 handling: surface stable no-retry rate-limit guidance without buffering discarded HTTP 429 response bodies from remote browser services. (#40491) thanks @mvanhorn.
- Gateway/auth: allow one trusted device-token retry on shared-token mismatch with recovery hints to prevent reconnect churn during token drift. (#42507) Thanks @joshavant.
- Channels/allowlists: remove stale matcher caching so same-array allowlist edits and wildcard replacements take effect immediately, with regression coverage for in-place mutation cases.
- Gateway/auth: fail closed when local `gateway.auth.*` SecretRefs are configured but unavailable, instead of silently falling back to `gateway.remote.*` credentials in local mode. (#42672) Thanks @joshavant.
- Sandbox/fs bridge: pin staged writes to verified parent directories so temporary write files cannot materialize outside the allowed mount before atomic replace. Thanks @tdjackey.
- Commands/config writes: enforce `configWrites` against both the originating account and the targeted account scope for `/config` and config-backed `/allowlist` edits, blocking sibling-account mutations while preserving gateway `operator.admin` flows. Thanks @tdjackey for reporting.
- Security/system.run: fail closed for approval-backed interpreter/runtime commands when OpenClaw cannot bind exactly one concrete local file operand, while extending best-effort direct-file binding to additional runtime forms. Thanks @tdjackey for reporting.
- Gateway/session reset auth: split conversation `/new` and `/reset` handling away from the admin-only `sessions.reset` control-plane RPC so write-scoped gateway callers can no longer reach the privileged reset path through `agent`. Thanks @tdjackey for reporting.
- Telegram/final preview delivery followup: keep ambiguous missing-`message_id` finals only when a preview was already visible, while first-preview/no-id cases still fall back so Telegram users do not lose the final reply. (#41932) thanks @hougangdev.
## 2026.3.8
@ -138,6 +147,7 @@ Docs: https://docs.openclaw.ai
- Docs/Changelog: correct the contributor credit for the bundled Control UI global-install fix to @LarytheLord. (#40420) Thanks @velvet-shark.
- Telegram/media downloads: time out only stalled body reads so polling recovers from hung file downloads without aborting slow downloads that are still streaming data. (#40098) thanks @tysoncung.
- Docker/runtime image: prune dev dependencies, strip build-only dist metadata for smaller Docker images. (#40307) Thanks @vincentkoc.
- Subagents/sandboxing: restrict leaf subagents to their own spawned runs and remove leaf `subagents` control access so sandboxed leaf workers can no longer steer sibling sessions. Thanks @tdjackey.
- Gateway/restart timeout recovery: exit non-zero when restart-triggered shutdown drains time out so launchd/systemd restart the gateway instead of treating the failed restart as a clean stop. Landed from contributor PR #40380 by @dsantoreis. Thanks @dsantoreis.
- Gateway/config restart guard: validate config before service start/restart and keep post-SIGUSR1 startup failures from crashing the gateway process, reducing invalid-config restart loops and macOS permission loss. Landed from contributor PR #38699 by @lml2468. Thanks @lml2468.
- Gateway/launchd respawn detection: treat `XPC_SERVICE_NAME` as a launchd supervision hint so macOS restarts exit cleanly under launchd instead of attempting detached self-respawn. Landed from contributor PR #20555 by @dimat. Thanks @dimat.
@ -218,6 +228,7 @@ Docs: https://docs.openclaw.ai
- Onboarding/API key input hardening: strip non-Latin1 Unicode artifacts from normalized secret input (while preserving Latin-1 content and internal spaces) so malformed copied API keys cannot trigger HTTP header `ByteString` construction crashes; adds regression coverage for shared normalization and MiniMax auth header usage. (#24496) Thanks @fa6maalassaf.
- Kimi Coding/Anthropic tools compatibility: normalize `anthropic-messages` tool payloads to OpenAI-style `tools[].function` + compatible `tool_choice` when targeting Kimi Coding endpoints, restoring tool-call workflows that regressed after v2026.3.2. (#37038) Thanks @mochimochimochi-hub.
- Heartbeat/workspace-path guardrails: append explicit workspace `HEARTBEAT.md` path guidance (and `docs/heartbeat.md` avoidance) to heartbeat prompts so heartbeat runs target workspace checklists reliably across packaged install layouts. (#37037) Thanks @stofancy.
- Node/system.run approvals: bind approval prompts to the exact executed argv text and show shell payload only as a secondary preview, closing basename-spoofed wrapper approval mismatches. Thanks @tdjackey.
- Subagents/kill-complete announce race: when a late `subagent-complete` lifecycle event arrives after an earlier kill marker, clear stale kill suppression/cleanup flags and re-run announce cleanup so finished runs no longer get silently swallowed. (#37024) Thanks @cmfinlan.
- Agents/tool-result cleanup timeout hardening: on embedded runner teardown idle timeouts, clear pending tool-call state without persisting synthetic `missing tool result` entries, preventing timeout cleanups from poisoning follow-up turns; adds regression coverage for timeout clear-vs-flush behavior. (#37081) Thanks @Coyote-Den.
- Agents/openai-completions stream timeout hardening: ensure runtime undici global dispatchers use extended streaming body/header timeouts (including env-proxy dispatcher mode) before embedded runs, reducing forced mid-stream `terminated` failures on long generations; adds regression coverage for dispatcher selection and idempotent reconfiguration. (#9708) Thanks @scottchguard.

View File

@ -125,6 +125,7 @@ Plugins/extensions are part of OpenClaw's trusted computing base for a gateway.
- Any report whose only claim is that an operator-enabled `dangerous*`/`dangerously*` config option weakens defaults (these are explicit break-glass tradeoffs by design)
- Reports that depend on trusted operator-supplied configuration values to trigger availability impact (for example custom regex patterns). These may still be fixed as defense-in-depth hardening, but are not security-boundary bypasses.
- Reports whose only claim is heuristic/parity drift in command-risk detection (for example obfuscation-pattern checks) across exec surfaces, without a demonstrated trust-boundary bypass. These are hardening-only findings and are not vulnerabilities; triage may close them as `invalid`/`no-action` or track them separately as low/informational hardening.
- Reports whose only claim is that exec approvals do not semantically model every interpreter/runtime loader form, subcommand, flag combination, package script, or transitive module/config import. Exec approvals bind exact request context and best-effort direct local file operands; they are not a complete semantic model of everything a runtime may load.
- Exposed secrets that are third-party/user-controlled credentials (not OpenClaw-owned and not granting access to OpenClaw-operated infrastructure/services) without demonstrated OpenClaw impact
- Reports whose only claim is host-side exec when sandbox runtime is disabled/unavailable (documented default behavior in the trusted-operator model), without a boundary bypass.
- Reports whose only claim is that a platform-provided upload destination URL is untrusted (for example Microsoft Teams `fileConsent/invoke` `uploadInfo.uploadUrl`) without proving attacker control in an authenticated production flow.
@ -165,6 +166,7 @@ OpenClaw separates routing from execution, but both remain inside the same opera
- **Gateway** is the control plane. If a caller passes Gateway auth, they are treated as a trusted operator for that Gateway.
- **Node** is an execution extension of the Gateway. Pairing a node grants operator-level remote capability on that node.
- **Exec approvals** (allowlist/ask UI) are operator guardrails to reduce accidental command execution, not a multi-tenant authorization boundary.
- Exec approvals bind exact command/cwd/env context and, when OpenClaw can identify one concrete local script/file operand, that file snapshot too. This is best-effort integrity hardening, not a complete semantic model of every interpreter/runtime loader path.
- Differences in command-risk warning heuristics between exec surfaces (`gateway`, `node`, `sandbox`) do not, by themselves, constitute a security-boundary bypass.
- For untrusted-user isolation, split by trust boundary: separate gateways and separate OS users/hosts per boundary.

View File

@ -8,6 +8,7 @@ import QuartzCore
import SwiftUI
private let webChatSwiftLogger = Logger(subsystem: "ai.openclaw", category: "WebChatSwiftUI")
private let webChatThinkingLevelDefaultsKey = "openclaw.webchat.thinkingLevel"
private enum WebChatSwiftUILayout {
static let windowSize = NSSize(width: 500, height: 840)
@ -21,6 +22,21 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
try await GatewayConnection.shared.chatHistory(sessionKey: sessionKey)
}
func listModels() async throws -> [OpenClawChatModelChoice] {
do {
let data = try await GatewayConnection.shared.request(
method: "models.list",
params: [:],
timeoutMs: 15000)
let result = try JSONDecoder().decode(ModelsListResult.self, from: data)
return result.models.map(Self.mapModelChoice)
} catch {
webChatSwiftLogger.warning(
"models.list failed; hiding model picker: \(error.localizedDescription, privacy: .public)")
return []
}
}
func abortRun(sessionKey: String, runId: String) async throws {
_ = try await GatewayConnection.shared.request(
method: "chat.abort",
@ -46,6 +62,28 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
return try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: data)
}
func setSessionModel(sessionKey: String, model: String?) async throws {
var params: [String: AnyCodable] = [
"key": AnyCodable(sessionKey),
]
params["model"] = model.map(AnyCodable.init) ?? AnyCodable(NSNull())
_ = try await GatewayConnection.shared.request(
method: "sessions.patch",
params: params,
timeoutMs: 15000)
}
func setSessionThinking(sessionKey: String, thinkingLevel: String) async throws {
let params: [String: AnyCodable] = [
"key": AnyCodable(sessionKey),
"thinkingLevel": AnyCodable(thinkingLevel),
]
_ = try await GatewayConnection.shared.request(
method: "sessions.patch",
params: params,
timeoutMs: 15000)
}
func sendMessage(
sessionKey: String,
message: String,
@ -133,6 +171,14 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
return .seqGap
}
}
private static func mapModelChoice(_ model: OpenClawProtocol.ModelChoice) -> OpenClawChatModelChoice {
OpenClawChatModelChoice(
modelID: model.id,
name: model.name,
provider: model.provider,
contextWindow: model.contextwindow)
}
}
// MARK: - Window controller
@ -155,7 +201,13 @@ final class WebChatSwiftUIWindowController {
init(sessionKey: String, presentation: WebChatPresentation, transport: any OpenClawChatTransport) {
self.sessionKey = sessionKey
self.presentation = presentation
let vm = OpenClawChatViewModel(sessionKey: sessionKey, transport: transport)
let vm = OpenClawChatViewModel(
sessionKey: sessionKey,
transport: transport,
initialThinkingLevel: Self.persistedThinkingLevel(),
onThinkingLevelChanged: { level in
UserDefaults.standard.set(level, forKey: webChatThinkingLevelDefaultsKey)
})
let accent = Self.color(fromHex: AppStateStore.shared.seamColorHex)
self.hosting = NSHostingController(rootView: OpenClawChatView(
viewModel: vm,
@ -254,6 +306,16 @@ final class WebChatSwiftUIWindowController {
OverlayPanelFactory.clearGlobalEventMonitor(&self.dismissMonitor)
}
private static func persistedThinkingLevel() -> String? {
let stored = UserDefaults.standard.string(forKey: webChatThinkingLevelDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
guard let stored, ["off", "minimal", "low", "medium", "high", "xhigh", "adaptive"].contains(stored) else {
return nil
}
return stored
}
private static func makeWindow(
for presentation: WebChatPresentation,
contentViewController: NSViewController) -> NSWindow

View File

@ -1337,6 +1337,8 @@ public struct SessionsPatchParams: Codable, Sendable {
public let model: AnyCodable?
public let spawnedby: AnyCodable?
public let spawndepth: AnyCodable?
public let subagentrole: AnyCodable?
public let subagentcontrolscope: AnyCodable?
public let sendpolicy: AnyCodable?
public let groupactivation: AnyCodable?
@ -1355,6 +1357,8 @@ public struct SessionsPatchParams: Codable, Sendable {
model: AnyCodable?,
spawnedby: AnyCodable?,
spawndepth: AnyCodable?,
subagentrole: AnyCodable?,
subagentcontrolscope: AnyCodable?,
sendpolicy: AnyCodable?,
groupactivation: AnyCodable?)
{
@ -1372,6 +1376,8 @@ public struct SessionsPatchParams: Codable, Sendable {
self.model = model
self.spawnedby = spawnedby
self.spawndepth = spawndepth
self.subagentrole = subagentrole
self.subagentcontrolscope = subagentcontrolscope
self.sendpolicy = sendpolicy
self.groupactivation = groupactivation
}
@ -1391,6 +1397,8 @@ public struct SessionsPatchParams: Codable, Sendable {
case model
case spawnedby = "spawnedBy"
case spawndepth = "spawnDepth"
case subagentrole = "subagentRole"
case subagentcontrolscope = "subagentControlScope"
case sendpolicy = "sendPolicy"
case groupactivation = "groupActivation"
}
@ -3046,7 +3054,7 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
public struct ExecApprovalRequestParams: Codable, Sendable {
public let id: String?
public let command: String
public let command: String?
public let commandargv: [String]?
public let systemrunplan: [String: AnyCodable]?
public let env: [String: AnyCodable]?
@ -3067,7 +3075,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
public init(
id: String?,
command: String,
command: String?,
commandargv: [String]?,
systemrunplan: [String: AnyCodable]?,
env: [String: AnyCodable]?,

View File

@ -9,6 +9,8 @@ import UniformTypeIdentifiers
@MainActor
struct OpenClawChatComposer: View {
private static let menuThinkingLevels = ["off", "low", "medium", "high"]
@Bindable var viewModel: OpenClawChatViewModel
let style: OpenClawChatView.Style
let showsSessionSwitcher: Bool
@ -27,11 +29,15 @@ struct OpenClawChatComposer: View {
if self.showsSessionSwitcher {
self.sessionPicker
}
if self.viewModel.showsModelPicker {
self.modelPicker
}
self.thinkingPicker
Spacer()
self.refreshButton
self.attachmentPicker
}
.padding(.horizontal, 10)
}
if self.showsAttachments, !self.viewModel.attachments.isEmpty {
@ -83,11 +89,19 @@ struct OpenClawChatComposer: View {
}
private var thinkingPicker: some View {
Picker("Thinking", selection: self.$viewModel.thinkingLevel) {
Picker(
"Thinking",
selection: Binding(
get: { self.viewModel.thinkingLevel },
set: { next in self.viewModel.selectThinkingLevel(next) }))
{
Text("Off").tag("off")
Text("Low").tag("low")
Text("Medium").tag("medium")
Text("High").tag("high")
if !Self.menuThinkingLevels.contains(self.viewModel.thinkingLevel) {
Text(self.viewModel.thinkingLevel.capitalized).tag(self.viewModel.thinkingLevel)
}
}
.labelsHidden()
.pickerStyle(.menu)
@ -95,6 +109,25 @@ struct OpenClawChatComposer: View {
.frame(maxWidth: 140, alignment: .leading)
}
private var modelPicker: some View {
Picker(
"Model",
selection: Binding(
get: { self.viewModel.modelSelectionID },
set: { next in self.viewModel.selectModel(next) }))
{
Text(self.viewModel.defaultModelLabel).tag(OpenClawChatViewModel.defaultModelSelectionID)
ForEach(self.viewModel.modelChoices) { model in
Text(model.displayLabel).tag(model.selectionID)
}
}
.labelsHidden()
.pickerStyle(.menu)
.controlSize(.small)
.frame(maxWidth: 240, alignment: .leading)
.help("Model")
}
private var sessionPicker: some View {
Picker(
"Session",

View File

@ -1,5 +1,36 @@
import Foundation
public struct OpenClawChatModelChoice: Identifiable, Codable, Sendable, Hashable {
public var id: String { self.selectionID }
public let modelID: String
public let name: String
public let provider: String
public let contextWindow: Int?
public init(modelID: String, name: String, provider: String, contextWindow: Int?) {
self.modelID = modelID
self.name = name
self.provider = provider
self.contextWindow = contextWindow
}
/// Provider-qualified model ref used for picker identity and selection tags.
public var selectionID: String {
let trimmedProvider = self.provider.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedProvider.isEmpty else { return self.modelID }
let providerPrefix = "\(trimmedProvider)/"
if self.modelID.hasPrefix(providerPrefix) {
return self.modelID
}
return "\(trimmedProvider)/\(self.modelID)"
}
public var displayLabel: String {
self.selectionID
}
}
public struct OpenClawChatSessionsDefaults: Codable, Sendable {
public let model: String?
public let contextTokens: Int?
@ -27,6 +58,7 @@ public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashabl
public let outputTokens: Int?
public let totalTokens: Int?
public let modelProvider: String?
public let model: String?
public let contextTokens: Int?
}

View File

@ -10,6 +10,7 @@ public enum OpenClawChatTransportEvent: Sendable {
public protocol OpenClawChatTransport: Sendable {
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload
func listModels() async throws -> [OpenClawChatModelChoice]
func sendMessage(
sessionKey: String,
message: String,
@ -19,6 +20,8 @@ public protocol OpenClawChatTransport: Sendable {
func abortRun(sessionKey: String, runId: String) async throws
func listSessions(limit: Int?) async throws -> OpenClawChatSessionsListResponse
func setSessionModel(sessionKey: String, model: String?) async throws
func setSessionThinking(sessionKey: String, thinkingLevel: String) async throws
func requestHealth(timeoutMs: Int) async throws -> Bool
func events() -> AsyncStream<OpenClawChatTransportEvent>
@ -42,4 +45,25 @@ extension OpenClawChatTransport {
code: 0,
userInfo: [NSLocalizedDescriptionKey: "sessions.list not supported by this transport"])
}
public func listModels() async throws -> [OpenClawChatModelChoice] {
throw NSError(
domain: "OpenClawChatTransport",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "models.list not supported by this transport"])
}
public func setSessionModel(sessionKey _: String, model _: String?) async throws {
throw NSError(
domain: "OpenClawChatTransport",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "sessions.patch(model) not supported by this transport"])
}
public func setSessionThinking(sessionKey _: String, thinkingLevel _: String) async throws {
throw NSError(
domain: "OpenClawChatTransport",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "sessions.patch(thinkingLevel) not supported by this transport"])
}
}

View File

@ -15,9 +15,13 @@ private let chatUILogger = Logger(subsystem: "ai.openclaw", category: "OpenClawC
@MainActor
@Observable
public final class OpenClawChatViewModel {
public static let defaultModelSelectionID = "__default__"
public private(set) var messages: [OpenClawChatMessage] = []
public var input: String = ""
public var thinkingLevel: String = "off"
public private(set) var thinkingLevel: String
public private(set) var modelSelectionID: String = "__default__"
public private(set) var modelChoices: [OpenClawChatModelChoice] = []
public private(set) var isLoading = false
public private(set) var isSending = false
public private(set) var isAborting = false
@ -32,6 +36,9 @@ public final class OpenClawChatViewModel {
public private(set) var pendingToolCalls: [OpenClawChatPendingToolCall] = []
public private(set) var sessions: [OpenClawChatSessionEntry] = []
private let transport: any OpenClawChatTransport
private var sessionDefaults: OpenClawChatSessionsDefaults?
private let prefersExplicitThinkingLevel: Bool
private let onThinkingLevelChanged: (@MainActor @Sendable (String) -> Void)?
@ObservationIgnored
private nonisolated(unsafe) var eventTask: Task<Void, Never>?
@ -42,6 +49,17 @@ public final class OpenClawChatViewModel {
@ObservationIgnored
private nonisolated(unsafe) var pendingRunTimeoutTasks: [String: Task<Void, Never>] = [:]
private let pendingRunTimeoutMs: UInt64 = 120_000
// Session switches can overlap in-flight picker patches, so stale completions
// must compare against the latest request and latest desired value for that session.
private var nextModelSelectionRequestID: UInt64 = 0
private var latestModelSelectionRequestIDsBySession: [String: UInt64] = [:]
private var latestModelSelectionIDsBySession: [String: String] = [:]
private var lastSuccessfulModelSelectionIDsBySession: [String: String] = [:]
private var inFlightModelPatchCountsBySession: [String: Int] = [:]
private var modelPatchWaitersBySession: [String: [CheckedContinuation<Void, Never>]] = [:]
private var nextThinkingSelectionRequestID: UInt64 = 0
private var latestThinkingSelectionRequestIDsBySession: [String: UInt64] = [:]
private var latestThinkingLevelsBySession: [String: String] = [:]
private var pendingToolCallsById: [String: OpenClawChatPendingToolCall] = [:] {
didSet {
@ -52,9 +70,18 @@ public final class OpenClawChatViewModel {
private var lastHealthPollAt: Date?
public init(sessionKey: String, transport: any OpenClawChatTransport) {
public init(
sessionKey: String,
transport: any OpenClawChatTransport,
initialThinkingLevel: String? = nil,
onThinkingLevelChanged: (@MainActor @Sendable (String) -> Void)? = nil)
{
self.sessionKey = sessionKey
self.transport = transport
let normalizedThinkingLevel = Self.normalizedThinkingLevel(initialThinkingLevel)
self.thinkingLevel = normalizedThinkingLevel ?? "off"
self.prefersExplicitThinkingLevel = normalizedThinkingLevel != nil
self.onThinkingLevelChanged = onThinkingLevelChanged
self.eventTask = Task { [weak self] in
guard let self else { return }
@ -99,6 +126,14 @@ public final class OpenClawChatViewModel {
Task { await self.performSwitchSession(to: sessionKey) }
}
public func selectThinkingLevel(_ level: String) {
Task { await self.performSelectThinkingLevel(level) }
}
public func selectModel(_ selectionID: String) {
Task { await self.performSelectModel(selectionID) }
}
public var sessionChoices: [OpenClawChatSessionEntry] {
let now = Date().timeIntervalSince1970 * 1000
let cutoff = now - (24 * 60 * 60 * 1000)
@ -134,6 +169,17 @@ public final class OpenClawChatViewModel {
return result
}
public var showsModelPicker: Bool {
!self.modelChoices.isEmpty
}
public var defaultModelLabel: String {
guard let defaultModelID = self.normalizedModelSelectionID(self.sessionDefaults?.model) else {
return "Default"
}
return "Default: \(self.modelLabel(for: defaultModelID))"
}
public func addAttachments(urls: [URL]) {
Task { await self.loadAttachments(urls: urls) }
}
@ -174,11 +220,14 @@ public final class OpenClawChatViewModel {
previous: self.messages,
incoming: Self.decodeMessages(payload.messages ?? []))
self.sessionId = payload.sessionId
if let level = payload.thinkingLevel, !level.isEmpty {
if !self.prefersExplicitThinkingLevel,
let level = Self.normalizedThinkingLevel(payload.thinkingLevel)
{
self.thinkingLevel = level
}
await self.pollHealthIfNeeded(force: true)
await self.fetchSessions(limit: 50)
await self.fetchModels()
self.errorText = nil
} catch {
self.errorText = error.localizedDescription
@ -320,6 +369,7 @@ public final class OpenClawChatViewModel {
guard !self.isSending else { return }
let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty || !self.attachments.isEmpty else { return }
let sessionKey = self.sessionKey
guard self.healthOK else {
self.errorText = "Gateway health not OK; cannot send"
@ -330,6 +380,7 @@ public final class OpenClawChatViewModel {
self.errorText = nil
let runId = UUID().uuidString
let messageText = trimmed.isEmpty && !self.attachments.isEmpty ? "See attached." : trimmed
let thinkingLevel = self.thinkingLevel
self.pendingRuns.insert(runId)
self.armPendingRunTimeout(runId: runId)
self.pendingToolCallsById = [:]
@ -382,10 +433,11 @@ public final class OpenClawChatViewModel {
self.attachments = []
do {
await self.waitForPendingModelPatches(in: sessionKey)
let response = try await self.transport.sendMessage(
sessionKey: self.sessionKey,
sessionKey: sessionKey,
message: messageText,
thinking: self.thinkingLevel,
thinking: thinkingLevel,
idempotencyKey: runId,
attachments: encodedAttachments)
if response.runId != runId {
@ -422,6 +474,17 @@ public final class OpenClawChatViewModel {
do {
let res = try await self.transport.listSessions(limit: limit)
self.sessions = res.sessions
self.sessionDefaults = res.defaults
self.syncSelectedModel()
} catch {
// Best-effort.
}
}
private func fetchModels() async {
do {
self.modelChoices = try await self.transport.listModels()
self.syncSelectedModel()
} catch {
// Best-effort.
}
@ -432,9 +495,106 @@ public final class OpenClawChatViewModel {
guard !next.isEmpty else { return }
guard next != self.sessionKey else { return }
self.sessionKey = next
self.modelSelectionID = Self.defaultModelSelectionID
await self.bootstrap()
}
private func performSelectThinkingLevel(_ level: String) async {
let next = Self.normalizedThinkingLevel(level) ?? "off"
guard next != self.thinkingLevel else { return }
let sessionKey = self.sessionKey
self.thinkingLevel = next
self.onThinkingLevelChanged?(next)
self.nextThinkingSelectionRequestID &+= 1
let requestID = self.nextThinkingSelectionRequestID
self.latestThinkingSelectionRequestIDsBySession[sessionKey] = requestID
self.latestThinkingLevelsBySession[sessionKey] = next
do {
try await self.transport.setSessionThinking(sessionKey: sessionKey, thinkingLevel: next)
guard requestID == self.latestThinkingSelectionRequestIDsBySession[sessionKey] else {
let latest = self.latestThinkingLevelsBySession[sessionKey] ?? next
guard latest != next else { return }
try? await self.transport.setSessionThinking(sessionKey: sessionKey, thinkingLevel: latest)
return
}
} catch {
guard sessionKey == self.sessionKey,
requestID == self.latestThinkingSelectionRequestIDsBySession[sessionKey]
else { return }
// Best-effort. Persisting the user's local preference matters more than a patch error here.
}
}
private func performSelectModel(_ selectionID: String) async {
let next = self.normalizedSelectionID(selectionID)
guard next != self.modelSelectionID else { return }
let sessionKey = self.sessionKey
let previous = self.modelSelectionID
let previousRequestID = self.latestModelSelectionRequestIDsBySession[sessionKey]
self.nextModelSelectionRequestID &+= 1
let requestID = self.nextModelSelectionRequestID
let nextModelRef = self.modelRef(forSelectionID: next)
self.latestModelSelectionRequestIDsBySession[sessionKey] = requestID
self.latestModelSelectionIDsBySession[sessionKey] = next
self.beginModelPatch(for: sessionKey)
self.modelSelectionID = next
self.errorText = nil
defer { self.endModelPatch(for: sessionKey) }
do {
try await self.transport.setSessionModel(
sessionKey: sessionKey,
model: nextModelRef)
guard requestID == self.latestModelSelectionRequestIDsBySession[sessionKey] else {
self.applySuccessfulModelSelection(next, sessionKey: sessionKey, syncSelection: false)
return
}
self.applySuccessfulModelSelection(next, sessionKey: sessionKey, syncSelection: true)
} catch {
guard requestID == self.latestModelSelectionRequestIDsBySession[sessionKey] else { return }
self.latestModelSelectionIDsBySession[sessionKey] = previous
if let previousRequestID {
self.latestModelSelectionRequestIDsBySession[sessionKey] = previousRequestID
} else {
self.latestModelSelectionRequestIDsBySession.removeValue(forKey: sessionKey)
}
if self.lastSuccessfulModelSelectionIDsBySession[sessionKey] == previous {
self.applySuccessfulModelSelection(previous, sessionKey: sessionKey, syncSelection: sessionKey == self.sessionKey)
}
guard sessionKey == self.sessionKey else { return }
self.modelSelectionID = previous
self.errorText = error.localizedDescription
chatUILogger.error("sessions.patch(model) failed \(error.localizedDescription, privacy: .public)")
}
}
private func beginModelPatch(for sessionKey: String) {
self.inFlightModelPatchCountsBySession[sessionKey, default: 0] += 1
}
private func endModelPatch(for sessionKey: String) {
let remaining = max(0, (self.inFlightModelPatchCountsBySession[sessionKey] ?? 0) - 1)
if remaining == 0 {
self.inFlightModelPatchCountsBySession.removeValue(forKey: sessionKey)
let waiters = self.modelPatchWaitersBySession.removeValue(forKey: sessionKey) ?? []
for waiter in waiters {
waiter.resume()
}
return
}
self.inFlightModelPatchCountsBySession[sessionKey] = remaining
}
private func waitForPendingModelPatches(in sessionKey: String) async {
guard (self.inFlightModelPatchCountsBySession[sessionKey] ?? 0) > 0 else { return }
await withCheckedContinuation { continuation in
self.modelPatchWaitersBySession[sessionKey, default: []].append(continuation)
}
}
private func placeholderSession(key: String) -> OpenClawChatSessionEntry {
OpenClawChatSessionEntry(
key: key,
@ -453,10 +613,159 @@ public final class OpenClawChatViewModel {
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: nil,
model: nil,
contextTokens: nil)
}
private func syncSelectedModel() {
let currentSession = self.sessions.first(where: { $0.key == self.sessionKey })
let explicitModelID = self.normalizedModelSelectionID(
currentSession?.model,
provider: currentSession?.modelProvider)
if let explicitModelID {
self.lastSuccessfulModelSelectionIDsBySession[self.sessionKey] = explicitModelID
self.modelSelectionID = explicitModelID
return
}
self.lastSuccessfulModelSelectionIDsBySession[self.sessionKey] = Self.defaultModelSelectionID
self.modelSelectionID = Self.defaultModelSelectionID
}
private func normalizedSelectionID(_ selectionID: String) -> String {
let trimmed = selectionID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return Self.defaultModelSelectionID }
return trimmed
}
private func normalizedModelSelectionID(_ modelID: String?, provider: String? = nil) -> String? {
guard let modelID else { return nil }
let trimmed = modelID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if let provider = Self.normalizedProvider(provider) {
let providerQualified = Self.providerQualifiedModelSelectionID(modelID: trimmed, provider: provider)
if let match = self.modelChoices.first(where: {
$0.selectionID == providerQualified ||
($0.modelID == trimmed && Self.normalizedProvider($0.provider) == provider)
}) {
return match.selectionID
}
return providerQualified
}
if self.modelChoices.contains(where: { $0.selectionID == trimmed }) {
return trimmed
}
let matches = self.modelChoices.filter { $0.modelID == trimmed || $0.selectionID == trimmed }
if matches.count == 1 {
return matches[0].selectionID
}
return trimmed
}
private func modelRef(forSelectionID selectionID: String) -> String? {
let normalized = self.normalizedSelectionID(selectionID)
if normalized == Self.defaultModelSelectionID {
return nil
}
return normalized
}
private func modelLabel(for modelID: String) -> String {
self.modelChoices.first(where: { $0.selectionID == modelID || $0.modelID == modelID })?.displayLabel ??
modelID
}
private func applySuccessfulModelSelection(_ selectionID: String, sessionKey: String, syncSelection: Bool) {
self.lastSuccessfulModelSelectionIDsBySession[sessionKey] = selectionID
let resolved = self.resolvedSessionModelIdentity(forSelectionID: selectionID)
self.updateCurrentSessionModel(
modelID: resolved.modelID,
modelProvider: resolved.modelProvider,
sessionKey: sessionKey,
syncSelection: syncSelection)
}
private func resolvedSessionModelIdentity(forSelectionID selectionID: String) -> (modelID: String?, modelProvider: String?) {
guard let modelRef = self.modelRef(forSelectionID: selectionID) else {
return (nil, nil)
}
if let choice = self.modelChoices.first(where: { $0.selectionID == modelRef }) {
return (choice.modelID, Self.normalizedProvider(choice.provider))
}
return (modelRef, nil)
}
private static func normalizedProvider(_ provider: String?) -> String? {
let trimmed = provider?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let trimmed, !trimmed.isEmpty else { return nil }
return trimmed
}
private static func providerQualifiedModelSelectionID(modelID: String, provider: String) -> String {
let providerPrefix = "\(provider)/"
if modelID.hasPrefix(providerPrefix) {
return modelID
}
return "\(provider)/\(modelID)"
}
private func updateCurrentSessionModel(
modelID: String?,
modelProvider: String?,
sessionKey: String,
syncSelection: Bool)
{
if let index = self.sessions.firstIndex(where: { $0.key == sessionKey }) {
let current = self.sessions[index]
self.sessions[index] = OpenClawChatSessionEntry(
key: current.key,
kind: current.kind,
displayName: current.displayName,
surface: current.surface,
subject: current.subject,
room: current.room,
space: current.space,
updatedAt: current.updatedAt,
sessionId: current.sessionId,
systemSent: current.systemSent,
abortedLastRun: current.abortedLastRun,
thinkingLevel: current.thinkingLevel,
verboseLevel: current.verboseLevel,
inputTokens: current.inputTokens,
outputTokens: current.outputTokens,
totalTokens: current.totalTokens,
modelProvider: modelProvider,
model: modelID,
contextTokens: current.contextTokens)
} else {
let placeholder = self.placeholderSession(key: sessionKey)
self.sessions.append(
OpenClawChatSessionEntry(
key: placeholder.key,
kind: placeholder.kind,
displayName: placeholder.displayName,
surface: placeholder.surface,
subject: placeholder.subject,
room: placeholder.room,
space: placeholder.space,
updatedAt: placeholder.updatedAt,
sessionId: placeholder.sessionId,
systemSent: placeholder.systemSent,
abortedLastRun: placeholder.abortedLastRun,
thinkingLevel: placeholder.thinkingLevel,
verboseLevel: placeholder.verboseLevel,
inputTokens: placeholder.inputTokens,
outputTokens: placeholder.outputTokens,
totalTokens: placeholder.totalTokens,
modelProvider: modelProvider,
model: modelID,
contextTokens: placeholder.contextTokens))
}
if syncSelection {
self.syncSelectedModel()
}
}
private func handleTransportEvent(_ evt: OpenClawChatTransportEvent) {
switch evt {
case let .health(ok):
@ -573,7 +882,9 @@ public final class OpenClawChatViewModel {
previous: self.messages,
incoming: Self.decodeMessages(payload.messages ?? []))
self.sessionId = payload.sessionId
if let level = payload.thinkingLevel, !level.isEmpty {
if !self.prefersExplicitThinkingLevel,
let level = Self.normalizedThinkingLevel(payload.thinkingLevel)
{
self.thinkingLevel = level
}
} catch {
@ -682,4 +993,13 @@ public final class OpenClawChatViewModel {
nil
#endif
}
private static func normalizedThinkingLevel(_ level: String?) -> String? {
guard let level else { return nil }
let trimmed = level.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard ["off", "minimal", "low", "medium", "high", "xhigh", "adaptive"].contains(trimmed) else {
return nil
}
return trimmed
}
}

View File

@ -1337,6 +1337,8 @@ public struct SessionsPatchParams: Codable, Sendable {
public let model: AnyCodable?
public let spawnedby: AnyCodable?
public let spawndepth: AnyCodable?
public let subagentrole: AnyCodable?
public let subagentcontrolscope: AnyCodable?
public let sendpolicy: AnyCodable?
public let groupactivation: AnyCodable?
@ -1355,6 +1357,8 @@ public struct SessionsPatchParams: Codable, Sendable {
model: AnyCodable?,
spawnedby: AnyCodable?,
spawndepth: AnyCodable?,
subagentrole: AnyCodable?,
subagentcontrolscope: AnyCodable?,
sendpolicy: AnyCodable?,
groupactivation: AnyCodable?)
{
@ -1372,6 +1376,8 @@ public struct SessionsPatchParams: Codable, Sendable {
self.model = model
self.spawnedby = spawnedby
self.spawndepth = spawndepth
self.subagentrole = subagentrole
self.subagentcontrolscope = subagentcontrolscope
self.sendpolicy = sendpolicy
self.groupactivation = groupactivation
}
@ -1391,6 +1397,8 @@ public struct SessionsPatchParams: Codable, Sendable {
case model
case spawnedby = "spawnedBy"
case spawndepth = "spawnDepth"
case subagentrole = "subagentRole"
case subagentcontrolscope = "subagentControlScope"
case sendpolicy = "sendPolicy"
case groupactivation = "groupActivation"
}
@ -3046,7 +3054,7 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
public struct ExecApprovalRequestParams: Codable, Sendable {
public let id: String?
public let command: String
public let command: String?
public let commandargv: [String]?
public let systemrunplan: [String: AnyCodable]?
public let env: [String: AnyCodable]?
@ -3067,7 +3075,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
public init(
id: String?,
command: String,
command: String?,
commandargv: [String]?,
systemrunplan: [String: AnyCodable]?,
env: [String: AnyCodable]?,

View File

@ -41,17 +41,67 @@ private func sessionEntry(key: String, updatedAt: Double) -> OpenClawChatSession
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: nil,
model: nil,
contextTokens: nil)
}
private func sessionEntry(
key: String,
updatedAt: Double,
model: String?,
modelProvider: String? = nil) -> OpenClawChatSessionEntry
{
OpenClawChatSessionEntry(
key: key,
kind: nil,
displayName: nil,
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: updatedAt,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: modelProvider,
model: model,
contextTokens: nil)
}
private func modelChoice(id: String, name: String, provider: String = "anthropic") -> OpenClawChatModelChoice {
OpenClawChatModelChoice(modelID: id, name: name, provider: provider, contextWindow: nil)
}
private func makeViewModel(
sessionKey: String = "main",
historyResponses: [OpenClawChatHistoryPayload],
sessionsResponses: [OpenClawChatSessionsListResponse] = []) async -> (TestChatTransport, OpenClawChatViewModel)
sessionsResponses: [OpenClawChatSessionsListResponse] = [],
modelResponses: [[OpenClawChatModelChoice]] = [],
setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil,
setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil,
initialThinkingLevel: String? = nil,
onThinkingLevelChanged: (@MainActor @Sendable (String) -> Void)? = nil) async
-> (TestChatTransport, OpenClawChatViewModel)
{
let transport = TestChatTransport(historyResponses: historyResponses, sessionsResponses: sessionsResponses)
let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: sessionKey, transport: transport) }
let transport = TestChatTransport(
historyResponses: historyResponses,
sessionsResponses: sessionsResponses,
modelResponses: modelResponses,
setSessionModelHook: setSessionModelHook,
setSessionThinkingHook: setSessionThinkingHook)
let vm = await MainActor.run {
OpenClawChatViewModel(
sessionKey: sessionKey,
transport: transport,
initialThinkingLevel: initialThinkingLevel,
onThinkingLevelChanged: onThinkingLevelChanged)
}
return (transport, vm)
}
@ -125,27 +175,60 @@ private func emitExternalFinal(
errorMessage: nil)))
}
@MainActor
private final class CallbackBox {
var values: [String] = []
}
private actor AsyncGate {
private var continuation: CheckedContinuation<Void, Never>?
func wait() async {
await withCheckedContinuation { continuation in
self.continuation = continuation
}
}
func open() {
self.continuation?.resume()
self.continuation = nil
}
}
private actor TestChatTransportState {
var historyCallCount: Int = 0
var sessionsCallCount: Int = 0
var modelsCallCount: Int = 0
var sentRunIds: [String] = []
var sentThinkingLevels: [String] = []
var abortedRunIds: [String] = []
var patchedModels: [String?] = []
var patchedThinkingLevels: [String] = []
}
private final class TestChatTransport: @unchecked Sendable, OpenClawChatTransport {
private let state = TestChatTransportState()
private let historyResponses: [OpenClawChatHistoryPayload]
private let sessionsResponses: [OpenClawChatSessionsListResponse]
private let modelResponses: [[OpenClawChatModelChoice]]
private let setSessionModelHook: (@Sendable (String?) async throws -> Void)?
private let setSessionThinkingHook: (@Sendable (String) async throws -> Void)?
private let stream: AsyncStream<OpenClawChatTransportEvent>
private let continuation: AsyncStream<OpenClawChatTransportEvent>.Continuation
init(
historyResponses: [OpenClawChatHistoryPayload],
sessionsResponses: [OpenClawChatSessionsListResponse] = [])
sessionsResponses: [OpenClawChatSessionsListResponse] = [],
modelResponses: [[OpenClawChatModelChoice]] = [],
setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil,
setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil)
{
self.historyResponses = historyResponses
self.sessionsResponses = sessionsResponses
self.modelResponses = modelResponses
self.setSessionModelHook = setSessionModelHook
self.setSessionThinkingHook = setSessionThinkingHook
var cont: AsyncStream<OpenClawChatTransportEvent>.Continuation!
self.stream = AsyncStream { c in
cont = c
@ -175,11 +258,12 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
func sendMessage(
sessionKey _: String,
message _: String,
thinking _: String,
thinking: String,
idempotencyKey: String,
attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
{
await self.state.sentRunIdsAppend(idempotencyKey)
await self.state.sentThinkingLevelsAppend(thinking)
return OpenClawChatSendResponse(runId: idempotencyKey, status: "ok")
}
@ -201,6 +285,29 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
sessions: [])
}
func listModels() async throws -> [OpenClawChatModelChoice] {
let idx = await self.state.modelsCallCount
await self.state.setModelsCallCount(idx + 1)
if idx < self.modelResponses.count {
return self.modelResponses[idx]
}
return self.modelResponses.last ?? []
}
func setSessionModel(sessionKey _: String, model: String?) async throws {
await self.state.patchedModelsAppend(model)
if let setSessionModelHook = self.setSessionModelHook {
try await setSessionModelHook(model)
}
}
func setSessionThinking(sessionKey _: String, thinkingLevel: String) async throws {
await self.state.patchedThinkingLevelsAppend(thinkingLevel)
if let setSessionThinkingHook = self.setSessionThinkingHook {
try await setSessionThinkingHook(thinkingLevel)
}
}
func requestHealth(timeoutMs _: Int) async throws -> Bool {
true
}
@ -217,6 +324,18 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
func abortedRunIds() async -> [String] {
await self.state.abortedRunIds
}
func sentThinkingLevels() async -> [String] {
await self.state.sentThinkingLevels
}
func patchedModels() async -> [String?] {
await self.state.patchedModels
}
func patchedThinkingLevels() async -> [String] {
await self.state.patchedThinkingLevels
}
}
extension TestChatTransportState {
@ -228,6 +347,10 @@ extension TestChatTransportState {
self.sessionsCallCount = v
}
fileprivate func setModelsCallCount(_ v: Int) {
self.modelsCallCount = v
}
fileprivate func sentRunIdsAppend(_ v: String) {
self.sentRunIds.append(v)
}
@ -235,6 +358,18 @@ extension TestChatTransportState {
fileprivate func abortedRunIdsAppend(_ v: String) {
self.abortedRunIds.append(v)
}
fileprivate func sentThinkingLevelsAppend(_ v: String) {
self.sentThinkingLevels.append(v)
}
fileprivate func patchedModelsAppend(_ v: String?) {
self.patchedModels.append(v)
}
fileprivate func patchedThinkingLevelsAppend(_ v: String) {
self.patchedThinkingLevels.append(v)
}
}
@Suite struct ChatViewModelTests {
@ -457,6 +592,512 @@ extension TestChatTransportState {
#expect(keys == ["main", "custom"])
}
@Test func bootstrapsModelSelectionFromSessionAndDefaults() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: OpenClawChatSessionsDefaults(model: "openai/gpt-4.1-mini", contextTokens: nil),
sessions: [
sessionEntry(key: "main", updatedAt: now, model: "anthropic/claude-opus-4-6"),
])
let models = [
modelChoice(id: "anthropic/claude-opus-4-6", name: "Claude Opus 4.6"),
modelChoice(id: "openai/gpt-4.1-mini", name: "GPT-4.1 mini", provider: "openai"),
]
let (_, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models])
try await loadAndWaitBootstrap(vm: vm)
#expect(await MainActor.run { vm.showsModelPicker })
#expect(await MainActor.run { vm.modelSelectionID } == "anthropic/claude-opus-4-6")
#expect(await MainActor.run { vm.defaultModelLabel } == "Default: openai/gpt-4.1-mini")
}
@Test func selectingDefaultModelPatchesNilAndUpdatesSelection() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: OpenClawChatSessionsDefaults(model: "openai/gpt-4.1-mini", contextTokens: nil),
sessions: [
sessionEntry(key: "main", updatedAt: now, model: "anthropic/claude-opus-4-6"),
])
let models = [
modelChoice(id: "anthropic/claude-opus-4-6", name: "Claude Opus 4.6"),
modelChoice(id: "openai/gpt-4.1-mini", name: "GPT-4.1 mini", provider: "openai"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models])
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run { vm.selectModel(OpenClawChatViewModel.defaultModelSelectionID) }
try await waitUntil("session model patched") {
let patched = await transport.patchedModels()
return patched == [nil]
}
#expect(await MainActor.run { vm.modelSelectionID } == OpenClawChatViewModel.defaultModelSelectionID)
}
@Test func selectingProviderQualifiedModelDisambiguatesDuplicateModelIDs() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: OpenClawChatSessionsDefaults(model: "openrouter/gpt-4.1-mini", contextTokens: nil),
sessions: [
sessionEntry(key: "main", updatedAt: now, model: "gpt-4.1-mini", modelProvider: "openrouter"),
])
let models = [
modelChoice(id: "gpt-4.1-mini", name: "GPT-4.1 mini", provider: "openai"),
modelChoice(id: "gpt-4.1-mini", name: "GPT-4.1 mini", provider: "openrouter"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models])
try await loadAndWaitBootstrap(vm: vm)
#expect(await MainActor.run { vm.modelSelectionID } == "openrouter/gpt-4.1-mini")
await MainActor.run { vm.selectModel("openai/gpt-4.1-mini") }
try await waitUntil("provider-qualified model patched") {
let patched = await transport.patchedModels()
return patched == ["openai/gpt-4.1-mini"]
}
}
@Test func slashModelIDsStayProviderQualifiedInSelectionAndPatch() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
])
let models = [
modelChoice(
id: "openai/gpt-5.4",
name: "GPT-5.4 via Vercel AI Gateway",
provider: "vercel-ai-gateway"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models])
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run { vm.selectModel("vercel-ai-gateway/openai/gpt-5.4") }
try await waitUntil("slash model patched with provider-qualified ref") {
let patched = await transport.patchedModels()
return patched == ["vercel-ai-gateway/openai/gpt-5.4"]
}
}
@Test func staleModelPatchCompletionsDoNotOverwriteNewerSelection() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
])
let models = [
modelChoice(id: "gpt-5.4", name: "GPT-5.4", provider: "openai"),
modelChoice(id: "gpt-5.4-pro", name: "GPT-5.4 Pro", provider: "openai"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models],
setSessionModelHook: { model in
if model == "openai/gpt-5.4" {
try await Task.sleep(for: .milliseconds(200))
}
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run {
vm.selectModel("openai/gpt-5.4")
vm.selectModel("openai/gpt-5.4-pro")
}
try await waitUntil("two model patches complete") {
let patched = await transport.patchedModels()
return patched == ["openai/gpt-5.4", "openai/gpt-5.4-pro"]
}
#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")
}
@Test func sendWaitsForInFlightModelPatchToFinish() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
])
let models = [
modelChoice(id: "gpt-5.4", name: "GPT-5.4", provider: "openai"),
]
let gate = AsyncGate()
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models],
setSessionModelHook: { model in
if model == "openai/gpt-5.4" {
await gate.wait()
}
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run { vm.selectModel("openai/gpt-5.4") }
try await waitUntil("model patch started") {
let patched = await transport.patchedModels()
return patched == ["openai/gpt-5.4"]
}
await sendUserMessage(vm, text: "hello")
try await waitUntil("send entered waiting state") {
await MainActor.run { vm.isSending }
}
#expect(await transport.lastSentRunId() == nil)
await MainActor.run { vm.selectThinkingLevel("high") }
try await waitUntil("thinking level changed while send is blocked") {
await MainActor.run { vm.thinkingLevel == "high" }
}
await gate.open()
try await waitUntil("send released after model patch") {
await transport.lastSentRunId() != nil
}
#expect(await transport.sentThinkingLevels() == ["off"])
}
@Test func failedLatestModelSelectionDoesNotReplayAfterOlderCompletionFinishes() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
])
let models = [
modelChoice(id: "gpt-5.4", name: "GPT-5.4", provider: "openai"),
modelChoice(id: "gpt-5.4-pro", name: "GPT-5.4 Pro", provider: "openai"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models],
setSessionModelHook: { model in
if model == "openai/gpt-5.4" {
try await Task.sleep(for: .milliseconds(200))
return
}
if model == "openai/gpt-5.4-pro" {
throw NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "boom"])
}
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run {
vm.selectModel("openai/gpt-5.4")
vm.selectModel("openai/gpt-5.4-pro")
}
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" }
}
#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 transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"])
}
@Test func failedLatestModelSelectionRestoresEarlierSuccessWithoutReplay() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
])
let models = [
modelChoice(id: "gpt-5.4", name: "GPT-5.4", provider: "openai"),
modelChoice(id: "gpt-5.4-pro", name: "GPT-5.4 Pro", provider: "openai"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models],
setSessionModelHook: { model in
if model == "openai/gpt-5.4" {
try await Task.sleep(for: .milliseconds(100))
return
}
if model == "openai/gpt-5.4-pro" {
try await Task.sleep(for: .milliseconds(200))
throw NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "boom"])
}
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run {
vm.selectModel("openai/gpt-5.4")
vm.selectModel("openai/gpt-5.4-pro")
}
try await waitUntil("latest failure restores prior successful model") {
await MainActor.run {
vm.modelSelectionID == "openai/gpt-5.4" &&
vm.sessions.first(where: { $0.key == "main" })?.model == "gpt-5.4" &&
vm.sessions.first(where: { $0.key == "main" })?.modelProvider == "openai"
}
}
#expect(await transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"])
}
@Test func switchingSessionsIgnoresLateModelPatchCompletionFromPreviousSession() async throws {
let now = Date().timeIntervalSince1970 * 1000
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 2,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
sessionEntry(key: "other", updatedAt: now - 1000, model: nil),
])
let models = [
modelChoice(id: "gpt-5.4", name: "GPT-5.4", provider: "openai"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [
historyPayload(sessionKey: "main", sessionId: "sess-main"),
historyPayload(sessionKey: "other", sessionId: "sess-other"),
],
sessionsResponses: [sessions, sessions],
modelResponses: [models, models],
setSessionModelHook: { model in
if model == "openai/gpt-5.4" {
try await Task.sleep(for: .milliseconds(200))
}
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
await MainActor.run { vm.selectModel("openai/gpt-5.4") }
await MainActor.run { vm.switchSession(to: "other") }
try await waitUntil("switched sessions") {
await MainActor.run { vm.sessionKey == "other" && vm.sessionId == "sess-other" }
}
try await waitUntil("late model patch finished") {
let patched = await transport.patchedModels()
return patched == ["openai/gpt-5.4"]
}
#expect(await MainActor.run { vm.modelSelectionID } == OpenClawChatViewModel.defaultModelSelectionID)
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "other" })?.model } == nil)
}
@Test func lateModelCompletionDoesNotReplayCurrentSessionSelectionIntoPreviousSession() async throws {
let now = Date().timeIntervalSince1970 * 1000
let initialSessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 2,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
sessionEntry(key: "other", updatedAt: now - 1000, model: nil),
])
let sessionsAfterOtherSelection = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 2,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
sessionEntry(key: "other", updatedAt: now - 1000, model: "openai/gpt-5.4-pro"),
])
let models = [
modelChoice(id: "gpt-5.4", name: "GPT-5.4", provider: "openai"),
modelChoice(id: "gpt-5.4-pro", name: "GPT-5.4 Pro", provider: "openai"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [
historyPayload(sessionKey: "main", sessionId: "sess-main"),
historyPayload(sessionKey: "other", sessionId: "sess-other"),
historyPayload(sessionKey: "main", sessionId: "sess-main"),
],
sessionsResponses: [initialSessions, initialSessions, sessionsAfterOtherSelection],
modelResponses: [models, models, models],
setSessionModelHook: { model in
if model == "openai/gpt-5.4" {
try await Task.sleep(for: .milliseconds(200))
}
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
await MainActor.run { vm.selectModel("openai/gpt-5.4") }
await MainActor.run { vm.switchSession(to: "other") }
try await waitUntil("switched to other session") {
await MainActor.run { vm.sessionKey == "other" && vm.sessionId == "sess-other" }
}
await MainActor.run { vm.selectModel("openai/gpt-5.4-pro") }
try await waitUntil("both model patches issued") {
let patched = await transport.patchedModels()
return patched == ["openai/gpt-5.4", "openai/gpt-5.4-pro"]
}
await MainActor.run { vm.switchSession(to: "main") }
try await waitUntil("switched back to main session") {
await MainActor.run { vm.sessionKey == "main" && vm.sessionId == "sess-main" }
}
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" }
}
#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 == "other" })?.model } == "openai/gpt-5.4-pro")
#expect(await transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"])
}
@Test func explicitThinkingLevelWinsOverHistoryAndPersistsChanges() async throws {
let history = OpenClawChatHistoryPayload(
sessionKey: "main",
sessionId: "sess-main",
messages: [],
thinkingLevel: "off")
let callbackState = await MainActor.run { CallbackBox() }
let (transport, vm) = await makeViewModel(
historyResponses: [history],
initialThinkingLevel: "high",
onThinkingLevelChanged: { level in
callbackState.values.append(level)
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
#expect(await MainActor.run { vm.thinkingLevel } == "high")
await MainActor.run { vm.selectThinkingLevel("medium") }
try await waitUntil("thinking level patched") {
let patched = await transport.patchedThinkingLevels()
return patched == ["medium"]
}
#expect(await MainActor.run { vm.thinkingLevel } == "medium")
#expect(await MainActor.run { callbackState.values } == ["medium"])
}
@Test func serverProvidedThinkingLevelsOutsideMenuArePreservedForSend() async throws {
let history = OpenClawChatHistoryPayload(
sessionKey: "main",
sessionId: "sess-main",
messages: [],
thinkingLevel: "xhigh")
let (transport, vm) = await makeViewModel(historyResponses: [history])
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
#expect(await MainActor.run { vm.thinkingLevel } == "xhigh")
await sendUserMessage(vm, text: "hello")
try await waitUntil("send uses preserved thinking level") {
await transport.sentThinkingLevels() == ["xhigh"]
}
}
@Test func staleThinkingPatchCompletionReappliesLatestSelection() async throws {
let history = OpenClawChatHistoryPayload(
sessionKey: "main",
sessionId: "sess-main",
messages: [],
thinkingLevel: "off")
let (transport, vm) = await makeViewModel(
historyResponses: [history],
setSessionThinkingHook: { level in
if level == "medium" {
try await Task.sleep(for: .milliseconds(200))
}
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
await MainActor.run {
vm.selectThinkingLevel("medium")
vm.selectThinkingLevel("high")
}
try await waitUntil("thinking patch replayed latest selection") {
let patched = await transport.patchedThinkingLevels()
return patched == ["medium", "high", "high"]
}
#expect(await MainActor.run { vm.thinkingLevel } == "high")
}
@Test func clearsStreamingOnExternalErrorEvent() async throws {
let sessionId = "sess-main"
let history = historyPayload(sessionId: sessionId)

View File

@ -946,7 +946,7 @@ Default slash command settings:
Gateway auth for this handler uses the same shared credential resolution contract as other Gateway clients:
- env-first local auth (`OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD` then `gateway.auth.*`)
- in local mode, `gateway.remote.*` can be used as fallback when `gateway.auth.*` is unset
- in local mode, `gateway.remote.*` can be used as fallback only when `gateway.auth.*` is unset; configured-but-unresolved local SecretRefs fail closed
- remote-mode support via `gateway.remote.*` when applicable
- URL overrides are override-safe: CLI overrides do not reuse implicit credentials, and env overrides use env credentials only

View File

@ -273,7 +273,7 @@ Security note:
- `--token` and `--password` can be visible in local process listings on some systems.
- Prefer `--token-file`/`--password-file` or environment variables (`OPENCLAW_GATEWAY_TOKEN`, `OPENCLAW_GATEWAY_PASSWORD`).
- Gateway auth resolution follows the shared contract used by other Gateway clients:
- local mode: env (`OPENCLAW_GATEWAY_*`) -> `gateway.auth.*` -> `gateway.remote.*` fallback when `gateway.auth.*` is unset
- local mode: env (`OPENCLAW_GATEWAY_*`) -> `gateway.auth.*` -> `gateway.remote.*` fallback only when `gateway.auth.*` is unset (configured-but-unresolved local SecretRefs fail closed)
- remote mode: `gateway.remote.*` with env/config fallback per remote precedence rules
- `--url` is override-safe and does not reuse implicit config/env credentials; pass explicit `--token`/`--password` (or file variants)
- ACP runtime backend child processes receive `OPENCLAW_SHELL=acp`, which can be used for context-specific shell/profile rules.

View File

@ -337,7 +337,7 @@ Options:
- `--non-interactive`
- `--mode <local|remote>`
- `--flow <quickstart|advanced|manual>` (manual is an alias for advanced)
- `--auth-choice <setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|moonshot-api-key-cn|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|mistral-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|custom-api-key|skip>`
- `--auth-choice <setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|moonshot-api-key-cn|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|mistral-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|opencode-go|custom-api-key|skip>`
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
- `--token <token>` (non-interactive; used with `--auth-choice token`)
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
@ -354,6 +354,7 @@ Options:
- `--zai-api-key <key>`
- `--minimax-api-key <key>`
- `--opencode-zen-api-key <key>`
- `--opencode-go-api-key <key>`
- `--custom-base-url <url>` (non-interactive; used with `--auth-choice custom-api-key`)
- `--custom-model-id <id>` (non-interactive; used with `--auth-choice custom-api-key`)
- `--custom-api-key <key>` (non-interactive; optional; used with `--auth-choice custom-api-key`; falls back to `CUSTOM_API_KEY` when omitted)
@ -1018,7 +1019,7 @@ Subcommands:
Auth notes:
- `node` resolves gateway auth from env/config (no `--token`/`--password` flags): `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`, then `gateway.auth.*`, with remote-mode support via `gateway.remote.*`.
- `node` resolves gateway auth from env/config (no `--token`/`--password` flags): `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`, then `gateway.auth.*`. In local mode, node host intentionally ignores `gateway.remote.*`; in `gateway.mode=remote`, `gateway.remote.*` participates per remote precedence rules.
- Legacy `CLAWDBOT_GATEWAY_*` env vars are intentionally ignored for node-host auth resolution.
## Nodes

View File

@ -64,7 +64,8 @@ Options:
- `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD` are checked first.
- Then local config fallback: `gateway.auth.token` / `gateway.auth.password`.
- In local mode, `gateway.remote.token` / `gateway.remote.password` are also eligible as fallback when `gateway.auth.*` is unset.
- In local mode, node host intentionally does not inherit `gateway.remote.token` / `gateway.remote.password`.
- If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, node auth resolution fails closed (no remote fallback masking).
- In `gateway.mode=remote`, remote client fields (`gateway.remote.token` / `gateway.remote.password`) are also eligible per remote precedence rules.
- Legacy `CLAWDBOT_GATEWAY_*` env vars are ignored for node host auth resolution.

View File

@ -86,12 +86,13 @@ OpenClaw ships with the piai catalog. These providers require **no**
}
```
### OpenCode Zen
### OpenCode
- Provider: `opencode`
- Auth: `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`)
- Example model: `opencode/claude-opus-4-6`
- CLI: `openclaw onboard --auth-choice opencode-zen`
- Zen runtime provider: `opencode`
- Go runtime provider: `opencode-go`
- Example models: `opencode/claude-opus-4-6`, `opencode-go/kimi-k2.5`
- CLI: `openclaw onboard --auth-choice opencode-zen` or `openclaw onboard --auth-choice opencode-go`
```json5
{
@ -104,8 +105,8 @@ OpenClaw ships with the piai catalog. These providers require **no**
- Provider: `google`
- Auth: `GEMINI_API_KEY`
- Optional rotation: `GEMINI_API_KEYS`, `GEMINI_API_KEY_1`, `GEMINI_API_KEY_2`, `GOOGLE_API_KEY` fallback, and `OPENCLAW_LIVE_GEMINI_KEY` (single override)
- Example models: `google/gemini-3.1-pro-preview`, `google/gemini-3-flash-preview`, `google/gemini-3.1-flash-lite-preview`
- Compatibility: legacy OpenClaw config using `google/gemini-3.1-flash-preview` is normalized to `google/gemini-3-flash-preview`, and bare `google/gemini-3.1-flash-lite` is normalized to `google/gemini-3.1-flash-lite-preview`
- Example models: `google/gemini-3.1-pro-preview`, `google/gemini-3-flash-preview`
- Compatibility: legacy OpenClaw config using `google/gemini-3.1-flash-preview` is normalized to `google/gemini-3-flash-preview`
- CLI: `openclaw onboard --auth-choice gemini-api-key`
### Google Vertex, Antigravity, and Gemini CLI

View File

@ -55,8 +55,8 @@ subscription** (OAuth) and **Anthropic** (API key or `claude setup-token`).
Model refs are normalized to lowercase. Provider aliases like `z.ai/*` normalize
to `zai/*`.
Provider configuration examples (including OpenCode Zen) live in
[/gateway/configuration](/gateway/configuration#opencode-zen-multi-model-proxy).
Provider configuration examples (including OpenCode) live in
[/gateway/configuration](/gateway/configuration#opencode).
## “Model is not allowed” (and why replies stop)

View File

@ -103,6 +103,10 @@
"source": "/opencode",
"destination": "/providers/opencode"
},
{
"source": "/opencode-go",
"destination": "/providers/opencode-go"
},
{
"source": "/qianfan",
"destination": "/providers/qianfan"
@ -1013,8 +1017,7 @@
"tools/browser",
"tools/browser-login",
"tools/chrome-extension",
"tools/browser-linux-troubleshooting",
"tools/browser-wsl2-windows-remote-cdp-troubleshooting"
"tools/browser-linux-troubleshooting"
]
},
{
@ -1112,6 +1115,7 @@
"providers/nvidia",
"providers/ollama",
"providers/openai",
"providers/opencode-go",
"providers/opencode",
"providers/openrouter",
"providers/qianfan",

View File

@ -748,6 +748,7 @@ Include your own number in `allowFrom` to enable self-chat mode (ignores native
- `bash: true` enables `! <cmd>` for host shell. Requires `tools.elevated.enabled` and sender in `tools.elevated.allowFrom.<channel>`.
- `config: true` enables `/config` (reads/writes `openclaw.json`). For gateway `chat.send` clients, persistent `/config set|unset` writes also require `operator.admin`; read-only `/config show` stays available to normal write-scoped operator clients.
- `channels.<provider>.configWrites` gates config mutations per channel (default: true).
- For multi-account channels, `channels.<provider>.accounts.<id>.configWrites` also gates writes that target that account (for example `/allowlist --config --account <id>` or `/config set channels.<provider>.accounts.<id>...`).
- `allowFrom` is per-provider. When set, it is the **only** authorization source (channel allowlists/pairing and `useAccessGroups` are ignored).
- `useAccessGroups: false` allows commands to bypass access-group policies when `allowFrom` is not set.
@ -2078,7 +2079,7 @@ Use `cerebras/zai-glm-4.7` for Cerebras; `zai/glm-4.7` for Z.AI direct.
</Accordion>
<Accordion title="OpenCode Zen">
<Accordion title="OpenCode">
```json5
{
@ -2091,7 +2092,7 @@ Use `cerebras/zai-glm-4.7` for Cerebras; `zai/glm-4.7` for Z.AI direct.
}
```
Set `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`). Shortcut: `openclaw onboard --auth-choice opencode-zen`.
Set `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`). Use `opencode/...` refs for the Zen catalog or `opencode-go/...` refs for the Go catalog. Shortcut: `openclaw onboard --auth-choice opencode-zen` or `openclaw onboard --auth-choice opencode-go`.
</Accordion>
@ -2469,7 +2470,8 @@ See [Plugins](/tools/plugin).
- `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`.
- `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`: client-side break-glass override that allows plaintext `ws://` to trusted private-network IPs; default remains loopback-only for plaintext.
- `gateway.remote.token` / `.password` are remote-client credential fields. They do not configure gateway auth by themselves.
- Local gateway call paths can use `gateway.remote.*` as fallback when `gateway.auth.*` is unset.
- Local gateway call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*` is unset.
- If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking).
- `trustedProxies`: reverse proxy IPs that terminate TLS. Only list proxies you control.
- `allowRealIpFallback`: when `true`, the gateway accepts `X-Real-IP` if `X-Forwarded-For` is missing. Default `false` for fail-closed behavior.
- `gateway.tools.deny`: extra tool names blocked for HTTP `POST /tools/invoke` (extends default deny list).

View File

@ -63,7 +63,7 @@ cat ~/.openclaw/openclaw.json
- Health check + restart prompt.
- Skills status summary (eligible/missing/blocked).
- Config normalization for legacy values.
- OpenCode Zen provider override warnings (`models.providers.opencode`).
- OpenCode provider override warnings (`models.providers.opencode` / `models.providers.opencode-go`).
- Legacy on-disk state migration (sessions/agent dir/WhatsApp auth).
- Legacy cron store migration (`jobId`, `schedule.cron`, top-level delivery/payload fields, payload `provider`, simple `notify: true` webhook fallback jobs).
- State integrity and permissions checks (sessions, transcripts, state dir).
@ -134,12 +134,12 @@ Doctor warnings also include account-default guidance for multi-account channels
- If two or more `channels.<channel>.accounts` entries are configured without `channels.<channel>.defaultAccount` or `accounts.default`, doctor warns that fallback routing can pick an unexpected account.
- If `channels.<channel>.defaultAccount` is set to an unknown account ID, doctor warns and lists configured account IDs.
### 2b) OpenCode Zen provider overrides
### 2b) OpenCode provider overrides
If youve added `models.providers.opencode` (or `opencode-zen`) manually, it
overrides the built-in OpenCode Zen catalog from `@mariozechner/pi-ai`. That can
force every model onto a single API or zero out costs. Doctor warns so you can
remove the override and restore per-model API routing + costs.
If youve added `models.providers.opencode`, `opencode-zen`, or `opencode-go`
manually, it overrides the built-in OpenCode catalog from `@mariozechner/pi-ai`.
That can force models onto the wrong API or zero out costs. Doctor warns so you
can remove the override and restore per-model API routing + costs.
### 3) Legacy state migrations (disk layout)

View File

@ -103,18 +103,19 @@ When the gateway is loopback-only, keep the URL at `ws://127.0.0.1:18789` and op
## Credential precedence
Gateway credential resolution follows one shared contract across call/probe/status paths, Discord exec-approval monitoring, and node-host connections:
Gateway credential resolution follows one shared contract across call/probe/status paths and Discord exec-approval monitoring. Node-host uses the same base contract with one local-mode exception (it intentionally ignores `gateway.remote.*`):
- Explicit credentials (`--token`, `--password`, or tool `gatewayToken`) always win on call paths that accept explicit auth.
- URL override safety:
- CLI URL overrides (`--url`) never reuse implicit config/env credentials.
- Env URL overrides (`OPENCLAW_GATEWAY_URL`) may use env credentials only (`OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`).
- Local mode defaults:
- token: `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token` -> `gateway.remote.token`
- password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.auth.password` -> `gateway.remote.password`
- token: `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token` -> `gateway.remote.token` (remote fallback applies only when local auth token input is unset)
- password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.auth.password` -> `gateway.remote.password` (remote fallback applies only when local auth password input is unset)
- Remote mode defaults:
- token: `gateway.remote.token` -> `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token`
- password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.remote.password` -> `gateway.auth.password`
- Node-host local-mode exception: `gateway.remote.token` / `gateway.remote.password` are ignored.
- Remote probe/status token checks are strict by default: they use `gateway.remote.token` only (no local token fallback) when targeting remote mode.
- Legacy `CLAWDBOT_GATEWAY_*` env vars are only used by compatibility call paths; probe/status/auth resolution uses `OPENCLAW_GATEWAY_*` only.
@ -140,7 +141,8 @@ Short version: **keep the Gateway loopback-only** unless youre sure you need
set `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` on the client process as break-glass.
- **Non-loopback binds** (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) must use auth tokens/passwords.
- `gateway.remote.token` / `.password` are client credential sources. They do **not** configure server auth by themselves.
- Local call paths can use `gateway.remote.*` as fallback when `gateway.auth.*` is unset.
- Local call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*` is unset.
- If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking).
- `gateway.remote.tlsFingerprint` pins the remote TLS cert when using `wss://`.
- **Tailscale Serve** can authenticate Control UI/WebSocket traffic via identity
headers when `gateway.auth.allowTailscale: true`; HTTP API endpoints still

View File

@ -41,13 +41,13 @@ Examples of inactive surfaces:
- Web search provider-specific keys that are not selected by `tools.web.search.provider`.
In auto mode (provider unset), keys are consulted by precedence for provider auto-detection until one resolves.
After selection, non-selected provider keys are treated as inactive until selected.
- `gateway.remote.token` / `gateway.remote.password` SecretRefs are active (when `gateway.remote.enabled` is not `false`) if one of these is true:
- `gateway.remote.token` / `gateway.remote.password` SecretRefs are active if one of these is true:
- `gateway.mode=remote`
- `gateway.remote.url` is configured
- `gateway.tailscale.mode` is `serve` or `funnel`
In local mode without those remote surfaces:
- `gateway.remote.token` is active when token auth can win and no env/auth token is configured.
- `gateway.remote.password` is active only when password auth can win and no env/auth password is configured.
- In local mode without those remote surfaces:
- `gateway.remote.token` is active when token auth can win and no env/auth token is configured.
- `gateway.remote.password` is active only when password auth can win and no env/auth password is configured.
- `gateway.auth.token` SecretRef is inactive for startup auth resolution when `OPENCLAW_GATEWAY_TOKEN` (or `CLAWDBOT_GATEWAY_TOKEN`) is set, because env token input wins for that runtime.
## Gateway auth surface diagnostics

View File

@ -104,6 +104,7 @@ Treat Gateway and node as one operator trust domain, with different roles:
- A caller authenticated to the Gateway is trusted at Gateway scope. After pairing, node actions are trusted operator actions on that node.
- `sessionKey` is routing/context selection, not per-user auth.
- Exec approvals (allowlist + ask) are guardrails for operator intent, not hostile multi-tenant isolation.
- Exec approvals bind exact request context and best-effort direct local file operands; they do not semantically model every runtime/interpreter loader path. Use sandboxing and host isolation for strong boundaries.
If you need hostile-user isolation, split trust boundaries by OS user/host and run separate gateways.
@ -370,6 +371,7 @@ If a macOS node is paired, the Gateway can invoke `system.run` on that node. Thi
- Requires node pairing (approval + token).
- Controlled on the Mac via **Settings → Exec approvals** (security + ask + allowlist).
- Approval mode binds exact request context and, when possible, one concrete local script/file operand. If OpenClaw cannot identify exactly one direct local file for an interpreter/runtime command, approval-backed execution is denied rather than promising full semantic coverage.
- If you dont want remote execution, set security to **deny** and remove node pairing for that Mac.
## Dynamic skills (watcher / remote nodes)
@ -752,8 +754,10 @@ Doctor can generate one for you: `openclaw doctor --generate-gateway-token`.
Note: `gateway.remote.token` / `.password` are client credential sources. They
do **not** protect local WS access by themselves.
Local call paths can use `gateway.remote.*` as fallback when `gateway.auth.*`
Local call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*`
is unset.
If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via
SecretRef and unresolved, resolution fails closed (no remote fallback masking).
Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`.
Plaintext `ws://` is loopback-only by default. For trusted private-network
paths, set `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` on the client process as break-glass.

View File

@ -1452,7 +1452,8 @@ Non-loopback binds **require auth**. Configure `gateway.auth.mode` + `gateway.au
Notes:
- `gateway.remote.token` / `.password` do **not** enable local gateway auth by themselves.
- Local call paths can use `gateway.remote.*` as fallback when `gateway.auth.*` is unset.
- Local call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*` is unset.
- If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking).
- The Control UI authenticates via `connect.params.auth.token` (stored in app/UI settings). Avoid putting tokens in URLs.
### Why do I need a token on localhost now

View File

@ -311,11 +311,11 @@ Include at least one image-capable model in `OPENCLAW_LIVE_GATEWAY_MODELS` (Clau
If you have keys enabled, we also support testing via:
- OpenRouter: `openrouter/...` (hundreds of models; use `openclaw models scan` to find tool+image capable candidates)
- OpenCode Zen: `opencode/...` (auth via `OPENCODE_API_KEY` / `OPENCODE_ZEN_API_KEY`)
- OpenCode: `opencode/...` for Zen and `opencode-go/...` for Go (auth via `OPENCODE_API_KEY` / `OPENCODE_ZEN_API_KEY`)
More providers you can include in the live matrix (if you have creds/config):
- Built-in: `openai`, `openai-codex`, `anthropic`, `google`, `google-vertex`, `google-antigravity`, `google-gemini-cli`, `zai`, `openrouter`, `opencode`, `xai`, `groq`, `cerebras`, `mistral`, `github-copilot`
- Built-in: `openai`, `openai-codex`, `anthropic`, `google`, `google-vertex`, `google-antigravity`, `google-gemini-cli`, `zai`, `openrouter`, `opencode`, `opencode-go`, `xai`, `groq`, `cerebras`, `mistral`, `github-copilot`
- Via `models.providers` (custom endpoints): `minimax` (cloud/API), plus any OpenAI/Anthropic-compatible proxy (LM Studio, vLLM, LiteLLM, etc.)
Tip: dont try to hardcode “all models” in docs. The authoritative list is whatever `discoverModels(...)` returns on your machine + whatever keys are available.

View File

@ -54,6 +54,15 @@ forwards `exec` calls to the **node host** when `host=node` is selected.
- **Node host**: executes `system.run`/`system.which` on the node machine.
- **Approvals**: enforced on the node host via `~/.openclaw/exec-approvals.json`.
Approval note:
- Approval-backed node runs bind exact request context.
- For direct shell/runtime file executions, OpenClaw also best-effort binds one concrete local
file operand and denies the run if that file changes before execution.
- If OpenClaw cannot identify exactly one concrete local file for an interpreter/runtime command,
approval-backed execution is denied instead of pretending full runtime coverage. Use sandboxing,
separate hosts, or an explicit trusted allowlist/full workflow for broader interpreter semantics.
### Start a node host (foreground)
On the node machine:
@ -83,7 +92,10 @@ Notes:
- `openclaw node run` supports token or password auth.
- Env vars are preferred: `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`.
- Config fallback is `gateway.auth.token` / `gateway.auth.password`; in remote mode, `gateway.remote.token` / `gateway.remote.password` are also eligible.
- Config fallback is `gateway.auth.token` / `gateway.auth.password`.
- In local mode, node host intentionally ignores `gateway.remote.token` / `gateway.remote.password`.
- In remote mode, `gateway.remote.token` / `gateway.remote.password` are eligible per remote precedence rules.
- If active local `gateway.auth.*` SecretRefs are configured but unresolved, node-host auth fails closed.
- Legacy `CLAWDBOT_GATEWAY_*` env vars are intentionally ignored by node-host auth resolution.
### Start a node host (service)

View File

@ -39,7 +39,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
- [NVIDIA](/providers/nvidia)
- [Ollama (local models)](/providers/ollama)
- [OpenAI (API + Codex)](/providers/openai)
- [OpenCode Zen](/providers/opencode)
- [OpenCode (Zen + Go)](/providers/opencode)
- [OpenRouter](/providers/openrouter)
- [Qianfan](/providers/qianfan)
- [Qwen (OAuth)](/providers/qwen)

View File

@ -32,7 +32,7 @@ model as `provider/model`.
- [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot)
- [Mistral](/providers/mistral)
- [Synthetic](/providers/synthetic)
- [OpenCode Zen](/providers/opencode)
- [OpenCode (Zen + Go)](/providers/opencode)
- [Z.AI](/providers/zai)
- [GLM models](/providers/glm)
- [MiniMax](/providers/minimax)

View File

@ -0,0 +1,45 @@
---
summary: "Use the OpenCode Go catalog with the shared OpenCode setup"
read_when:
- You want the OpenCode Go catalog
- You need the runtime model refs for Go-hosted models
title: "OpenCode Go"
---
# OpenCode Go
OpenCode Go is the Go catalog within [OpenCode](/providers/opencode).
It uses the same `OPENCODE_API_KEY` as the Zen catalog, but keeps the runtime
provider id `opencode-go` so upstream per-model routing stays correct.
## Supported models
- `opencode-go/kimi-k2.5`
- `opencode-go/glm-5`
- `opencode-go/minimax-m2.5`
## CLI setup
```bash
openclaw onboard --auth-choice opencode-go
# or non-interactive
openclaw onboard --opencode-go-api-key "$OPENCODE_API_KEY"
```
## Config snippet
```json5
{
env: { OPENCODE_API_KEY: "YOUR_API_KEY_HERE" }, // pragma: allowlist secret
agents: { defaults: { model: { primary: "opencode-go/kimi-k2.5" } } },
}
```
## Routing behavior
OpenClaw handles per-model routing automatically when the model ref uses `opencode-go/...`.
## Notes
- Use [OpenCode](/providers/opencode) for the shared onboarding and catalog overview.
- Runtime refs stay explicit: `opencode/...` for Zen, `opencode-go/...` for Go.

View File

@ -1,25 +1,38 @@
---
summary: "Use OpenCode Zen (curated models) with OpenClaw"
summary: "Use OpenCode Zen and Go catalogs with OpenClaw"
read_when:
- You want OpenCode Zen for model access
- You want a curated list of coding-friendly models
title: "OpenCode Zen"
- You want OpenCode-hosted model access
- You want to pick between the Zen and Go catalogs
title: "OpenCode"
---
# OpenCode Zen
# OpenCode
OpenCode Zen is a **curated list of models** recommended by the OpenCode team for coding agents.
It is an optional, hosted model access path that uses an API key and the `opencode` provider.
Zen is currently in beta.
OpenCode exposes two hosted catalogs in OpenClaw:
- `opencode/...` for the **Zen** catalog
- `opencode-go/...` for the **Go** catalog
Both catalogs use the same OpenCode API key. OpenClaw keeps the runtime provider ids
split so upstream per-model routing stays correct, but onboarding and docs treat them
as one OpenCode setup.
## CLI setup
### Zen catalog
```bash
openclaw onboard --auth-choice opencode-zen
# or non-interactive
openclaw onboard --opencode-zen-api-key "$OPENCODE_API_KEY"
```
### Go catalog
```bash
openclaw onboard --auth-choice opencode-go
openclaw onboard --opencode-go-api-key "$OPENCODE_API_KEY"
```
## Config snippet
```json5
@ -29,8 +42,23 @@ openclaw onboard --opencode-zen-api-key "$OPENCODE_API_KEY"
}
```
## Catalogs
### Zen
- Runtime provider: `opencode`
- Example models: `opencode/claude-opus-4-6`, `opencode/gpt-5.2`, `opencode/gemini-3-pro`
- Best when you want the curated OpenCode multi-model proxy
### Go
- Runtime provider: `opencode-go`
- Example models: `opencode-go/kimi-k2.5`, `opencode-go/glm-5`, `opencode-go/minimax-m2.5`
- Best when you want the OpenCode-hosted Kimi/GLM/MiniMax lineup
## Notes
- `OPENCODE_ZEN_API_KEY` is also supported.
- You sign in to Zen, add billing details, and copy your API key.
- OpenCode Zen bills per request; check the OpenCode dashboard for details.
- Entering one OpenCode key during onboarding stores credentials for both runtime providers.
- You sign in to OpenCode, add billing details, and copy your API key.
- Billing and catalog availability are managed from the OpenCode dashboard.

View File

@ -38,7 +38,7 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard).
- Sets `agents.defaults.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`.
- **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then stores it in auth profiles.
- **xAI (Grok) API key**: prompts for `XAI_API_KEY` and configures xAI as a model provider.
- **OpenCode Zen (multi-model proxy)**: prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`, get it at https://opencode.ai/auth).
- **OpenCode**: prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`, get it at https://opencode.ai/auth) and lets you pick the Zen or Go catalog.
- **API key**: stores the key for you.
- **Vercel AI Gateway (multi-model proxy)**: prompts for `AI_GATEWAY_API_KEY`.
- More detail: [Vercel AI Gateway](/providers/vercel-ai-gateway)
@ -228,7 +228,7 @@ openclaw onboard --non-interactive \
--gateway-bind loopback
```
</Accordion>
<Accordion title="OpenCode Zen example">
<Accordion title="OpenCode example">
```bash
openclaw onboard --non-interactive \
--mode local \
@ -237,6 +237,7 @@ openclaw onboard --non-interactive \
--gateway-port 18789 \
--gateway-bind loopback
```
Swap to `--auth-choice opencode-go --opencode-go-api-key "$OPENCODE_API_KEY"` for the Go catalog.
</Accordion>
</AccordionGroup>

View File

@ -123,7 +123,7 @@ openclaw onboard --non-interactive \
--gateway-bind loopback
```
</Accordion>
<Accordion title="OpenCode Zen example">
<Accordion title="OpenCode example">
```bash
openclaw onboard --non-interactive \
--mode local \
@ -132,6 +132,7 @@ openclaw onboard --non-interactive \
--gateway-port 18789 \
--gateway-bind loopback
```
Swap to `--auth-choice opencode-go --opencode-go-api-key "$OPENCODE_API_KEY"` for the Go catalog.
</Accordion>
<Accordion title="Custom provider example">
```bash

View File

@ -155,8 +155,8 @@ What you set:
<Accordion title="xAI (Grok) API key">
Prompts for `XAI_API_KEY` and configures xAI as a model provider.
</Accordion>
<Accordion title="OpenCode Zen">
Prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`).
<Accordion title="OpenCode">
Prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`) and lets you choose the Zen or Go catalog.
Setup URL: [opencode.ai/auth](https://opencode.ai/auth).
</Accordion>
<Accordion title="API key (generic)">

View File

@ -30,9 +30,14 @@ Trust model note:
- Gateway-authenticated callers are trusted operators for that Gateway.
- Paired nodes extend that trusted operator capability onto the node host.
- Exec approvals reduce accidental execution risk, but are not a per-user auth boundary.
- Approved node-host runs also bind canonical execution context: canonical cwd, pinned executable
path when applicable, and interpreter-style script operands. If a bound script changes after
approval but before execution, the run is denied instead of executing drifted content.
- Approved node-host runs bind canonical execution context: canonical cwd, exact argv, env
binding when present, and pinned executable path when applicable.
- For shell scripts and direct interpreter/runtime file invocations, OpenClaw also tries to bind
one concrete local file operand. If that bound file changes after approval but before execution,
the run is denied instead of executing drifted content.
- This file binding is intentionally best-effort, not a complete semantic model of every
interpreter/runtime loader path. If approval mode cannot identify exactly one concrete local
file to bind, it refuses to mint an approval-backed run instead of pretending full coverage.
macOS split:
@ -259,6 +264,20 @@ For `host=node`, approval requests include a canonical `systemRunPlan` payload.
that plan as the authoritative command/cwd/session context when forwarding approved `system.run`
requests.
## Interpreter/runtime commands
Approval-backed interpreter/runtime runs are intentionally conservative:
- Exact argv/cwd/env context is always bound.
- Direct shell script and direct runtime file forms are best-effort bound to one concrete local
file snapshot.
- If OpenClaw cannot identify exactly one concrete local file for an interpreter/runtime command
(for example package scripts, eval forms, runtime-specific loader chains, or ambiguous multi-file
forms), approval-backed execution is denied instead of claiming semantic coverage it does not
have.
- For those workflows, prefer sandboxing, a separate host boundary, or an explicit trusted
allowlist/full workflow where the operator accepts the broader runtime semantics.
When approvals are required, the exec tool returns immediately with an approval id. Use that id to
correlate later system events (`Exec finished` / `Exec denied`). If no decision arrives before the
timeout, the request is treated as an approval timeout and surfaced as a denial reason.

View File

@ -123,6 +123,7 @@ Notes:
- `/new <model>` accepts a model alias, `provider/model`, or a provider name (fuzzy match); if no match, the text is treated as the message body.
- For full provider usage breakdown, use `openclaw status --usage`.
- `/allowlist add|remove` requires `commands.config=true` and honors channel `configWrites`.
- In multi-account channels, config-targeted `/allowlist --account <id>` and `/config set channels.<provider>.accounts.<id>...` also honor the target account's `configWrites`.
- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from OpenClaw session logs.
- `/restart` is enabled by default; set `commands.restart: false` to disable it.
- Discord-only native command: `/vc join|leave|status` controls voice channels (requires `channels.discord.voice` and native commands; not available as text).

View File

@ -182,6 +182,7 @@ Each level only sees announces from its direct children.
### Tool policy by depth
- Role and control scope are written into session metadata at spawn time. That keeps flat or restored session keys from accidentally regaining orchestrator privileges.
- **Depth 1 (orchestrator, when `maxSpawnDepth >= 2`)**: Gets `sessions_spawn`, `subagents`, `sessions_list`, `sessions_history` so it can manage its children. Other session/system tools remain denied.
- **Depth 1 (leaf, when `maxSpawnDepth == 1`)**: No session tools (current default behavior).
- **Depth 2 (leaf worker)**: No session tools — `sessions_spawn` is always denied at depth 2. Cannot spawn further children.

View File

@ -7,6 +7,9 @@
"dependencies": {
"google-auth-library": "^10.6.1"
},
"devDependencies": {
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.3.7"
},

View File

@ -4,6 +4,9 @@
"private": true,
"description": "OpenClaw core memory search plugin",
"type": "module",
"devDependencies": {
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.3.7"
},

View File

@ -364,8 +364,9 @@
"discord-api-types": "^0.38.41",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"file-type": "^21.3.0",
"file-type": "^21.3.1",
"grammy": "^1.41.1",
"hono": "4.12.7",
"https-proxy-agent": "^7.0.6",
"ipaddr.js": "^2.3.0",
"jiti": "^2.6.1",
@ -422,17 +423,18 @@
"pnpm": {
"minimumReleaseAge": 2880,
"overrides": {
"hono": "4.12.5",
"hono": "4.12.7",
"@hono/node-server": "1.19.10",
"fast-xml-parser": "5.3.8",
"request": "npm:@cypress/request@3.0.10",
"request-promise": "npm:@cypress/request-promise@5.0.0",
"file-type": "21.3.1",
"form-data": "2.5.4",
"minimatch": "10.2.4",
"qs": "6.14.2",
"node-domexception": "npm:@nolyfill/domexception@^1.0.28",
"@sinclair/typebox": "0.34.48",
"tar": "7.5.10",
"tar": "7.5.11",
"tough-cookie": "4.1.3"
},
"onlyBuiltDependencies": [

View File

@ -5,17 +5,18 @@ settings:
excludeLinksFromLockfile: false
overrides:
hono: 4.12.5
hono: 4.12.7
'@hono/node-server': 1.19.10
fast-xml-parser: 5.3.8
request: npm:@cypress/request@3.0.10
request-promise: npm:@cypress/request-promise@5.0.0
file-type: 21.3.1
form-data: 2.5.4
minimatch: 10.2.4
qs: 6.14.2
node-domexception: npm:@nolyfill/domexception@^1.0.28
'@sinclair/typebox': 0.34.48
tar: 7.5.10
tar: 7.5.11
tough-cookie: 4.1.3
packageExtensionsChecksum: sha256-n+P/SQo4Pf+dHYpYn1Y6wL4cJEVoVzZ835N0OEp4TM8=
@ -32,7 +33,7 @@ importers:
version: 3.1004.0
'@buape/carbon':
specifier: 0.0.0-beta-20260216184201
version: 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.5)(opusscript@0.1.1)
version: 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.7)(opusscript@0.1.1)
'@clack/prompts':
specifier: ^1.1.0
version: 1.1.0
@ -115,11 +116,14 @@ importers:
specifier: ^5.2.1
version: 5.2.1
file-type:
specifier: ^21.3.0
version: 21.3.0
specifier: 21.3.1
version: 21.3.1
grammy:
specifier: ^1.41.1
version: 1.41.1
hono:
specifier: 4.12.7
version: 4.12.7
https-proxy-agent:
specifier: ^7.0.6
version: 7.0.6
@ -172,8 +176,8 @@ importers:
specifier: 0.1.7-alpha.2
version: 0.1.7-alpha.2
tar:
specifier: 7.5.10
version: 7.5.10
specifier: 7.5.11
version: 7.5.11
tslog:
specifier: ^4.10.2
version: 4.10.2
@ -337,9 +341,10 @@ importers:
google-auth-library:
specifier: ^10.6.1
version: 10.6.1
devDependencies:
openclaw:
specifier: '>=2026.3.7'
version: 2026.3.8(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3))
specifier: workspace:*
version: link:../..
extensions/imessage: {}
@ -397,10 +402,10 @@ importers:
version: 4.3.6
extensions/memory-core:
dependencies:
devDependencies:
openclaw:
specifier: '>=2026.3.7'
version: 2026.3.8(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3))
specifier: workspace:*
version: link:../..
extensions/memory-lancedb:
dependencies:
@ -1243,7 +1248,7 @@ packages:
resolution: {integrity: sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==}
engines: {node: '>=18.14.1'}
peerDependencies:
hono: 4.12.5
hono: 4.12.7
'@huggingface/jinja@0.5.5':
resolution: {integrity: sha512-xRlzazC+QZwr6z4ixEqYHo9fgwhTZ3xNSdljlKfUFGZSdlvt166DljRELFUfFytlYOYvo3vTisA/AFOuOAzFQQ==}
@ -4290,8 +4295,8 @@ packages:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
file-type@21.3.0:
resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==}
file-type@21.3.1:
resolution: {integrity: sha512-SrzXX46I/zsRDjTb82eucsGg0ODq2NpGDp4HcsFKApPy8P8vACjpJRDoGGMfEzhFC0ry61ajd7f72J3603anBA==}
engines: {node: '>=20'}
filename-reserved-regex@3.0.0:
@ -4507,8 +4512,8 @@ packages:
highlight.js@10.7.3:
resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
hono@4.12.5:
resolution: {integrity: sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==}
hono@4.12.7:
resolution: {integrity: sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==}
engines: {node: '>=16.9.0'}
hookable@6.0.1:
@ -5336,14 +5341,6 @@ packages:
zod:
optional: true
openclaw@2026.3.8:
resolution: {integrity: sha512-e5Rk2Aj55sD/5LyX94mdYCQj7zpHXo0xIZsl+k140+nRopePfPAxC7nsu0V/NyypPRtaotP1riFfzK7IhaYkuQ==}
engines: {node: '>=22.12.0'}
hasBin: true
peerDependencies:
'@napi-rs/canvas': ^0.1.89
node-llama-cpp: 3.16.2
opus-decoder@0.7.11:
resolution: {integrity: sha512-+e+Jz3vGQLxRTBHs8YJQPRPc1Tr+/aC6coV/DlZylriA29BdHQAYXhvNRKtjftof17OFng0+P4wsFIqQu3a48A==}
@ -6130,8 +6127,8 @@ packages:
tar-stream@3.1.7:
resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==}
tar@7.5.10:
resolution: {integrity: sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw==}
tar@7.5.11:
resolution: {integrity: sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==}
engines: {node: '>=18'}
text-decoder@1.2.7:
@ -7509,14 +7506,14 @@ snapshots:
'@borewit/text-codec@0.2.1': {}
'@buape/carbon@0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.5)(opusscript@0.1.1)':
'@buape/carbon@0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.7)(opusscript@0.1.1)':
dependencies:
'@types/node': 25.3.5
discord-api-types: 0.38.37
optionalDependencies:
'@cloudflare/workers-types': 4.20260120.0
'@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1)
'@hono/node-server': 1.19.10(hono@4.12.5)
'@hono/node-server': 1.19.10(hono@4.12.7)
'@types/bun': 1.3.9
'@types/ws': 8.18.1
ws: 8.19.0
@ -7651,7 +7648,7 @@ snapshots:
npmlog: 5.0.1
rimraf: 3.0.2
semver: 7.7.4
tar: 7.5.10
tar: 7.5.11
transitivePeerDependencies:
- encoding
- supports-color
@ -7828,9 +7825,9 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@hono/node-server@1.19.10(hono@4.12.5)':
'@hono/node-server@1.19.10(hono@4.12.7)':
dependencies:
hono: 4.12.5
hono: 4.12.7
optional: true
'@huggingface/jinja@0.5.5': {}
@ -8205,7 +8202,7 @@ snapshots:
cli-highlight: 2.1.11
diff: 8.0.3
extract-zip: 2.0.1
file-type: 21.3.0
file-type: 21.3.1
glob: 13.0.6
hosted-git-info: 9.0.2
ignore: 7.0.5
@ -10729,7 +10726,7 @@ snapshots:
node-api-headers: 1.8.0
rc: 1.2.8
semver: 7.7.4
tar: 7.5.10
tar: 7.5.11
url-join: 4.0.1
which: 6.0.1
yargs: 17.7.2
@ -11166,7 +11163,7 @@ snapshots:
node-domexception: '@nolyfill/domexception@1.0.28'
web-streams-polyfill: 3.3.3
file-type@21.3.0:
file-type@21.3.1:
dependencies:
'@tokenizer/inflate': 0.4.1
strtok3: 10.3.4
@ -11443,8 +11440,7 @@ snapshots:
highlight.js@10.7.3: {}
hono@4.12.5:
optional: true
hono@4.12.7: {}
hookable@6.0.1: {}
@ -12100,7 +12096,7 @@ snapshots:
'@tokenizer/token': 0.3.0
content-type: 1.0.5
debug: 4.4.3
file-type: 21.3.0
file-type: 21.3.1
media-typer: 1.1.0
strtok3: 10.3.4
token-types: 6.1.2
@ -12318,81 +12314,6 @@ snapshots:
ws: 8.19.0
zod: 4.3.6
openclaw@2026.3.8(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)):
dependencies:
'@agentclientprotocol/sdk': 0.15.0(zod@4.3.6)
'@aws-sdk/client-bedrock': 3.1004.0
'@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.5)(opusscript@0.1.1)
'@clack/prompts': 1.1.0
'@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1)
'@grammyjs/runner': 2.0.3(grammy@1.41.1)
'@grammyjs/transformer-throttler': 1.2.1(grammy@1.41.1)
'@homebridge/ciao': 1.3.5
'@larksuiteoapi/node-sdk': 1.59.0
'@line/bot-sdk': 10.6.0
'@lydell/node-pty': 1.2.0-beta.3
'@mariozechner/pi-agent-core': 0.57.1(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-ai': 0.57.1(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-coding-agent': 0.57.1(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-tui': 0.57.1
'@mozilla/readability': 0.6.0
'@napi-rs/canvas': 0.1.95
'@sinclair/typebox': 0.34.48
'@slack/bolt': 4.6.0(@types/express@5.0.6)
'@slack/web-api': 7.14.1
'@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5)
ajv: 8.18.0
chalk: 5.6.2
chokidar: 5.0.0
cli-highlight: 2.1.11
commander: 14.0.3
croner: 10.0.1
discord-api-types: 0.38.41
dotenv: 17.3.1
express: 5.2.1
file-type: 21.3.0
grammy: 1.41.1
https-proxy-agent: 7.0.6
ipaddr.js: 2.3.0
jiti: 2.6.1
json5: 2.2.3
jszip: 3.10.1
linkedom: 0.18.12
long: 5.3.2
markdown-it: 14.1.1
node-edge-tts: 1.2.10
node-llama-cpp: 3.16.2(typescript@5.9.3)
opusscript: 0.1.1
osc-progress: 0.3.0
pdfjs-dist: 5.5.207
playwright-core: 1.58.2
qrcode-terminal: 0.12.0
sharp: 0.34.5
sqlite-vec: 0.1.7-alpha.2
tar: 7.5.10
tslog: 4.10.2
undici: 7.22.0
ws: 8.19.0
yaml: 2.8.2
zod: 4.3.6
transitivePeerDependencies:
- '@discordjs/opus'
- '@modelcontextprotocol/sdk'
- '@types/express'
- audio-decode
- aws-crt
- bufferutil
- canvas
- debug
- encoding
- ffmpeg-static
- hono
- jimp
- link-preview-js
- node-opus
- supports-color
- utf-8-validate
opus-decoder@0.7.11:
dependencies:
'@wasm-audio-decoders/common': 9.0.7
@ -13397,7 +13318,7 @@ snapshots:
- bare-abort-controller
- react-native-b4a
tar@7.5.10:
tar@7.5.11:
dependencies:
'@isaacs/fs-minipass': 4.0.1
chownr: 3.0.0

View File

@ -86,7 +86,7 @@ if [[ -f "$HASH_FILE" ]]; then
fi
pnpm -s exec tsc -p "$A2UI_RENDERER_DIR/tsconfig.json"
if command -v rolldown >/dev/null 2>&1; then
if command -v rolldown >/dev/null 2>&1 && rolldown --version >/dev/null 2>&1; then
rolldown -c "$A2UI_APP_DIR/rolldown.config.mjs"
else
pnpm -s dlx rolldown -c "$A2UI_APP_DIR/rolldown.config.mjs"

View File

@ -7,7 +7,7 @@ import { callGatewayTool } from "./tools/gateway.js";
export type RequestExecApprovalDecisionParams = {
id: string;
command: string;
command?: string;
commandArgv?: string[];
systemRunPlan?: SystemRunApprovalPlan;
env?: Record<string, string>;
@ -35,8 +35,8 @@ function buildExecApprovalRequestToolParams(
): ExecApprovalRequestToolParams {
return {
id: params.id,
command: params.command,
commandArgv: params.commandArgv,
...(params.command ? { command: params.command } : {}),
...(params.commandArgv ? { commandArgv: params.commandArgv } : {}),
systemRunPlan: params.systemRunPlan,
env: params.env,
cwd: params.cwd,
@ -150,7 +150,7 @@ export async function requestExecApprovalDecision(
type HostExecApprovalParams = {
approvalId: string;
command: string;
command?: string;
commandArgv?: string[];
systemRunPlan?: SystemRunApprovalPlan;
env?: Record<string, string>;

View File

@ -125,7 +125,7 @@ export async function executeNodeHostCommand(
throw new Error("invalid system.run.prepare response");
}
const runArgv = prepared.plan.argv;
const runRawCommand = prepared.plan.rawCommand ?? prepared.cmdText;
const runRawCommand = prepared.plan.commandText;
const runCwd = prepared.plan.cwd ?? params.workdir;
const runAgentId = prepared.plan.agentId ?? params.agentId;
const runSessionKey = prepared.plan.sessionKey ?? params.sessionKey;
@ -238,8 +238,6 @@ export async function executeNodeHostCommand(
// Register first so the returned approval ID is actionable immediately.
const registration = await registerExecApprovalRequestForHostOrThrow({
approvalId,
command: prepared.cmdText,
commandArgv: prepared.plan.argv,
systemRunPlan: prepared.plan,
env: nodeEnv,
workdir: runCwd,
@ -391,7 +389,7 @@ export async function executeNodeHostCommand(
warningText,
approvalSlug,
approvalId,
command: prepared.cmdText,
command: prepared.plan.commandText,
cwd: runCwd,
host: "node",
nodeId,

View File

@ -81,7 +81,7 @@ export function isModernModelRef(ref: ModelRef): boolean {
return false;
}
if (provider === "openrouter" || provider === "opencode") {
if (provider === "openrouter" || provider === "opencode" || provider === "opencode-go") {
// OpenRouter/opencode are pass-through proxies; accept any model ID
// rather than restricting to a static prefix list.
return true;

View File

@ -4,6 +4,7 @@ export const PROVIDER_ENV_API_KEY_CANDIDATES: Record<string, string[]> = {
chutes: ["CHUTES_OAUTH_TOKEN", "CHUTES_API_KEY"],
zai: ["ZAI_API_KEY", "Z_AI_API_KEY"],
opencode: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
"opencode-go": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
"qwen-portal": ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"],
volcengine: ["VOLCANO_ENGINE_API_KEY"],
"volcengine-plan": ["VOLCANO_ENGINE_API_KEY"],

View File

@ -412,4 +412,18 @@ describe("getApiKeyForModel", () => {
},
);
});
it("resolveEnvApiKey('opencode-go') falls back to OPENCODE_ZEN_API_KEY", async () => {
await withEnvAsync(
{
OPENCODE_API_KEY: undefined,
OPENCODE_ZEN_API_KEY: "sk-opencode-zen-fallback", // pragma: allowlist secret
},
async () => {
const resolved = resolveEnvApiKey("opencode-go");
expect(resolved?.apiKey).toBe("sk-opencode-zen-fallback");
expect(resolved?.source).toContain("OPENCODE_ZEN_API_KEY");
},
);
});
});

View File

@ -313,6 +313,12 @@ describe("isModernModelRef", () => {
expect(isModernModelRef({ provider: "opencode", id: "claude-opus-4-6" })).toBe(true);
expect(isModernModelRef({ provider: "opencode", id: "gemini-3-pro" })).toBe(true);
});
it("accepts all opencode-go models without zen exclusions", () => {
expect(isModernModelRef({ provider: "opencode-go", id: "kimi-k2.5" })).toBe(true);
expect(isModernModelRef({ provider: "opencode-go", id: "glm-5" })).toBe(true);
expect(isModernModelRef({ provider: "opencode-go", id: "minimax-m2.5" })).toBe(true);
});
});
describe("resolveForwardCompatModel", () => {

View File

@ -46,6 +46,9 @@ export function normalizeProviderId(provider: string): string {
if (normalized === "opencode-zen") {
return "opencode";
}
if (normalized === "opencode-go-auth") {
return "opencode-go";
}
if (normalized === "qwen") {
return "qwen-portal";
}

View File

@ -9,10 +9,6 @@ import {
isAnthropicBillingError,
isAnthropicRateLimitError,
} from "./live-auth-keys.js";
import {
isMiniMaxModelNotFoundErrorMessage,
isModelNotFoundErrorMessage,
} from "./live-model-errors.js";
import { isModernModelRef } from "./live-model-filter.js";
import { getApiKeyForModel, requireApiKey } from "./model-auth.js";
import { ensureOpenClawModelsJson } from "./models-config.js";
@ -86,6 +82,35 @@ function isGoogleModelNotFoundError(err: unknown): boolean {
return false;
}
function isModelNotFoundErrorMessage(raw: string): boolean {
const msg = raw.trim();
if (!msg) {
return false;
}
if (/\b404\b/.test(msg) && /not(?:[\s_-]+)?found/i.test(msg)) {
return true;
}
if (/not_found_error/i.test(msg)) {
return true;
}
if (/model:\s*[a-z0-9._-]+/i.test(msg) && /not(?:[\s_-]+)?found/i.test(msg)) {
return true;
}
return false;
}
describe("isModelNotFoundErrorMessage", () => {
it("matches whitespace-separated not found errors", () => {
expect(isModelNotFoundErrorMessage("404 model not found")).toBe(true);
expect(isModelNotFoundErrorMessage("model: minimax-text-01 not found")).toBe(true);
});
it("still matches underscore and hyphen variants", () => {
expect(isModelNotFoundErrorMessage("404 model not_found")).toBe(true);
expect(isModelNotFoundErrorMessage("404 model not-found")).toBe(true);
});
});
function isChatGPTUsageLimitErrorMessage(raw: string): boolean {
const msg = raw.toLowerCase();
return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in");
@ -475,11 +500,7 @@ describeLive("live models (profile keys)", () => {
if (ok.res.stopReason === "error") {
const msg = ok.res.errorMessage ?? "";
if (
allowNotFoundSkip &&
(isModelNotFoundErrorMessage(msg) ||
(model.provider === "minimax" && isMiniMaxModelNotFoundErrorMessage(msg)))
) {
if (allowNotFoundSkip && isModelNotFoundErrorMessage(msg)) {
skipped.push({ model: id, reason: msg });
logProgress(`${progressLabel}: skip (model not found)`);
break;
@ -500,7 +521,9 @@ describeLive("live models (profile keys)", () => {
}
if (
ok.text.length === 0 &&
(model.provider === "openrouter" || model.provider === "opencode")
(model.provider === "openrouter" ||
model.provider === "opencode" ||
model.provider === "opencode-go")
) {
skipped.push({
model: id,
@ -563,15 +586,6 @@ describeLive("live models (profile keys)", () => {
logProgress(`${progressLabel}: skip (google model not found)`);
break;
}
if (
allowNotFoundSkip &&
model.provider === "minimax" &&
isMiniMaxModelNotFoundErrorMessage(message)
) {
skipped.push({ model: id, reason: message });
logProgress(`${progressLabel}: skip (model not found)`);
break;
}
if (
allowNotFoundSkip &&
model.provider === "minimax" &&
@ -592,7 +606,7 @@ describeLive("live models (profile keys)", () => {
}
if (
allowNotFoundSkip &&
model.provider === "opencode" &&
(model.provider === "opencode" || model.provider === "opencode-go") &&
isRateLimitErrorMessage(message)
) {
skipped.push({ model: id, reason: message });

View File

@ -135,11 +135,10 @@ function setupNodeInvokeMock(params: {
function createSystemRunPreparePayload(cwd: string | null) {
return {
payload: {
cmdText: "echo hi",
plan: {
argv: ["echo", "hi"],
cwd,
rawCommand: "echo hi",
commandText: "echo hi",
agentId: null,
sessionKey: null,
},
@ -662,10 +661,9 @@ describe("nodes run", () => {
onApprovalRequest: (approvalParams) => {
expect(approvalParams).toMatchObject({
id: expect.any(String),
command: "echo hi",
commandArgv: ["echo", "hi"],
systemRunPlan: expect.objectContaining({
argv: ["echo", "hi"],
commandText: "echo hi",
}),
nodeId: NODE_ID,
host: "node",

View File

@ -0,0 +1,245 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it } from "vitest";
import {
callGatewayMock,
resetSubagentsConfigOverride,
setSubagentsConfigOverride,
} from "./openclaw-tools.subagents.test-harness.js";
import { addSubagentRunForTests, resetSubagentRegistryForTests } from "./subagent-registry.js";
import "./test-helpers/fast-core-tools.js";
import { createPerSenderSessionConfig } from "./test-helpers/session-config.js";
import { createSubagentsTool } from "./tools/subagents-tool.js";
function writeStore(storePath: string, store: Record<string, unknown>) {
fs.mkdirSync(path.dirname(storePath), { recursive: true });
fs.writeFileSync(storePath, JSON.stringify(store, null, 2), "utf-8");
}
describe("openclaw-tools: subagents scope isolation", () => {
let storePath = "";
beforeEach(() => {
resetSubagentRegistryForTests();
resetSubagentsConfigOverride();
callGatewayMock.mockReset();
storePath = path.join(
os.tmpdir(),
`openclaw-subagents-scope-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
);
setSubagentsConfigOverride({
session: createPerSenderSessionConfig({ store: storePath }),
});
writeStore(storePath, {});
});
it("leaf subagents do not inherit parent sibling control scope", async () => {
const leafKey = "agent:main:subagent:leaf";
const siblingKey = "agent:main:subagent:unsandboxed";
writeStore(storePath, {
[leafKey]: {
sessionId: "leaf-session",
updatedAt: Date.now(),
spawnedBy: "agent:main:main",
},
[siblingKey]: {
sessionId: "sibling-session",
updatedAt: Date.now(),
spawnedBy: "agent:main:main",
},
});
addSubagentRunForTests({
runId: "run-leaf",
childSessionKey: leafKey,
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "sandboxed leaf",
cleanup: "keep",
createdAt: Date.now() - 30_000,
startedAt: Date.now() - 30_000,
});
addSubagentRunForTests({
runId: "run-sibling",
childSessionKey: siblingKey,
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "unsandboxed sibling",
cleanup: "keep",
createdAt: Date.now() - 20_000,
startedAt: Date.now() - 20_000,
});
const tool = createSubagentsTool({ agentSessionKey: leafKey });
const result = await tool.execute("call-leaf-list", { action: "list" });
expect(result.details).toMatchObject({
status: "ok",
requesterSessionKey: leafKey,
callerSessionKey: leafKey,
callerIsSubagent: true,
total: 0,
active: [],
recent: [],
});
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("orchestrator subagents still see children they spawned", async () => {
const orchestratorKey = "agent:main:subagent:orchestrator";
const workerKey = `${orchestratorKey}:subagent:worker`;
const siblingKey = "agent:main:subagent:sibling";
writeStore(storePath, {
[orchestratorKey]: {
sessionId: "orchestrator-session",
updatedAt: Date.now(),
spawnedBy: "agent:main:main",
},
[workerKey]: {
sessionId: "worker-session",
updatedAt: Date.now(),
spawnedBy: orchestratorKey,
},
[siblingKey]: {
sessionId: "sibling-session",
updatedAt: Date.now(),
spawnedBy: "agent:main:main",
},
});
addSubagentRunForTests({
runId: "run-worker",
childSessionKey: workerKey,
requesterSessionKey: orchestratorKey,
requesterDisplayKey: orchestratorKey,
task: "worker child",
cleanup: "keep",
createdAt: Date.now() - 30_000,
startedAt: Date.now() - 30_000,
});
addSubagentRunForTests({
runId: "run-sibling",
childSessionKey: siblingKey,
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "sibling of orchestrator",
cleanup: "keep",
createdAt: Date.now() - 20_000,
startedAt: Date.now() - 20_000,
});
const tool = createSubagentsTool({ agentSessionKey: orchestratorKey });
const result = await tool.execute("call-orchestrator-list", { action: "list" });
const details = result.details as {
status?: string;
requesterSessionKey?: string;
total?: number;
active?: Array<{ sessionKey?: string }>;
};
expect(details.status).toBe("ok");
expect(details.requesterSessionKey).toBe(orchestratorKey);
expect(details.total).toBe(1);
expect(details.active).toEqual([
expect.objectContaining({
sessionKey: workerKey,
}),
]);
});
it("leaf subagents cannot kill even explicitly-owned child sessions", async () => {
const leafKey = "agent:main:subagent:leaf";
const childKey = `${leafKey}:subagent:child`;
writeStore(storePath, {
[leafKey]: {
sessionId: "leaf-session",
updatedAt: Date.now(),
spawnedBy: "agent:main:main",
subagentRole: "leaf",
subagentControlScope: "none",
},
[childKey]: {
sessionId: "child-session",
updatedAt: Date.now(),
spawnedBy: leafKey,
subagentRole: "leaf",
subagentControlScope: "none",
},
});
addSubagentRunForTests({
runId: "run-child",
childSessionKey: childKey,
controllerSessionKey: leafKey,
requesterSessionKey: leafKey,
requesterDisplayKey: leafKey,
task: "impossible child",
cleanup: "keep",
createdAt: Date.now() - 30_000,
startedAt: Date.now() - 30_000,
});
const tool = createSubagentsTool({ agentSessionKey: leafKey });
const result = await tool.execute("call-leaf-kill", {
action: "kill",
target: childKey,
});
expect(result.details).toMatchObject({
status: "forbidden",
error: "Leaf subagents cannot control other sessions.",
});
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("leaf subagents cannot steer even explicitly-owned child sessions", async () => {
const leafKey = "agent:main:subagent:leaf";
const childKey = `${leafKey}:subagent:child`;
writeStore(storePath, {
[leafKey]: {
sessionId: "leaf-session",
updatedAt: Date.now(),
spawnedBy: "agent:main:main",
subagentRole: "leaf",
subagentControlScope: "none",
},
[childKey]: {
sessionId: "child-session",
updatedAt: Date.now(),
spawnedBy: leafKey,
subagentRole: "leaf",
subagentControlScope: "none",
},
});
addSubagentRunForTests({
runId: "run-child",
childSessionKey: childKey,
controllerSessionKey: leafKey,
requesterSessionKey: leafKey,
requesterDisplayKey: leafKey,
task: "impossible child",
cleanup: "keep",
createdAt: Date.now() - 30_000,
startedAt: Date.now() - 30_000,
});
const tool = createSubagentsTool({ agentSessionKey: leafKey });
const result = await tool.execute("call-leaf-steer", {
action: "steer",
target: childKey,
message: "continue",
});
expect(result.details).toMatchObject({
status: "forbidden",
error: "Leaf subagents cannot control other sessions.",
});
expect(callGatewayMock).not.toHaveBeenCalled();
});
});

View File

@ -116,6 +116,8 @@ describe("sessions_spawn depth + child limits", () => {
(entry) => entry.method === "sessions.patch" && entry.params?.spawnDepth === 2,
);
expect(spawnDepthPatch?.params?.key).toMatch(/^agent:main:subagent:/);
expect(spawnDepthPatch?.params?.subagentRole).toBe("leaf");
expect(spawnDepthPatch?.params?.subagentControlScope).toBe("none");
});
it("rejects depth-2 callers when maxSpawnDepth is 2 (using stored spawnDepth on flat keys)", async () => {

View File

@ -1,3 +1,6 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
@ -5,6 +8,7 @@ import {
isToolAllowedByPolicyName,
resolveEffectiveToolPolicy,
resolveSubagentToolPolicy,
resolveSubagentToolPolicyForSession,
} from "./pi-tools.policy.js";
import { createStubTool } from "./test-helpers/pi-tool-stubs.js";
@ -144,9 +148,9 @@ describe("resolveSubagentToolPolicy depth awareness", () => {
expect(isToolAllowedByPolicyName("sessions_spawn", policy)).toBe(false);
});
it("depth-2 leaf allows subagents (for visibility)", () => {
it("depth-2 leaf denies subagents", () => {
const policy = resolveSubagentToolPolicy(baseCfg, 2);
expect(isToolAllowedByPolicyName("subagents", policy)).toBe(true);
expect(isToolAllowedByPolicyName("subagents", policy)).toBe(false);
});
it("depth-2 leaf denies sessions_list and sessions_history", () => {
@ -165,6 +169,41 @@ describe("resolveSubagentToolPolicy depth awareness", () => {
expect(isToolAllowedByPolicyName("sessions_list", policy)).toBe(false);
});
it("uses stored leaf role for flat depth-1 session keys", () => {
const storePath = path.join(
os.tmpdir(),
`openclaw-subagent-policy-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
);
fs.mkdirSync(path.dirname(storePath), { recursive: true });
fs.writeFileSync(
storePath,
JSON.stringify(
{
"agent:main:subagent:flat-leaf": {
sessionId: "flat-leaf",
updatedAt: Date.now(),
spawnDepth: 1,
subagentRole: "leaf",
subagentControlScope: "none",
},
},
null,
2,
),
"utf-8",
);
const cfg = {
...baseCfg,
session: {
store: storePath,
},
} as unknown as OpenClawConfig;
const policy = resolveSubagentToolPolicyForSession(cfg, "agent:main:subagent:flat-leaf");
expect(isToolAllowedByPolicyName("sessions_spawn", policy)).toBe(false);
expect(isToolAllowedByPolicyName("subagents", policy)).toBe(false);
});
it("defaults to leaf behavior when no depth is provided", () => {
const policy = resolveSubagentToolPolicy(baseCfg);
// Default depth=1, maxSpawnDepth=2 → orchestrator

View File

@ -11,6 +11,10 @@ import { compileGlobPatterns, matchesAnyGlobPattern } from "./glob-pattern.js";
import type { AnyAgentTool } from "./pi-tools.types.js";
import { pickSandboxToolPolicy } from "./sandbox-tool-policy.js";
import type { SandboxToolPolicy } from "./sandbox.js";
import {
resolveStoredSubagentCapabilities,
type SubagentSessionRole,
} from "./subagent-capabilities.js";
import { expandToolGroups, normalizeToolName } from "./tool-policy.js";
function makeToolPolicyMatcher(policy: SandboxToolPolicy) {
@ -64,15 +68,20 @@ const SUBAGENT_TOOL_DENY_ALWAYS = [
* Additional tools denied for leaf sub-agents (depth >= maxSpawnDepth).
* These are tools that only make sense for orchestrator sub-agents that can spawn children.
*/
const SUBAGENT_TOOL_DENY_LEAF = ["sessions_list", "sessions_history", "sessions_spawn"];
const SUBAGENT_TOOL_DENY_LEAF = [
"subagents",
"sessions_list",
"sessions_history",
"sessions_spawn",
];
/**
* Build the deny list for a sub-agent at a given depth.
*
* - Depth 1 with maxSpawnDepth >= 2 (orchestrator): allowed to use sessions_spawn,
* subagents, sessions_list, sessions_history so it can manage its children.
* - Depth >= maxSpawnDepth (leaf): denied sessions_spawn and
* session management tools. Still allowed subagents (for list/status visibility).
* - Depth >= maxSpawnDepth (leaf): denied subagents, sessions_spawn, and
* session management tools.
*/
function resolveSubagentDenyList(depth: number, maxSpawnDepth: number): string[] {
const isLeaf = depth >= Math.max(1, Math.floor(maxSpawnDepth));
@ -84,6 +93,13 @@ function resolveSubagentDenyList(depth: number, maxSpawnDepth: number): string[]
return [...SUBAGENT_TOOL_DENY_ALWAYS];
}
function resolveSubagentDenyListForRole(role: SubagentSessionRole): string[] {
if (role === "leaf") {
return [...SUBAGENT_TOOL_DENY_ALWAYS, ...SUBAGENT_TOOL_DENY_LEAF];
}
return [...SUBAGENT_TOOL_DENY_ALWAYS];
}
export function resolveSubagentToolPolicy(cfg?: OpenClawConfig, depth?: number): SandboxToolPolicy {
const configured = cfg?.tools?.subagents?.tools;
const maxSpawnDepth =
@ -103,6 +119,27 @@ export function resolveSubagentToolPolicy(cfg?: OpenClawConfig, depth?: number):
return { allow: mergedAllow, deny };
}
export function resolveSubagentToolPolicyForSession(
cfg: OpenClawConfig | undefined,
sessionKey: string,
): SandboxToolPolicy {
const configured = cfg?.tools?.subagents?.tools;
const capabilities = resolveStoredSubagentCapabilities(sessionKey, { cfg });
const allow = Array.isArray(configured?.allow) ? configured.allow : undefined;
const alsoAllow = Array.isArray(configured?.alsoAllow) ? configured.alsoAllow : undefined;
const explicitAllow = new Set(
[...(allow ?? []), ...(alsoAllow ?? [])].map((toolName) => normalizeToolName(toolName)),
);
const deny = [
...resolveSubagentDenyListForRole(capabilities.role).filter(
(toolName) => !explicitAllow.has(normalizeToolName(toolName)),
),
...(Array.isArray(configured?.deny) ? configured.deny : []),
];
const mergedAllow = allow && alsoAllow ? Array.from(new Set([...allow, ...alsoAllow])) : allow;
return { allow: mergedAllow, deny };
}
export function isToolAllowedByPolicyName(name: string, policy?: SandboxToolPolicy): boolean {
if (!policy) {
return true;

View File

@ -24,7 +24,7 @@ import {
isToolAllowedByPolicies,
resolveEffectiveToolPolicy,
resolveGroupToolPolicy,
resolveSubagentToolPolicy,
resolveSubagentToolPolicyForSession,
} from "./pi-tools.policy.js";
import {
assertRequiredParams,
@ -45,7 +45,6 @@ import { cleanToolSchemaForGemini, normalizeToolParameters } from "./pi-tools.sc
import type { AnyAgentTool } from "./pi-tools.types.js";
import type { SandboxContext } from "./sandbox.js";
import { isXaiProvider } from "./schema/clean-for-xai.js";
import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
import { createToolFsPolicy, resolveToolFsConfig } from "./tool-fs-policy.js";
import {
applyToolPolicyPipeline,
@ -321,10 +320,7 @@ export function createOpenClawCodingTools(options?: {
options?.exec?.scopeKey ?? options?.sessionKey ?? (agentId ? `agent:${agentId}` : undefined);
const subagentPolicy =
isSubagentSessionKey(options?.sessionKey) && options?.sessionKey
? resolveSubagentToolPolicy(
options.config,
getSubagentDepthFromSessionStore(options.sessionKey, { cfg: options.config }),
)
? resolveSubagentToolPolicyForSession(options.config, options.sessionKey)
: undefined;
const allowBackground = isToolAllowedByPolicies("process", [
profilePolicyWithAlsoAllow,

View File

@ -47,6 +47,7 @@ describe("resolveProviderCapabilities", () => {
it("flags providers that opt out of OpenAI-compatible turn validation", () => {
expect(supportsOpenAiCompatTurnValidation("openrouter")).toBe(false);
expect(supportsOpenAiCompatTurnValidation("opencode")).toBe(false);
expect(supportsOpenAiCompatTurnValidation("opencode-go")).toBe(false);
expect(supportsOpenAiCompatTurnValidation("moonshot")).toBe(true);
});
@ -63,6 +64,12 @@ describe("resolveProviderCapabilities", () => {
modelId: "gemini-2.0-flash",
}),
).toBe(true);
expect(
shouldSanitizeGeminiThoughtSignaturesForModel({
provider: "opencode-go",
modelId: "google/gemini-2.5-pro-preview",
}),
).toBe(true);
expect(resolveTranscriptToolCallIdMode("mistral", "mistral-large-latest")).toBe("strict9");
});

View File

@ -66,6 +66,11 @@ const PROVIDER_CAPABILITIES: Record<string, Partial<ProviderCapabilities>> = {
geminiThoughtSignatureSanitization: true,
geminiThoughtSignatureModelHints: ["gemini"],
},
"opencode-go": {
openAiCompatTurnValidation: false,
geminiThoughtSignatureSanitization: true,
geminiThoughtSignatureModelHints: ["gemini"],
},
kilocode: {
geminiThoughtSignatureSanitization: true,
geminiThoughtSignatureModelHints: ["gemini"],

View File

@ -137,6 +137,33 @@ describe("buildSandboxCreateArgs", () => {
);
});
it("preserves the OpenClaw exec marker when strict env sanitization is enabled", () => {
const cfg = createSandboxConfig({
env: {
NODE_ENV: "test",
},
});
const args = buildSandboxCreateArgs({
name: "openclaw-sbx-marker",
cfg,
scopeKey: "main",
createdAtMs: 1700000000000,
envSanitizationOptions: {
strictMode: true,
},
});
expect(args).toEqual(
expect.arrayContaining([
"--env",
"NODE_ENV=test",
"--env",
`OPENCLAW_CLI=${OPENCLAW_CLI_ENV_VALUE}`,
]),
);
});
it("emits -v flags for safe custom binds", () => {
const cfg: SandboxDockerConfig = {
image: "openclaw-sandbox:bookworm-slim",

View File

@ -5,6 +5,7 @@ import {
resolveWindowsSpawnProgram,
} from "../../plugin-sdk/windows-spawn.js";
import { sanitizeEnvVars } from "./sanitize-env-vars.js";
import type { EnvSanitizationOptions } from "./sanitize-env-vars.js";
type ExecDockerRawOptions = {
allowFailure?: boolean;
@ -52,7 +53,7 @@ export function resolveDockerSpawnInvocation(
env: runtime.env,
execPath: runtime.execPath,
packageName: "docker",
allowShellFallback: true,
allowShellFallback: false,
});
const resolved = materializeWindowsSpawnProgram(program, args);
return {
@ -325,6 +326,7 @@ export function buildSandboxCreateArgs(params: {
allowSourcesOutsideAllowedRoots?: boolean;
allowReservedContainerTargets?: boolean;
allowContainerNamespaceJoin?: boolean;
envSanitizationOptions?: EnvSanitizationOptions;
}) {
// Runtime security validation: blocks dangerous bind mounts, network modes, and profiles.
validateSandboxSecurity({
@ -366,14 +368,14 @@ export function buildSandboxCreateArgs(params: {
if (params.cfg.user) {
args.push("--user", params.cfg.user);
}
const envSanitization = sanitizeEnvVars(markOpenClawExecEnv(params.cfg.env ?? {}));
const envSanitization = sanitizeEnvVars(params.cfg.env ?? {}, params.envSanitizationOptions);
if (envSanitization.blocked.length > 0) {
log.warn(`Blocked sensitive environment variables: ${envSanitization.blocked.join(", ")}`);
}
if (envSanitization.warnings.length > 0) {
log.warn(`Suspicious environment variables: ${envSanitization.warnings.join(", ")}`);
}
for (const [key, value] of Object.entries(envSanitization.allowed)) {
for (const [key, value] of Object.entries(markOpenClawExecEnv(envSanitization.allowed))) {
args.push("--env", `${key}=${value}`);
}
for (const cap of params.cfg.capDrop) {

View File

@ -47,22 +47,20 @@ describe("resolveDockerSpawnInvocation", () => {
});
});
it("falls back to shell mode when only unresolved docker.cmd wrapper exists", async () => {
it("rejects unresolved docker.cmd wrappers instead of shelling out", async () => {
const dir = await createTempDir();
const cmdPath = path.join(dir, "docker.cmd");
await mkdir(path.dirname(cmdPath), { recursive: true });
await writeFile(cmdPath, "@ECHO off\r\necho docker\r\n", "utf8");
const resolved = resolveDockerSpawnInvocation(["ps"], {
platform: "win32",
env: { PATH: dir, PATHEXT: ".CMD;.EXE;.BAT" },
execPath: "C:\\node\\node.exe",
});
expect(path.normalize(resolved.command).toLowerCase()).toBe(
path.normalize(cmdPath).toLowerCase(),
expect(() =>
resolveDockerSpawnInvocation(["ps"], {
platform: "win32",
env: { PATH: dir, PATHEXT: ".CMD;.EXE;.BAT" },
execPath: "C:\\node\\node.exe",
}),
).toThrow(
/wrapper resolved, but no executable\/Node entrypoint could be resolved without shell execution\./i,
);
expect(resolved.args).toEqual(["ps"]);
expect(resolved.shell).toBe(true);
expect(resolved.windowsHide).toBeUndefined();
});
});

View File

@ -0,0 +1,143 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { SANDBOX_PINNED_MUTATION_PYTHON } from "./fs-bridge-mutation-helper.js";
async function withTempRoot<T>(prefix: string, run: (root: string) => Promise<T>): Promise<T> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
try {
return await run(root);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
}
function runMutation(args: string[], input?: string) {
return spawnSync("python3", ["-c", SANDBOX_PINNED_MUTATION_PYTHON, ...args], {
input,
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
});
}
describe("sandbox pinned mutation helper", () => {
it("writes through a pinned directory fd", async () => {
await withTempRoot("openclaw-mutation-helper-", async (root) => {
const workspace = path.join(root, "workspace");
await fs.mkdir(workspace, { recursive: true });
const result = runMutation(["write", workspace, "nested/deeper", "note.txt", "1"], "hello");
expect(result.status).toBe(0);
await expect(
fs.readFile(path.join(workspace, "nested", "deeper", "note.txt"), "utf8"),
).resolves.toBe("hello");
});
});
it.runIf(process.platform !== "win32")(
"rejects symlink-parent writes instead of materializing a temp file outside the mount",
async () => {
await withTempRoot("openclaw-mutation-helper-", async (root) => {
const workspace = path.join(root, "workspace");
const outside = path.join(root, "outside");
await fs.mkdir(workspace, { recursive: true });
await fs.mkdir(outside, { recursive: true });
await fs.symlink(outside, path.join(workspace, "alias"));
const result = runMutation(["write", workspace, "alias", "escape.txt", "0"], "owned");
expect(result.status).not.toBe(0);
await expect(fs.readFile(path.join(outside, "escape.txt"), "utf8")).rejects.toThrow();
});
},
);
it.runIf(process.platform !== "win32")("rejects symlink segments during mkdirp", async () => {
await withTempRoot("openclaw-mutation-helper-", async (root) => {
const workspace = path.join(root, "workspace");
const outside = path.join(root, "outside");
await fs.mkdir(workspace, { recursive: true });
await fs.mkdir(outside, { recursive: true });
await fs.symlink(outside, path.join(workspace, "alias"));
const result = runMutation(["mkdirp", workspace, "alias/nested"]);
expect(result.status).not.toBe(0);
await expect(fs.readFile(path.join(outside, "nested"), "utf8")).rejects.toThrow();
});
});
it.runIf(process.platform !== "win32")("remove unlinks the symlink itself", async () => {
await withTempRoot("openclaw-mutation-helper-", async (root) => {
const workspace = path.join(root, "workspace");
const outside = path.join(root, "outside");
await fs.mkdir(workspace, { recursive: true });
await fs.mkdir(outside, { recursive: true });
await fs.writeFile(path.join(outside, "secret.txt"), "classified", "utf8");
await fs.symlink(path.join(outside, "secret.txt"), path.join(workspace, "link.txt"));
const result = runMutation(["remove", workspace, "", "link.txt", "0", "0"]);
expect(result.status).toBe(0);
await expect(fs.readlink(path.join(workspace, "link.txt"))).rejects.toThrow();
await expect(fs.readFile(path.join(outside, "secret.txt"), "utf8")).resolves.toBe(
"classified",
);
});
});
it.runIf(process.platform !== "win32")(
"rejects symlink destination parents during rename",
async () => {
await withTempRoot("openclaw-mutation-helper-", async (root) => {
const workspace = path.join(root, "workspace");
const outside = path.join(root, "outside");
await fs.mkdir(workspace, { recursive: true });
await fs.mkdir(outside, { recursive: true });
await fs.writeFile(path.join(workspace, "from.txt"), "payload", "utf8");
await fs.symlink(outside, path.join(workspace, "alias"));
const result = runMutation([
"rename",
workspace,
"",
"from.txt",
workspace,
"alias",
"escape.txt",
"1",
]);
expect(result.status).not.toBe(0);
await expect(fs.readFile(path.join(workspace, "from.txt"), "utf8")).resolves.toBe(
"payload",
);
await expect(fs.readFile(path.join(outside, "escape.txt"), "utf8")).rejects.toThrow();
});
},
);
it.runIf(process.platform !== "win32")(
"copies directories across different mount roots during rename fallback",
async () => {
await withTempRoot("openclaw-mutation-helper-", async (root) => {
const sourceRoot = path.join(root, "source");
const destRoot = path.join(root, "dest");
await fs.mkdir(path.join(sourceRoot, "dir", "nested"), { recursive: true });
await fs.mkdir(destRoot, { recursive: true });
await fs.writeFile(path.join(sourceRoot, "dir", "nested", "file.txt"), "payload", "utf8");
const result = runMutation(["rename", sourceRoot, "", "dir", destRoot, "", "moved", "1"]);
expect(result.status).toBe(0);
await expect(
fs.readFile(path.join(destRoot, "moved", "nested", "file.txt"), "utf8"),
).resolves.toBe("payload");
await expect(fs.stat(path.join(sourceRoot, "dir"))).rejects.toThrow();
});
},
);
});

View File

@ -0,0 +1,347 @@
import { PATH_ALIAS_POLICIES } from "../../infra/path-alias-guards.js";
import type {
PathSafetyCheck,
PinnedSandboxDirectoryEntry,
PinnedSandboxEntry,
} from "./fs-bridge-path-safety.js";
import type { SandboxFsCommandPlan } from "./fs-bridge-shell-command-plans.js";
export const SANDBOX_PINNED_MUTATION_PYTHON = [
"import errno",
"import os",
"import secrets",
"import stat",
"import sys",
"",
"operation = sys.argv[1]",
"",
"DIR_FLAGS = os.O_RDONLY",
"if hasattr(os, 'O_DIRECTORY'):",
" DIR_FLAGS |= os.O_DIRECTORY",
"if hasattr(os, 'O_NOFOLLOW'):",
" DIR_FLAGS |= os.O_NOFOLLOW",
"",
"READ_FLAGS = os.O_RDONLY",
"if hasattr(os, 'O_NOFOLLOW'):",
" READ_FLAGS |= os.O_NOFOLLOW",
"",
"WRITE_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL",
"if hasattr(os, 'O_NOFOLLOW'):",
" WRITE_FLAGS |= os.O_NOFOLLOW",
"",
"def split_relative(path_value):",
" segments = []",
" for segment in path_value.split('/'):",
" if not segment or segment == '.':",
" continue",
" if segment == '..':",
" raise OSError(errno.EPERM, 'path traversal is not allowed', segment)",
" segments.append(segment)",
" return segments",
"",
"def open_dir(path_value, dir_fd=None):",
" return os.open(path_value, DIR_FLAGS, dir_fd=dir_fd)",
"",
"def walk_dir(root_fd, rel_path, mkdir_enabled):",
" current_fd = os.dup(root_fd)",
" try:",
" for segment in split_relative(rel_path):",
" try:",
" next_fd = open_dir(segment, dir_fd=current_fd)",
" except FileNotFoundError:",
" if not mkdir_enabled:",
" raise",
" os.mkdir(segment, 0o777, dir_fd=current_fd)",
" next_fd = open_dir(segment, dir_fd=current_fd)",
" os.close(current_fd)",
" current_fd = next_fd",
" return current_fd",
" except Exception:",
" os.close(current_fd)",
" raise",
"",
"def create_temp_file(parent_fd, basename):",
" prefix = '.openclaw-write-' + basename + '.'",
" for _ in range(128):",
" candidate = prefix + secrets.token_hex(6)",
" try:",
" fd = os.open(candidate, WRITE_FLAGS, 0o600, dir_fd=parent_fd)",
" return candidate, fd",
" except FileExistsError:",
" continue",
" raise RuntimeError('failed to allocate sandbox temp file')",
"",
"def create_temp_dir(parent_fd, basename, mode):",
" prefix = '.openclaw-move-' + basename + '.'",
" for _ in range(128):",
" candidate = prefix + secrets.token_hex(6)",
" try:",
" os.mkdir(candidate, mode, dir_fd=parent_fd)",
" return candidate",
" except FileExistsError:",
" continue",
" raise RuntimeError('failed to allocate sandbox temp directory')",
"",
"def write_atomic(parent_fd, basename, stdin_buffer):",
" temp_fd = None",
" temp_name = None",
" try:",
" temp_name, temp_fd = create_temp_file(parent_fd, basename)",
" while True:",
" chunk = stdin_buffer.read(65536)",
" if not chunk:",
" break",
" os.write(temp_fd, chunk)",
" os.fsync(temp_fd)",
" os.close(temp_fd)",
" temp_fd = None",
" os.replace(temp_name, basename, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)",
" temp_name = None",
" os.fsync(parent_fd)",
" finally:",
" if temp_fd is not None:",
" os.close(temp_fd)",
" if temp_name is not None:",
" try:",
" os.unlink(temp_name, dir_fd=parent_fd)",
" except FileNotFoundError:",
" pass",
"",
"def remove_tree(parent_fd, basename):",
" entry_stat = os.lstat(basename, dir_fd=parent_fd)",
" if not stat.S_ISDIR(entry_stat.st_mode) or stat.S_ISLNK(entry_stat.st_mode):",
" os.unlink(basename, dir_fd=parent_fd)",
" return",
" dir_fd = open_dir(basename, dir_fd=parent_fd)",
" try:",
" for child in os.listdir(dir_fd):",
" remove_tree(dir_fd, child)",
" finally:",
" os.close(dir_fd)",
" os.rmdir(basename, dir_fd=parent_fd)",
"",
"def move_entry(src_parent_fd, src_basename, dst_parent_fd, dst_basename):",
" try:",
" os.rename(src_basename, dst_basename, src_dir_fd=src_parent_fd, dst_dir_fd=dst_parent_fd)",
" os.fsync(dst_parent_fd)",
" os.fsync(src_parent_fd)",
" return",
" except OSError as err:",
" if err.errno != errno.EXDEV:",
" raise",
" src_stat = os.lstat(src_basename, dir_fd=src_parent_fd)",
" if stat.S_ISDIR(src_stat.st_mode) and not stat.S_ISLNK(src_stat.st_mode):",
" temp_dir_name = create_temp_dir(dst_parent_fd, dst_basename, stat.S_IMODE(src_stat.st_mode) or 0o755)",
" temp_dir_fd = open_dir(temp_dir_name, dir_fd=dst_parent_fd)",
" src_dir_fd = open_dir(src_basename, dir_fd=src_parent_fd)",
" try:",
" for child in os.listdir(src_dir_fd):",
" move_entry(src_dir_fd, child, temp_dir_fd, child)",
" finally:",
" os.close(src_dir_fd)",
" os.close(temp_dir_fd)",
" os.rename(temp_dir_name, dst_basename, src_dir_fd=dst_parent_fd, dst_dir_fd=dst_parent_fd)",
" os.rmdir(src_basename, dir_fd=src_parent_fd)",
" os.fsync(dst_parent_fd)",
" os.fsync(src_parent_fd)",
" return",
" if stat.S_ISLNK(src_stat.st_mode):",
" link_target = os.readlink(src_basename, dir_fd=src_parent_fd)",
" try:",
" os.unlink(dst_basename, dir_fd=dst_parent_fd)",
" except FileNotFoundError:",
" pass",
" os.symlink(link_target, dst_basename, dir_fd=dst_parent_fd)",
" os.unlink(src_basename, dir_fd=src_parent_fd)",
" os.fsync(dst_parent_fd)",
" os.fsync(src_parent_fd)",
" return",
" src_fd = os.open(src_basename, READ_FLAGS, dir_fd=src_parent_fd)",
" temp_fd = None",
" temp_name = None",
" try:",
" temp_name, temp_fd = create_temp_file(dst_parent_fd, dst_basename)",
" while True:",
" chunk = os.read(src_fd, 65536)",
" if not chunk:",
" break",
" os.write(temp_fd, chunk)",
" try:",
" os.fchmod(temp_fd, stat.S_IMODE(src_stat.st_mode))",
" except AttributeError:",
" pass",
" os.fsync(temp_fd)",
" os.close(temp_fd)",
" temp_fd = None",
" os.replace(temp_name, dst_basename, src_dir_fd=dst_parent_fd, dst_dir_fd=dst_parent_fd)",
" temp_name = None",
" os.unlink(src_basename, dir_fd=src_parent_fd)",
" os.fsync(dst_parent_fd)",
" os.fsync(src_parent_fd)",
" finally:",
" if temp_fd is not None:",
" os.close(temp_fd)",
" if temp_name is not None:",
" try:",
" os.unlink(temp_name, dir_fd=dst_parent_fd)",
" except FileNotFoundError:",
" pass",
" os.close(src_fd)",
"",
"if operation == 'write':",
" root_fd = open_dir(sys.argv[2])",
" parent_fd = None",
" try:",
" parent_fd = walk_dir(root_fd, sys.argv[3], sys.argv[5] == '1')",
" write_atomic(parent_fd, sys.argv[4], sys.stdin.buffer)",
" finally:",
" if parent_fd is not None:",
" os.close(parent_fd)",
" os.close(root_fd)",
"elif operation == 'mkdirp':",
" root_fd = open_dir(sys.argv[2])",
" target_fd = None",
" try:",
" target_fd = walk_dir(root_fd, sys.argv[3], True)",
" os.fsync(target_fd)",
" finally:",
" if target_fd is not None:",
" os.close(target_fd)",
" os.close(root_fd)",
"elif operation == 'remove':",
" root_fd = open_dir(sys.argv[2])",
" parent_fd = None",
" try:",
" parent_fd = walk_dir(root_fd, sys.argv[3], False)",
" try:",
" if sys.argv[5] == '1':",
" remove_tree(parent_fd, sys.argv[4])",
" else:",
" entry_stat = os.lstat(sys.argv[4], dir_fd=parent_fd)",
" if stat.S_ISDIR(entry_stat.st_mode) and not stat.S_ISLNK(entry_stat.st_mode):",
" os.rmdir(sys.argv[4], dir_fd=parent_fd)",
" else:",
" os.unlink(sys.argv[4], dir_fd=parent_fd)",
" os.fsync(parent_fd)",
" except FileNotFoundError:",
" if sys.argv[6] != '1':",
" raise",
" finally:",
" if parent_fd is not None:",
" os.close(parent_fd)",
" os.close(root_fd)",
"elif operation == 'rename':",
" src_root_fd = open_dir(sys.argv[2])",
" dst_root_fd = open_dir(sys.argv[5])",
" src_parent_fd = None",
" dst_parent_fd = None",
" try:",
" src_parent_fd = walk_dir(src_root_fd, sys.argv[3], False)",
" dst_parent_fd = walk_dir(dst_root_fd, sys.argv[6], sys.argv[8] == '1')",
" move_entry(src_parent_fd, sys.argv[4], dst_parent_fd, sys.argv[7])",
" finally:",
" if src_parent_fd is not None:",
" os.close(src_parent_fd)",
" if dst_parent_fd is not None:",
" os.close(dst_parent_fd)",
" os.close(src_root_fd)",
" os.close(dst_root_fd)",
"else:",
" raise RuntimeError('unknown sandbox mutation operation: ' + operation)",
].join("\n");
function buildPinnedMutationPlan(params: {
args: string[];
checks: PathSafetyCheck[];
}): SandboxFsCommandPlan {
return {
checks: params.checks,
recheckBeforeCommand: true,
script: ["set -eu", "python3 - \"$@\" <<'PY'", SANDBOX_PINNED_MUTATION_PYTHON, "PY"].join("\n"),
args: params.args,
};
}
export function buildPinnedWritePlan(params: {
check: PathSafetyCheck;
pinned: PinnedSandboxEntry;
mkdir: boolean;
}): SandboxFsCommandPlan {
return buildPinnedMutationPlan({
checks: [params.check],
args: [
"write",
params.pinned.mountRootPath,
params.pinned.relativeParentPath,
params.pinned.basename,
params.mkdir ? "1" : "0",
],
});
}
export function buildPinnedMkdirpPlan(params: {
check: PathSafetyCheck;
pinned: PinnedSandboxDirectoryEntry;
}): SandboxFsCommandPlan {
return buildPinnedMutationPlan({
checks: [params.check],
args: ["mkdirp", params.pinned.mountRootPath, params.pinned.relativePath],
});
}
export function buildPinnedRemovePlan(params: {
check: PathSafetyCheck;
pinned: PinnedSandboxEntry;
recursive?: boolean;
force?: boolean;
}): SandboxFsCommandPlan {
return buildPinnedMutationPlan({
checks: [
{
target: params.check.target,
options: {
...params.check.options,
aliasPolicy: PATH_ALIAS_POLICIES.unlinkTarget,
},
},
],
args: [
"remove",
params.pinned.mountRootPath,
params.pinned.relativeParentPath,
params.pinned.basename,
params.recursive ? "1" : "0",
params.force === false ? "0" : "1",
],
});
}
export function buildPinnedRenamePlan(params: {
fromCheck: PathSafetyCheck;
toCheck: PathSafetyCheck;
from: PinnedSandboxEntry;
to: PinnedSandboxEntry;
}): SandboxFsCommandPlan {
return buildPinnedMutationPlan({
checks: [
{
target: params.fromCheck.target,
options: {
...params.fromCheck.options,
aliasPolicy: PATH_ALIAS_POLICIES.unlinkTarget,
},
},
params.toCheck,
],
args: [
"rename",
params.from.mountRootPath,
params.from.relativeParentPath,
params.from.basename,
params.to.mountRootPath,
params.to.relativeParentPath,
params.to.basename,
"1",
],
});
}

View File

@ -0,0 +1,190 @@
// language=python
export const SANDBOX_PINNED_FS_MUTATION_PYTHON = String.raw`import os
import secrets
import subprocess
import sys
operation = sys.argv[1]
DIR_FLAGS = os.O_RDONLY
if hasattr(os, "O_DIRECTORY"):
DIR_FLAGS |= os.O_DIRECTORY
if hasattr(os, "O_NOFOLLOW"):
DIR_FLAGS |= os.O_NOFOLLOW
WRITE_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL
if hasattr(os, "O_NOFOLLOW"):
WRITE_FLAGS |= os.O_NOFOLLOW
def open_dir(path, dir_fd=None):
return os.open(path, DIR_FLAGS, dir_fd=dir_fd)
def walk_parent(root_fd, rel_parent, mkdir_enabled):
current_fd = os.dup(root_fd)
try:
segments = [segment for segment in rel_parent.split("/") if segment and segment != "."]
for segment in segments:
if segment == "..":
raise OSError("path traversal is not allowed")
try:
next_fd = open_dir(segment, dir_fd=current_fd)
except FileNotFoundError:
if not mkdir_enabled:
raise
os.mkdir(segment, 0o777, dir_fd=current_fd)
next_fd = open_dir(segment, dir_fd=current_fd)
os.close(current_fd)
current_fd = next_fd
return current_fd
except Exception:
os.close(current_fd)
raise
def create_temp_file(parent_fd, basename):
prefix = ".openclaw-write-" + basename + "."
for _ in range(128):
candidate = prefix + secrets.token_hex(6)
try:
fd = os.open(candidate, WRITE_FLAGS, 0o600, dir_fd=parent_fd)
return candidate, fd
except FileExistsError:
continue
raise RuntimeError("failed to allocate sandbox temp file")
def fd_path(fd, basename=None):
base = f"/proc/self/fd/{fd}"
if basename is None:
return base
return f"{base}/{basename}"
def run_command(argv, pass_fds):
subprocess.run(argv, check=True, pass_fds=tuple(pass_fds))
def write_stdin_to_fd(fd):
while True:
chunk = sys.stdin.buffer.read(65536)
if not chunk:
break
os.write(fd, chunk)
def run_write(args):
mount_root, relative_parent, basename, mkdir_enabled_raw = args
mkdir_enabled = mkdir_enabled_raw == "1"
root_fd = open_dir(mount_root)
parent_fd = None
temp_fd = None
temp_name = None
try:
parent_fd = walk_parent(root_fd, relative_parent, mkdir_enabled)
temp_name, temp_fd = create_temp_file(parent_fd, basename)
write_stdin_to_fd(temp_fd)
os.fsync(temp_fd)
os.close(temp_fd)
temp_fd = None
os.replace(temp_name, basename, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
os.fsync(parent_fd)
except Exception:
if temp_fd is not None:
os.close(temp_fd)
temp_fd = None
if temp_name is not None and parent_fd is not None:
try:
os.unlink(temp_name, dir_fd=parent_fd)
except FileNotFoundError:
pass
raise
finally:
if parent_fd is not None:
os.close(parent_fd)
os.close(root_fd)
def run_mkdirp(args):
mount_root, relative_parent, basename = args
root_fd = open_dir(mount_root)
parent_fd = None
try:
parent_fd = walk_parent(root_fd, relative_parent, True)
run_command(["mkdir", "-p", "--", fd_path(parent_fd, basename)], [parent_fd])
os.fsync(parent_fd)
finally:
if parent_fd is not None:
os.close(parent_fd)
os.close(root_fd)
def run_remove(args):
mount_root, relative_parent, basename, recursive_raw, force_raw = args
root_fd = open_dir(mount_root)
parent_fd = None
try:
parent_fd = walk_parent(root_fd, relative_parent, False)
argv = ["rm"]
if force_raw == "1":
argv.append("-f")
if recursive_raw == "1":
argv.append("-r")
argv.extend(["--", fd_path(parent_fd, basename)])
run_command(argv, [parent_fd])
os.fsync(parent_fd)
finally:
if parent_fd is not None:
os.close(parent_fd)
os.close(root_fd)
def run_rename(args):
(
from_mount_root,
from_relative_parent,
from_basename,
to_mount_root,
to_relative_parent,
to_basename,
) = args
from_root_fd = open_dir(from_mount_root)
to_root_fd = open_dir(to_mount_root)
from_parent_fd = None
to_parent_fd = None
try:
from_parent_fd = walk_parent(from_root_fd, from_relative_parent, False)
to_parent_fd = walk_parent(to_root_fd, to_relative_parent, True)
run_command(
[
"mv",
"--",
fd_path(from_parent_fd, from_basename),
fd_path(to_parent_fd, to_basename),
],
[from_parent_fd, to_parent_fd],
)
os.fsync(from_parent_fd)
if to_parent_fd != from_parent_fd:
os.fsync(to_parent_fd)
finally:
if from_parent_fd is not None:
os.close(from_parent_fd)
if to_parent_fd is not None:
os.close(to_parent_fd)
os.close(from_root_fd)
os.close(to_root_fd)
OPERATIONS = {
"write": run_write,
"mkdirp": run_mkdirp,
"remove": run_remove,
"rename": run_rename,
}
if operation not in OPERATIONS:
raise RuntimeError(f"unknown sandbox fs mutation: {operation}")
OPERATIONS[operation](sys.argv[2:])`;

View File

@ -18,11 +18,17 @@ export type PathSafetyCheck = {
options: PathSafetyOptions;
};
export type AnchoredSandboxEntry = {
canonicalParentPath: string;
export type PinnedSandboxEntry = {
mountRootPath: string;
relativeParentPath: string;
basename: string;
};
export type PinnedSandboxDirectoryEntry = {
mountRootPath: string;
relativePath: string;
};
type RunCommand = (
script: string,
options?: {
@ -128,22 +134,43 @@ export class SandboxFsPathGuard {
return guarded;
}
async resolveAnchoredSandboxEntry(target: SandboxResolvedFsPath): Promise<AnchoredSandboxEntry> {
resolvePinnedEntry(target: SandboxResolvedFsPath, action: string): PinnedSandboxEntry {
const basename = path.posix.basename(target.containerPath);
if (!basename || basename === "." || basename === "/") {
throw new Error(`Invalid sandbox entry target: ${target.containerPath}`);
}
const parentPath = normalizeContainerPath(path.posix.dirname(target.containerPath));
const canonicalParentPath = await this.resolveCanonicalContainerPath({
containerPath: parentPath,
allowFinalSymlinkForUnlink: false,
});
const mount = this.resolveRequiredMount(parentPath, action);
const relativeParentPath = path.posix.relative(mount.containerRoot, parentPath);
if (relativeParentPath.startsWith("..") || path.posix.isAbsolute(relativeParentPath)) {
throw new Error(
`Sandbox path escapes allowed mounts; cannot ${action}: ${target.containerPath}`,
);
}
return {
canonicalParentPath,
mountRootPath: mount.containerRoot,
relativeParentPath: relativeParentPath === "." ? "" : relativeParentPath,
basename,
};
}
resolvePinnedDirectoryEntry(
target: SandboxResolvedFsPath,
action: string,
): PinnedSandboxDirectoryEntry {
const mount = this.resolveRequiredMount(target.containerPath, action);
const relativePath = path.posix.relative(mount.containerRoot, target.containerPath);
if (relativePath.startsWith("..") || path.posix.isAbsolute(relativePath)) {
throw new Error(
`Sandbox path escapes allowed mounts; cannot ${action}: ${target.containerPath}`,
);
}
return {
mountRootPath: mount.containerRoot,
relativePath: relativePath === "." ? "" : relativePath,
};
}
private pathIsExistingDirectory(hostPath: string): boolean {
try {
return fs.statSync(hostPath).isDirectory();

View File

@ -1,107 +1,15 @@
import { PATH_ALIAS_POLICIES } from "../../infra/path-alias-guards.js";
import type { AnchoredSandboxEntry, PathSafetyCheck } from "./fs-bridge-path-safety.js";
import type { PathSafetyCheck } from "./fs-bridge-path-safety.js";
import type { SandboxResolvedFsPath } from "./fs-paths.js";
export type SandboxFsCommandPlan = {
checks: PathSafetyCheck[];
script: string;
args?: string[];
stdin?: Buffer | string;
recheckBeforeCommand?: boolean;
allowFailure?: boolean;
};
export function buildWriteCommitPlan(
target: SandboxResolvedFsPath,
tempPath: string,
): SandboxFsCommandPlan {
return {
checks: [{ target, options: { action: "write files", requireWritable: true } }],
recheckBeforeCommand: true,
script: 'set -eu; mv -f -- "$1" "$2"',
args: [tempPath, target.containerPath],
};
}
export function buildMkdirpPlan(
target: SandboxResolvedFsPath,
anchoredTarget: AnchoredSandboxEntry,
): SandboxFsCommandPlan {
return {
checks: [
{
target,
options: {
action: "create directories",
requireWritable: true,
allowedType: "directory",
},
},
],
script: 'set -eu\ncd -- "$1"\nmkdir -p -- "$2"',
args: [anchoredTarget.canonicalParentPath, anchoredTarget.basename],
};
}
export function buildRemovePlan(params: {
target: SandboxResolvedFsPath;
anchoredTarget: AnchoredSandboxEntry;
recursive?: boolean;
force?: boolean;
}): SandboxFsCommandPlan {
const flags = [params.force === false ? "" : "-f", params.recursive ? "-r" : ""].filter(Boolean);
const rmCommand = flags.length > 0 ? `rm ${flags.join(" ")}` : "rm";
return {
checks: [
{
target: params.target,
options: {
action: "remove files",
requireWritable: true,
aliasPolicy: PATH_ALIAS_POLICIES.unlinkTarget,
},
},
],
recheckBeforeCommand: true,
script: `set -eu\ncd -- "$1"\n${rmCommand} -- "$2"`,
args: [params.anchoredTarget.canonicalParentPath, params.anchoredTarget.basename],
};
}
export function buildRenamePlan(params: {
from: SandboxResolvedFsPath;
to: SandboxResolvedFsPath;
anchoredFrom: AnchoredSandboxEntry;
anchoredTo: AnchoredSandboxEntry;
}): SandboxFsCommandPlan {
return {
checks: [
{
target: params.from,
options: {
action: "rename files",
requireWritable: true,
aliasPolicy: PATH_ALIAS_POLICIES.unlinkTarget,
},
},
{
target: params.to,
options: {
action: "rename files",
requireWritable: true,
},
},
],
recheckBeforeCommand: true,
script: ["set -eu", 'mkdir -p -- "$2"', 'cd -- "$1"', 'mv -- "$3" "$2/$4"'].join("\n"),
args: [
params.anchoredFrom.canonicalParentPath,
params.anchoredTo.canonicalParentPath,
params.anchoredFrom.basename,
params.anchoredTo.basename,
],
};
}
export function buildStatPlan(target: SandboxResolvedFsPath): SandboxFsCommandPlan {
return {
checks: [{ target, options: { action: "stat files" } }],

View File

@ -4,8 +4,6 @@ import { describe, expect, it } from "vitest";
import {
createSandbox,
createSandboxFsBridge,
findCallByScriptFragment,
findCallsByScriptFragment,
getDockerArg,
installFsBridgeTestHarness,
mockedExecDockerRaw,
@ -67,54 +65,60 @@ describe("sandbox fs bridge anchored ops", () => {
});
});
const anchoredCases = [
const pinnedCases = [
{
name: "mkdirp anchors parent + basename",
name: "mkdirp pins mount root + relative path",
invoke: (bridge: ReturnType<typeof createSandboxFsBridge>) =>
bridge.mkdirp({ filePath: "nested/leaf" }),
scriptFragment: 'mkdir -p -- "$2"',
expectedArgs: ["/workspace/nested", "leaf"],
expectedArgs: ["mkdirp", "/workspace", "nested/leaf"],
forbiddenArgs: ["/workspace/nested/leaf"],
canonicalProbe: "/workspace/nested",
},
{
name: "remove anchors parent + basename",
name: "remove pins mount root + parent/basename",
invoke: (bridge: ReturnType<typeof createSandboxFsBridge>) =>
bridge.remove({ filePath: "nested/file.txt" }),
scriptFragment: 'rm -f -- "$2"',
expectedArgs: ["/workspace/nested", "file.txt"],
expectedArgs: ["remove", "/workspace", "nested", "file.txt", "0", "1"],
forbiddenArgs: ["/workspace/nested/file.txt"],
canonicalProbe: "/workspace/nested",
},
{
name: "rename anchors both parents + basenames",
name: "rename pins both parents + basenames",
invoke: (bridge: ReturnType<typeof createSandboxFsBridge>) =>
bridge.rename({ from: "from.txt", to: "nested/to.txt" }),
scriptFragment: 'mv -- "$3" "$2/$4"',
expectedArgs: ["/workspace", "/workspace/nested", "from.txt", "to.txt"],
expectedArgs: ["rename", "/workspace", "", "from.txt", "/workspace", "nested", "to.txt", "1"],
forbiddenArgs: ["/workspace/from.txt", "/workspace/nested/to.txt"],
canonicalProbe: "/workspace/nested",
},
] as const;
it.each(anchoredCases)("$name", async (testCase) => {
const bridge = createSandboxFsBridge({ sandbox: createSandbox() });
it.each(pinnedCases)("$name", async (testCase) => {
await withTempDir("openclaw-fs-bridge-contract-write-", async (stateDir) => {
const workspaceDir = path.join(stateDir, "workspace");
await fs.mkdir(path.join(workspaceDir, "nested"), { recursive: true });
await fs.writeFile(path.join(workspaceDir, "from.txt"), "hello", "utf8");
await fs.writeFile(path.join(workspaceDir, "nested", "file.txt"), "bye", "utf8");
await testCase.invoke(bridge);
const bridge = createSandboxFsBridge({
sandbox: createSandbox({
workspaceDir,
agentWorkspaceDir: workspaceDir,
}),
});
const opCall = findCallByScriptFragment(testCase.scriptFragment);
expect(opCall).toBeDefined();
const args = opCall?.[0] ?? [];
testCase.expectedArgs.forEach((value, index) => {
expect(getDockerArg(args, index + 1)).toBe(value);
await testCase.invoke(bridge);
const opCall = mockedExecDockerRaw.mock.calls.find(
([args]) =>
typeof args[5] === "string" &&
args[5].includes("python3 - \"$@\" <<'PY'") &&
getDockerArg(args, 1) === testCase.expectedArgs[0],
);
expect(opCall).toBeDefined();
const args = opCall?.[0] ?? [];
testCase.expectedArgs.forEach((value, index) => {
expect(getDockerArg(args, index + 1)).toBe(value);
});
testCase.forbiddenArgs.forEach((value) => {
expect(args).not.toContain(value);
});
});
testCase.forbiddenArgs.forEach((value) => {
expect(args).not.toContain(value);
});
const canonicalCalls = findCallsByScriptFragment('readlink -f -- "$cursor"');
expect(
canonicalCalls.some(([callArgs]) => getDockerArg(callArgs, 1) === testCase.canonicalProbe),
).toBe(true);
});
});

View File

@ -6,7 +6,7 @@ import {
createSandbox,
createSandboxFsBridge,
expectMkdirpAllowsExistingDirectory,
getScriptsFromCalls,
findCallByDockerArg,
installFsBridgeTestHarness,
mockedExecDockerRaw,
withTempDir,
@ -55,8 +55,7 @@ describe("sandbox fs bridge boundary validation", () => {
await expect(bridge.mkdirp({ filePath: "memory/kemik" })).rejects.toThrow(
/cannot create directories/i,
);
const scripts = getScriptsFromCalls();
expect(scripts.some((script) => script.includes('mkdir -p -- "$2"'))).toBe(false);
expect(findCallByDockerArg(1, "mkdirp")).toBeUndefined();
});
});
@ -111,7 +110,6 @@ describe("sandbox fs bridge boundary validation", () => {
it("rejects missing files before any docker read command runs", async () => {
const bridge = createSandboxFsBridge({ sandbox: createSandbox() });
await expect(bridge.readFile({ filePath: "a.txt" })).rejects.toThrow(/ENOENT|no such file/i);
const scripts = getScriptsFromCalls();
expect(scripts.some((script) => script.includes('cat -- "$1"'))).toBe(false);
expect(mockedExecDockerRaw).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,89 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { DEFAULT_SANDBOX_IMAGE } from "./constants.js";
import { buildSandboxCreateArgs, execDocker, execDockerRaw } from "./docker.js";
import { createSandboxFsBridge } from "./fs-bridge.js";
import { createSandboxTestContext } from "./test-fixtures.js";
import { appendWorkspaceMountArgs } from "./workspace-mounts.js";
async function sandboxImageReady(): Promise<boolean> {
try {
const dockerVersion = await execDockerRaw(["version"], { allowFailure: true });
if (dockerVersion.code !== 0) {
return false;
}
const pythonCheck = await execDockerRaw(
["run", "--rm", "--entrypoint", "python3", DEFAULT_SANDBOX_IMAGE, "--version"],
{ allowFailure: true },
);
return pythonCheck.code === 0;
} catch {
return false;
}
}
describe("sandbox fs bridge docker e2e", () => {
it.runIf(process.platform !== "win32")(
"writes through docker exec using the pinned mutation helper",
async () => {
if (!(await sandboxImageReady())) {
return;
}
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fsbridge-e2e-"));
const workspaceDir = path.join(stateDir, "workspace");
await fs.mkdir(workspaceDir, { recursive: true });
const suffix = `${process.pid}-${Date.now()}`;
const containerName = `openclaw-fsbridge-${suffix}`.slice(0, 63);
try {
const sandbox = createSandboxTestContext({
overrides: {
workspaceDir,
agentWorkspaceDir: workspaceDir,
containerName,
containerWorkdir: "/workspace",
},
dockerOverrides: {
image: DEFAULT_SANDBOX_IMAGE,
containerPrefix: "openclaw-fsbridge-",
user: "",
},
});
const createArgs = buildSandboxCreateArgs({
name: containerName,
cfg: sandbox.docker,
scopeKey: sandbox.sessionKey,
includeBinds: false,
bindSourceRoots: [workspaceDir],
});
createArgs.push("--workdir", sandbox.containerWorkdir);
appendWorkspaceMountArgs({
args: createArgs,
workspaceDir,
agentWorkspaceDir: workspaceDir,
workdir: sandbox.containerWorkdir,
workspaceAccess: sandbox.workspaceAccess,
});
createArgs.push(sandbox.docker.image, "sleep", "infinity");
await execDocker(createArgs);
await execDocker(["start", containerName]);
const bridge = createSandboxFsBridge({ sandbox });
await bridge.writeFile({ filePath: "nested/hello.txt", data: "from-docker" });
await expect(
fs.readFile(path.join(workspaceDir, "nested", "hello.txt"), "utf8"),
).resolves.toBe("from-docker");
} finally {
await execDocker(["rm", "-f", containerName], { allowFailure: true });
await fs.rm(stateDir, { recursive: true, force: true });
}
},
);
});

View File

@ -45,10 +45,10 @@ describe("sandbox fs bridge shell compatibility", () => {
});
});
it("resolveCanonicalContainerPath script is valid POSIX sh (no do; token)", async () => {
it("path canonicalization recheck script is valid POSIX sh", async () => {
const bridge = createSandboxFsBridge({ sandbox: createSandbox() });
await bridge.mkdirp({ filePath: "nested" });
await bridge.writeFile({ filePath: "b.txt", data: "hello" });
const scripts = getScriptsFromCalls();
const canonicalScript = scripts.find((script) => script.includes("allow_final"));
@ -130,11 +130,37 @@ describe("sandbox fs bridge shell compatibility", () => {
const scripts = getScriptsFromCalls();
expect(scripts.some((script) => script.includes('cat >"$1"'))).toBe(false);
expect(scripts.some((script) => script.includes('cat >"$tmp"'))).toBe(true);
expect(scripts.some((script) => script.includes('mv -f -- "$1" "$2"'))).toBe(true);
expect(scripts.some((script) => script.includes('cat >"$tmp"'))).toBe(false);
expect(scripts.some((script) => script.includes("os.replace("))).toBe(true);
});
it("re-validates target before final rename and cleans temp file on failure", async () => {
it("routes mkdirp, remove, and rename through the pinned mutation helper", async () => {
await withTempDir("openclaw-fs-bridge-shell-write-", async (stateDir) => {
const workspaceDir = path.join(stateDir, "workspace");
await fs.mkdir(path.join(workspaceDir, "nested"), { recursive: true });
await fs.writeFile(path.join(workspaceDir, "a.txt"), "hello", "utf8");
await fs.writeFile(path.join(workspaceDir, "nested", "file.txt"), "bye", "utf8");
const bridge = createSandboxFsBridge({
sandbox: createSandbox({
workspaceDir,
agentWorkspaceDir: workspaceDir,
}),
});
await bridge.mkdirp({ filePath: "nested" });
await bridge.remove({ filePath: "nested/file.txt" });
await bridge.rename({ from: "a.txt", to: "nested/b.txt" });
const scripts = getScriptsFromCalls();
expect(scripts.filter((script) => script.includes("operation = sys.argv[1]")).length).toBe(3);
expect(scripts.some((script) => script.includes('mkdir -p -- "$2"'))).toBe(false);
expect(scripts.some((script) => script.includes('rm -f -- "$2"'))).toBe(false);
expect(scripts.some((script) => script.includes('mv -- "$3" "$2/$4"'))).toBe(false);
});
});
it("re-validates target before the pinned write helper runs", async () => {
const { mockedOpenBoundaryFile } = await import("./fs-bridge.test-helpers.js");
mockedOpenBoundaryFile
.mockImplementationOnce(async () => ({ ok: false, reason: "path" }))
@ -150,8 +176,6 @@ describe("sandbox fs bridge shell compatibility", () => {
);
const scripts = getScriptsFromCalls();
expect(scripts.some((script) => script.includes("mktemp"))).toBe(true);
expect(scripts.some((script) => script.includes('mv -f -- "$1" "$2"'))).toBe(false);
expect(scripts.some((script) => script.includes('rm -f -- "$1"'))).toBe(true);
expect(scripts.some((script) => script.includes("os.replace("))).toBe(false);
});
});

View File

@ -48,6 +48,10 @@ export function findCallByScriptFragment(fragment: string) {
return mockedExecDockerRaw.mock.calls.find(([args]) => getDockerScript(args).includes(fragment));
}
export function findCallByDockerArg(position: number, value: string) {
return mockedExecDockerRaw.mock.calls.find(([args]) => getDockerArg(args, position) === value);
}
export function findCallsByScriptFragment(fragment: string) {
return mockedExecDockerRaw.mock.calls.filter(([args]) =>
getDockerScript(args).includes(fragment),
@ -142,12 +146,16 @@ export async function expectMkdirpAllowsExistingDirectory(params?: {
await expect(bridge.mkdirp({ filePath: "memory/kemik" })).resolves.toBeUndefined();
const mkdirCall = findCallByScriptFragment('mkdir -p -- "$2"');
const mkdirCall = mockedExecDockerRaw.mock.calls.find(
([args]) =>
getDockerScript(args).includes("operation = sys.argv[1]") &&
getDockerArg(args, 1) === "mkdirp",
);
expect(mkdirCall).toBeDefined();
const mkdirParent = mkdirCall ? getDockerArg(mkdirCall[0], 1) : "";
const mkdirBase = mkdirCall ? getDockerArg(mkdirCall[0], 2) : "";
expect(mkdirParent).toBe("/workspace/memory");
expect(mkdirBase).toBe("kemik");
const mountRoot = mkdirCall ? getDockerArg(mkdirCall[0], 2) : "";
const relativePath = mkdirCall ? getDockerArg(mkdirCall[0], 3) : "";
expect(mountRoot).toBe("/workspace");
expect(relativePath).toBe("memory/kemik");
});
}

View File

@ -1,20 +1,18 @@
import fs from "node:fs";
import { execDockerRaw, type ExecDockerRawResult } from "./docker.js";
import { SandboxFsPathGuard } from "./fs-bridge-path-safety.js";
import {
buildMkdirpPlan,
buildRemovePlan,
buildRenamePlan,
buildStatPlan,
buildWriteCommitPlan,
type SandboxFsCommandPlan,
} from "./fs-bridge-shell-command-plans.js";
buildPinnedMkdirpPlan,
buildPinnedRemovePlan,
buildPinnedRenamePlan,
buildPinnedWritePlan,
} from "./fs-bridge-mutation-helper.js";
import { SandboxFsPathGuard } from "./fs-bridge-path-safety.js";
import { buildStatPlan, type SandboxFsCommandPlan } from "./fs-bridge-shell-command-plans.js";
import {
buildSandboxFsMounts,
resolveSandboxFsPathWithMounts,
type SandboxResolvedFsPath,
} from "./fs-paths.js";
import { normalizeContainerPath } from "./path-utils.js";
import type { SandboxContext, SandboxWorkspaceAccess } from "./types.js";
type RunCommandOptions = {
@ -112,33 +110,44 @@ class SandboxFsBridgeImpl implements SandboxFsBridge {
}): Promise<void> {
const target = this.resolveResolvedPath(params);
this.ensureWriteAccess(target, "write files");
await this.pathGuard.assertPathSafety(target, { action: "write files", requireWritable: true });
const writeCheck = {
target,
options: { action: "write files", requireWritable: true } as const,
};
await this.pathGuard.assertPathSafety(target, writeCheck.options);
const buffer = Buffer.isBuffer(params.data)
? params.data
: Buffer.from(params.data, params.encoding ?? "utf8");
const tempPath = await this.writeFileToTempPath({
targetContainerPath: target.containerPath,
mkdir: params.mkdir !== false,
data: buffer,
const pinnedWriteTarget = this.pathGuard.resolvePinnedEntry(target, "write files");
await this.runCheckedCommand({
...buildPinnedWritePlan({
check: writeCheck,
pinned: pinnedWriteTarget,
mkdir: params.mkdir !== false,
}),
stdin: buffer,
signal: params.signal,
});
try {
await this.runCheckedCommand({
...buildWriteCommitPlan(target, tempPath),
signal: params.signal,
});
} catch (error) {
await this.cleanupTempPath(tempPath, params.signal);
throw error;
}
}
async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise<void> {
const target = this.resolveResolvedPath(params);
this.ensureWriteAccess(target, "create directories");
const anchoredTarget = await this.pathGuard.resolveAnchoredSandboxEntry(target);
await this.runPlannedCommand(buildMkdirpPlan(target, anchoredTarget), params.signal);
const mkdirCheck = {
target,
options: {
action: "create directories",
requireWritable: true,
allowedType: "directory",
} as const,
};
await this.runCheckedCommand({
...buildPinnedMkdirpPlan({
check: mkdirCheck,
pinned: this.pathGuard.resolvePinnedDirectoryEntry(target, "create directories"),
}),
signal: params.signal,
});
}
async remove(params: {
@ -150,16 +159,22 @@ class SandboxFsBridgeImpl implements SandboxFsBridge {
}): Promise<void> {
const target = this.resolveResolvedPath(params);
this.ensureWriteAccess(target, "remove files");
const anchoredTarget = await this.pathGuard.resolveAnchoredSandboxEntry(target);
await this.runPlannedCommand(
buildRemovePlan({
target,
anchoredTarget,
const removeCheck = {
target,
options: {
action: "remove files",
requireWritable: true,
} as const,
};
await this.runCheckedCommand({
...buildPinnedRemovePlan({
check: removeCheck,
pinned: this.pathGuard.resolvePinnedEntry(target, "remove files"),
recursive: params.recursive,
force: params.force,
}),
params.signal,
);
signal: params.signal,
});
}
async rename(params: {
@ -172,17 +187,29 @@ class SandboxFsBridgeImpl implements SandboxFsBridge {
const to = this.resolveResolvedPath({ filePath: params.to, cwd: params.cwd });
this.ensureWriteAccess(from, "rename files");
this.ensureWriteAccess(to, "rename files");
const anchoredFrom = await this.pathGuard.resolveAnchoredSandboxEntry(from);
const anchoredTo = await this.pathGuard.resolveAnchoredSandboxEntry(to);
await this.runPlannedCommand(
buildRenamePlan({
from,
to,
anchoredFrom,
anchoredTo,
const fromCheck = {
target: from,
options: {
action: "rename files",
requireWritable: true,
} as const,
};
const toCheck = {
target: to,
options: {
action: "rename files",
requireWritable: true,
} as const,
};
await this.runCheckedCommand({
...buildPinnedRenamePlan({
fromCheck,
toCheck,
from: this.pathGuard.resolvePinnedEntry(from, "rename files"),
to: this.pathGuard.resolvePinnedEntry(to, "rename files"),
}),
params.signal,
);
signal: params.signal,
});
}
async stat(params: {
@ -265,58 +292,6 @@ class SandboxFsBridgeImpl implements SandboxFsBridge {
return await this.runCheckedCommand({ ...plan, signal });
}
private async writeFileToTempPath(params: {
targetContainerPath: string;
mkdir: boolean;
data: Buffer;
signal?: AbortSignal;
}): Promise<string> {
const script = params.mkdir
? [
"set -eu",
'target="$1"',
'dir=$(dirname -- "$target")',
'if [ "$dir" != "." ]; then mkdir -p -- "$dir"; fi',
'base=$(basename -- "$target")',
'tmp=$(mktemp "$dir/.openclaw-write-$base.XXXXXX")',
'cat >"$tmp"',
'printf "%s\\n" "$tmp"',
].join("\n")
: [
"set -eu",
'target="$1"',
'dir=$(dirname -- "$target")',
'base=$(basename -- "$target")',
'tmp=$(mktemp "$dir/.openclaw-write-$base.XXXXXX")',
'cat >"$tmp"',
'printf "%s\\n" "$tmp"',
].join("\n");
const result = await this.runCommand(script, {
args: [params.targetContainerPath],
stdin: params.data,
signal: params.signal,
});
const tempPath = result.stdout.toString("utf8").trim().split(/\r?\n/).at(-1)?.trim();
if (!tempPath || !tempPath.startsWith("/")) {
throw new Error(
`Failed to create temporary sandbox write path for ${params.targetContainerPath}`,
);
}
return normalizeContainerPath(tempPath);
}
private async cleanupTempPath(tempPath: string, signal?: AbortSignal): Promise<void> {
try {
await this.runCommand('set -eu; rm -f -- "$1"', {
args: [tempPath],
signal,
allowFailure: true,
});
} catch {
// Best-effort cleanup only.
}
}
private ensureWriteAccess(target: SandboxResolvedFsPath, action: string) {
if (!allowsWrites(this.sandbox.workspaceAccess) || !target.writable) {
throw new Error(`Sandbox path is read-only; cannot ${action}: ${target.containerPath}`);

View File

@ -0,0 +1,156 @@
import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js";
import type { OpenClawConfig } from "../config/config.js";
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
import { isSubagentSessionKey, parseAgentSessionKey } from "../routing/session-key.js";
import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
export const SUBAGENT_SESSION_ROLES = ["main", "orchestrator", "leaf"] as const;
export type SubagentSessionRole = (typeof SUBAGENT_SESSION_ROLES)[number];
export const SUBAGENT_CONTROL_SCOPES = ["children", "none"] as const;
export type SubagentControlScope = (typeof SUBAGENT_CONTROL_SCOPES)[number];
type SessionCapabilityEntry = {
sessionId?: unknown;
spawnDepth?: unknown;
subagentRole?: unknown;
subagentControlScope?: unknown;
};
function normalizeSessionKey(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
function normalizeSubagentRole(value: unknown): SubagentSessionRole | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim().toLowerCase();
return SUBAGENT_SESSION_ROLES.find((entry) => entry === trimmed);
}
function normalizeSubagentControlScope(value: unknown): SubagentControlScope | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim().toLowerCase();
return SUBAGENT_CONTROL_SCOPES.find((entry) => entry === trimmed);
}
function readSessionStore(storePath: string): Record<string, SessionCapabilityEntry> {
try {
return loadSessionStore(storePath);
} catch {
return {};
}
}
function findEntryBySessionId(
store: Record<string, SessionCapabilityEntry>,
sessionId: string,
): SessionCapabilityEntry | undefined {
const normalizedSessionId = normalizeSessionKey(sessionId);
if (!normalizedSessionId) {
return undefined;
}
for (const entry of Object.values(store)) {
const candidateSessionId = normalizeSessionKey(entry?.sessionId);
if (candidateSessionId === normalizedSessionId) {
return entry;
}
}
return undefined;
}
function resolveSessionCapabilityEntry(params: {
sessionKey: string;
cfg?: OpenClawConfig;
store?: Record<string, SessionCapabilityEntry>;
}): SessionCapabilityEntry | undefined {
if (params.store) {
return params.store[params.sessionKey] ?? findEntryBySessionId(params.store, params.sessionKey);
}
if (!params.cfg) {
return undefined;
}
const parsed = parseAgentSessionKey(params.sessionKey);
if (!parsed?.agentId) {
return undefined;
}
const storePath = resolveStorePath(params.cfg.session?.store, { agentId: parsed.agentId });
const store = readSessionStore(storePath);
return store[params.sessionKey] ?? findEntryBySessionId(store, params.sessionKey);
}
export function resolveSubagentRoleForDepth(params: {
depth: number;
maxSpawnDepth?: number;
}): SubagentSessionRole {
const depth = Number.isInteger(params.depth) ? Math.max(0, params.depth) : 0;
const maxSpawnDepth =
typeof params.maxSpawnDepth === "number" && Number.isFinite(params.maxSpawnDepth)
? Math.max(1, Math.floor(params.maxSpawnDepth))
: DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH;
if (depth <= 0) {
return "main";
}
return depth < maxSpawnDepth ? "orchestrator" : "leaf";
}
export function resolveSubagentControlScopeForRole(
role: SubagentSessionRole,
): SubagentControlScope {
return role === "leaf" ? "none" : "children";
}
export function resolveSubagentCapabilities(params: { depth: number; maxSpawnDepth?: number }) {
const role = resolveSubagentRoleForDepth(params);
const controlScope = resolveSubagentControlScopeForRole(role);
return {
depth: Math.max(0, Math.floor(params.depth)),
role,
controlScope,
canSpawn: role === "main" || role === "orchestrator",
canControlChildren: controlScope === "children",
};
}
export function resolveStoredSubagentCapabilities(
sessionKey: string | undefined | null,
opts?: {
cfg?: OpenClawConfig;
store?: Record<string, SessionCapabilityEntry>;
},
) {
const normalizedSessionKey = normalizeSessionKey(sessionKey);
const maxSpawnDepth =
opts?.cfg?.agents?.defaults?.subagents?.maxSpawnDepth ?? DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH;
const depth = getSubagentDepthFromSessionStore(normalizedSessionKey, {
cfg: opts?.cfg,
store: opts?.store,
});
if (!normalizedSessionKey || !isSubagentSessionKey(normalizedSessionKey)) {
return resolveSubagentCapabilities({ depth, maxSpawnDepth });
}
const entry = resolveSessionCapabilityEntry({
sessionKey: normalizedSessionKey,
cfg: opts?.cfg,
store: opts?.store,
});
const storedRole = normalizeSubagentRole(entry?.subagentRole);
const storedControlScope = normalizeSubagentControlScope(entry?.subagentControlScope);
const fallback = resolveSubagentCapabilities({ depth, maxSpawnDepth });
const role = storedRole ?? fallback.role;
const controlScope = storedControlScope ?? resolveSubagentControlScopeForRole(role);
return {
depth,
role,
controlScope,
canSpawn: role === "main" || role === "orchestrator",
canControlChildren: controlScope === "children",
};
}

View File

@ -0,0 +1,768 @@
import crypto from "node:crypto";
import { clearSessionQueues } from "../auto-reply/reply/queue.js";
import {
resolveSubagentLabel,
resolveSubagentTargetFromRuns,
sortSubagentRuns,
type SubagentTargetResolution,
} from "../auto-reply/reply/subagents-utils.js";
import type { OpenClawConfig } from "../config/config.js";
import type { SessionEntry } from "../config/sessions.js";
import { loadSessionStore, resolveStorePath, updateSessionStore } from "../config/sessions.js";
import { callGateway } from "../gateway/call.js";
import { logVerbose } from "../globals.js";
import {
isSubagentSessionKey,
parseAgentSessionKey,
type ParsedAgentSessionKey,
} from "../routing/session-key.js";
import {
formatDurationCompact,
formatTokenUsageDisplay,
resolveTotalTokens,
truncateLine,
} from "../shared/subagents-format.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js";
import { AGENT_LANE_SUBAGENT } from "./lanes.js";
import { abortEmbeddedPiRun } from "./pi-embedded.js";
import { resolveStoredSubagentCapabilities } from "./subagent-capabilities.js";
import {
clearSubagentRunSteerRestart,
countPendingDescendantRuns,
listSubagentRunsForController,
markSubagentRunTerminated,
markSubagentRunForSteerRestart,
replaceSubagentRunAfterSteer,
type SubagentRunRecord,
} from "./subagent-registry.js";
import {
extractAssistantText,
resolveInternalSessionKey,
resolveMainSessionAlias,
stripToolMessages,
} from "./tools/sessions-helpers.js";
export const DEFAULT_RECENT_MINUTES = 30;
export const MAX_RECENT_MINUTES = 24 * 60;
export const MAX_STEER_MESSAGE_CHARS = 4_000;
export const STEER_RATE_LIMIT_MS = 2_000;
export const STEER_ABORT_SETTLE_TIMEOUT_MS = 5_000;
const steerRateLimit = new Map<string, number>();
export type SessionEntryResolution = {
storePath: string;
entry: SessionEntry | undefined;
};
export type ResolvedSubagentController = {
controllerSessionKey: string;
callerSessionKey: string;
callerIsSubagent: boolean;
controlScope: "children" | "none";
};
export type SubagentListItem = {
index: number;
line: string;
runId: string;
sessionKey: string;
label: string;
task: string;
status: string;
pendingDescendants: number;
runtime: string;
runtimeMs: number;
model?: string;
totalTokens?: number;
startedAt?: number;
endedAt?: number;
};
export type BuiltSubagentList = {
total: number;
active: SubagentListItem[];
recent: SubagentListItem[];
text: string;
};
function resolveStorePathForKey(
cfg: OpenClawConfig,
key: string,
parsed?: ParsedAgentSessionKey | null,
) {
return resolveStorePath(cfg.session?.store, {
agentId: parsed?.agentId,
});
}
export function resolveSessionEntryForKey(params: {
cfg: OpenClawConfig;
key: string;
cache: Map<string, Record<string, SessionEntry>>;
}): SessionEntryResolution {
const parsed = parseAgentSessionKey(params.key);
const storePath = resolveStorePathForKey(params.cfg, params.key, parsed);
let store = params.cache.get(storePath);
if (!store) {
store = loadSessionStore(storePath);
params.cache.set(storePath, store);
}
return {
storePath,
entry: store[params.key],
};
}
export function resolveSubagentController(params: {
cfg: OpenClawConfig;
agentSessionKey?: string;
}): ResolvedSubagentController {
const { mainKey, alias } = resolveMainSessionAlias(params.cfg);
const callerRaw = params.agentSessionKey?.trim() || alias;
const callerSessionKey = resolveInternalSessionKey({
key: callerRaw,
alias,
mainKey,
});
if (!isSubagentSessionKey(callerSessionKey)) {
return {
controllerSessionKey: callerSessionKey,
callerSessionKey,
callerIsSubagent: false,
controlScope: "children",
};
}
const capabilities = resolveStoredSubagentCapabilities(callerSessionKey, {
cfg: params.cfg,
});
return {
controllerSessionKey: callerSessionKey,
callerSessionKey,
callerIsSubagent: true,
controlScope: capabilities.controlScope,
};
}
export function listControlledSubagentRuns(controllerSessionKey: string): SubagentRunRecord[] {
return sortSubagentRuns(listSubagentRunsForController(controllerSessionKey));
}
export function createPendingDescendantCounter() {
const pendingDescendantCache = new Map<string, number>();
return (sessionKey: string) => {
if (pendingDescendantCache.has(sessionKey)) {
return pendingDescendantCache.get(sessionKey) ?? 0;
}
const pending = Math.max(0, countPendingDescendantRuns(sessionKey));
pendingDescendantCache.set(sessionKey, pending);
return pending;
};
}
export function isActiveSubagentRun(
entry: SubagentRunRecord,
pendingDescendantCount: (sessionKey: string) => number,
) {
return !entry.endedAt || pendingDescendantCount(entry.childSessionKey) > 0;
}
function resolveRunStatus(entry: SubagentRunRecord, options?: { pendingDescendants?: number }) {
const pendingDescendants = Math.max(0, options?.pendingDescendants ?? 0);
if (pendingDescendants > 0) {
const childLabel = pendingDescendants === 1 ? "child" : "children";
return `active (waiting on ${pendingDescendants} ${childLabel})`;
}
if (!entry.endedAt) {
return "running";
}
const status = entry.outcome?.status ?? "done";
if (status === "ok") {
return "done";
}
if (status === "error") {
return "failed";
}
return status;
}
function resolveModelRef(entry?: SessionEntry) {
const model = typeof entry?.model === "string" ? entry.model.trim() : "";
const provider = typeof entry?.modelProvider === "string" ? entry.modelProvider.trim() : "";
if (model.includes("/")) {
return model;
}
if (model && provider) {
return `${provider}/${model}`;
}
if (model) {
return model;
}
if (provider) {
return provider;
}
const overrideModel = typeof entry?.modelOverride === "string" ? entry.modelOverride.trim() : "";
const overrideProvider =
typeof entry?.providerOverride === "string" ? entry.providerOverride.trim() : "";
if (overrideModel.includes("/")) {
return overrideModel;
}
if (overrideModel && overrideProvider) {
return `${overrideProvider}/${overrideModel}`;
}
if (overrideModel) {
return overrideModel;
}
return overrideProvider || undefined;
}
function resolveModelDisplay(entry?: SessionEntry, fallbackModel?: string) {
const modelRef = resolveModelRef(entry) || fallbackModel || undefined;
if (!modelRef) {
return "model n/a";
}
const slash = modelRef.lastIndexOf("/");
if (slash >= 0 && slash < modelRef.length - 1) {
return modelRef.slice(slash + 1);
}
return modelRef;
}
function buildListText(params: {
active: Array<{ line: string }>;
recent: Array<{ line: string }>;
recentMinutes: number;
}) {
const lines: string[] = [];
lines.push("active subagents:");
if (params.active.length === 0) {
lines.push("(none)");
} else {
lines.push(...params.active.map((entry) => entry.line));
}
lines.push("");
lines.push(`recent (last ${params.recentMinutes}m):`);
if (params.recent.length === 0) {
lines.push("(none)");
} else {
lines.push(...params.recent.map((entry) => entry.line));
}
return lines.join("\n");
}
export function buildSubagentList(params: {
cfg: OpenClawConfig;
runs: SubagentRunRecord[];
recentMinutes: number;
taskMaxChars?: number;
}): BuiltSubagentList {
const now = Date.now();
const recentCutoff = now - params.recentMinutes * 60_000;
const cache = new Map<string, Record<string, SessionEntry>>();
const pendingDescendantCount = createPendingDescendantCounter();
let index = 1;
const buildListEntry = (entry: SubagentRunRecord, runtimeMs: number) => {
const sessionEntry = resolveSessionEntryForKey({
cfg: params.cfg,
key: entry.childSessionKey,
cache,
}).entry;
const totalTokens = resolveTotalTokens(sessionEntry);
const usageText = formatTokenUsageDisplay(sessionEntry);
const pendingDescendants = pendingDescendantCount(entry.childSessionKey);
const status = resolveRunStatus(entry, {
pendingDescendants,
});
const runtime = formatDurationCompact(runtimeMs);
const label = truncateLine(resolveSubagentLabel(entry), 48);
const task = truncateLine(entry.task.trim(), params.taskMaxChars ?? 72);
const line = `${index}. ${label} (${resolveModelDisplay(sessionEntry, entry.model)}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${task.toLowerCase() !== label.toLowerCase() ? ` - ${task}` : ""}`;
const view: SubagentListItem = {
index,
line,
runId: entry.runId,
sessionKey: entry.childSessionKey,
label,
task,
status,
pendingDescendants,
runtime,
runtimeMs,
model: resolveModelRef(sessionEntry) || entry.model,
totalTokens,
startedAt: entry.startedAt,
...(entry.endedAt ? { endedAt: entry.endedAt } : {}),
};
index += 1;
return view;
};
const active = params.runs
.filter((entry) => isActiveSubagentRun(entry, pendingDescendantCount))
.map((entry) => buildListEntry(entry, now - (entry.startedAt ?? entry.createdAt)));
const recent = params.runs
.filter(
(entry) =>
!isActiveSubagentRun(entry, pendingDescendantCount) &&
!!entry.endedAt &&
(entry.endedAt ?? 0) >= recentCutoff,
)
.map((entry) =>
buildListEntry(entry, (entry.endedAt ?? now) - (entry.startedAt ?? entry.createdAt)),
);
return {
total: params.runs.length,
active,
recent,
text: buildListText({ active, recent, recentMinutes: params.recentMinutes }),
};
}
function ensureControllerOwnsRun(params: {
controller: ResolvedSubagentController;
entry: SubagentRunRecord;
}) {
const owner = params.entry.controllerSessionKey?.trim() || params.entry.requesterSessionKey;
if (owner === params.controller.controllerSessionKey) {
return undefined;
}
return "Subagents can only control runs spawned from their own session.";
}
async function killSubagentRun(params: {
cfg: OpenClawConfig;
entry: SubagentRunRecord;
cache: Map<string, Record<string, SessionEntry>>;
}): Promise<{ killed: boolean; sessionId?: string }> {
if (params.entry.endedAt) {
return { killed: false };
}
const childSessionKey = params.entry.childSessionKey;
const resolved = resolveSessionEntryForKey({
cfg: params.cfg,
key: childSessionKey,
cache: params.cache,
});
const sessionId = resolved.entry?.sessionId;
const aborted = sessionId ? abortEmbeddedPiRun(sessionId) : false;
const cleared = clearSessionQueues([childSessionKey, sessionId]);
if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
logVerbose(
`subagents control kill: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
);
}
if (resolved.entry) {
await updateSessionStore(resolved.storePath, (store) => {
const current = store[childSessionKey];
if (!current) {
return;
}
current.abortedLastRun = true;
current.updatedAt = Date.now();
store[childSessionKey] = current;
});
}
const marked = markSubagentRunTerminated({
runId: params.entry.runId,
childSessionKey,
reason: "killed",
});
const killed = marked > 0 || aborted || cleared.followupCleared > 0 || cleared.laneCleared > 0;
return { killed, sessionId };
}
async function cascadeKillChildren(params: {
cfg: OpenClawConfig;
parentChildSessionKey: string;
cache: Map<string, Record<string, SessionEntry>>;
seenChildSessionKeys?: Set<string>;
}): Promise<{ killed: number; labels: string[] }> {
const childRuns = listSubagentRunsForController(params.parentChildSessionKey);
const seenChildSessionKeys = params.seenChildSessionKeys ?? new Set<string>();
let killed = 0;
const labels: string[] = [];
for (const run of childRuns) {
const childKey = run.childSessionKey?.trim();
if (!childKey || seenChildSessionKeys.has(childKey)) {
continue;
}
seenChildSessionKeys.add(childKey);
if (!run.endedAt) {
const stopResult = await killSubagentRun({
cfg: params.cfg,
entry: run,
cache: params.cache,
});
if (stopResult.killed) {
killed += 1;
labels.push(resolveSubagentLabel(run));
}
}
const cascade = await cascadeKillChildren({
cfg: params.cfg,
parentChildSessionKey: childKey,
cache: params.cache,
seenChildSessionKeys,
});
killed += cascade.killed;
labels.push(...cascade.labels);
}
return { killed, labels };
}
export async function killAllControlledSubagentRuns(params: {
cfg: OpenClawConfig;
controller: ResolvedSubagentController;
runs: SubagentRunRecord[];
}) {
if (params.controller.controlScope !== "children") {
return {
status: "forbidden" as const,
error: "Leaf subagents cannot control other sessions.",
killed: 0,
labels: [],
};
}
const cache = new Map<string, Record<string, SessionEntry>>();
const seenChildSessionKeys = new Set<string>();
const killedLabels: string[] = [];
let killed = 0;
for (const entry of params.runs) {
const childKey = entry.childSessionKey?.trim();
if (!childKey || seenChildSessionKeys.has(childKey)) {
continue;
}
seenChildSessionKeys.add(childKey);
if (!entry.endedAt) {
const stopResult = await killSubagentRun({ cfg: params.cfg, entry, cache });
if (stopResult.killed) {
killed += 1;
killedLabels.push(resolveSubagentLabel(entry));
}
}
const cascade = await cascadeKillChildren({
cfg: params.cfg,
parentChildSessionKey: childKey,
cache,
seenChildSessionKeys,
});
killed += cascade.killed;
killedLabels.push(...cascade.labels);
}
return { status: "ok" as const, killed, labels: killedLabels };
}
export async function killControlledSubagentRun(params: {
cfg: OpenClawConfig;
controller: ResolvedSubagentController;
entry: SubagentRunRecord;
}) {
const ownershipError = ensureControllerOwnsRun({
controller: params.controller,
entry: params.entry,
});
if (ownershipError) {
return {
status: "forbidden" as const,
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
error: ownershipError,
};
}
if (params.controller.controlScope !== "children") {
return {
status: "forbidden" as const,
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
error: "Leaf subagents cannot control other sessions.",
};
}
const killCache = new Map<string, Record<string, SessionEntry>>();
const stopResult = await killSubagentRun({
cfg: params.cfg,
entry: params.entry,
cache: killCache,
});
const seenChildSessionKeys = new Set<string>();
const targetChildKey = params.entry.childSessionKey?.trim();
if (targetChildKey) {
seenChildSessionKeys.add(targetChildKey);
}
const cascade = await cascadeKillChildren({
cfg: params.cfg,
parentChildSessionKey: params.entry.childSessionKey,
cache: killCache,
seenChildSessionKeys,
});
if (!stopResult.killed && cascade.killed === 0) {
return {
status: "done" as const,
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
label: resolveSubagentLabel(params.entry),
text: `${resolveSubagentLabel(params.entry)} is already finished.`,
};
}
const cascadeText =
cascade.killed > 0 ? ` (+ ${cascade.killed} descendant${cascade.killed === 1 ? "" : "s"})` : "";
return {
status: "ok" as const,
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
label: resolveSubagentLabel(params.entry),
cascadeKilled: cascade.killed,
cascadeLabels: cascade.killed > 0 ? cascade.labels : undefined,
text: stopResult.killed
? `killed ${resolveSubagentLabel(params.entry)}${cascadeText}.`
: `killed ${cascade.killed} descendant${cascade.killed === 1 ? "" : "s"} of ${resolveSubagentLabel(params.entry)}.`,
};
}
export async function steerControlledSubagentRun(params: {
cfg: OpenClawConfig;
controller: ResolvedSubagentController;
entry: SubagentRunRecord;
message: string;
}): Promise<
| {
status: "forbidden" | "done" | "rate_limited" | "error";
runId?: string;
sessionKey: string;
sessionId?: string;
error?: string;
text?: string;
}
| {
status: "accepted";
runId: string;
sessionKey: string;
sessionId?: string;
mode: "restart";
label: string;
text: string;
}
> {
const ownershipError = ensureControllerOwnsRun({
controller: params.controller,
entry: params.entry,
});
if (ownershipError) {
return {
status: "forbidden",
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
error: ownershipError,
};
}
if (params.controller.controlScope !== "children") {
return {
status: "forbidden",
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
error: "Leaf subagents cannot control other sessions.",
};
}
if (params.entry.endedAt) {
return {
status: "done",
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
text: `${resolveSubagentLabel(params.entry)} is already finished.`,
};
}
if (params.controller.callerSessionKey === params.entry.childSessionKey) {
return {
status: "forbidden",
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
error: "Subagents cannot steer themselves.",
};
}
const rateKey = `${params.controller.callerSessionKey}:${params.entry.childSessionKey}`;
if (process.env.VITEST !== "true") {
const now = Date.now();
const lastSentAt = steerRateLimit.get(rateKey) ?? 0;
if (now - lastSentAt < STEER_RATE_LIMIT_MS) {
return {
status: "rate_limited",
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
error: "Steer rate limit exceeded. Wait a moment before sending another steer.",
};
}
steerRateLimit.set(rateKey, now);
}
markSubagentRunForSteerRestart(params.entry.runId);
const targetSession = resolveSessionEntryForKey({
cfg: params.cfg,
key: params.entry.childSessionKey,
cache: new Map<string, Record<string, SessionEntry>>(),
});
const sessionId =
typeof targetSession.entry?.sessionId === "string" && targetSession.entry.sessionId.trim()
? targetSession.entry.sessionId.trim()
: undefined;
if (sessionId) {
abortEmbeddedPiRun(sessionId);
}
const cleared = clearSessionQueues([params.entry.childSessionKey, sessionId]);
if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
logVerbose(
`subagents control steer: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
);
}
try {
await callGateway({
method: "agent.wait",
params: {
runId: params.entry.runId,
timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS,
},
timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS + 2_000,
});
} catch {
// Continue even if wait fails; steer should still be attempted.
}
const idempotencyKey = crypto.randomUUID();
let runId: string = idempotencyKey;
try {
const response = await callGateway<{ runId: string }>({
method: "agent",
params: {
message: params.message,
sessionKey: params.entry.childSessionKey,
sessionId,
idempotencyKey,
deliver: false,
channel: INTERNAL_MESSAGE_CHANNEL,
lane: AGENT_LANE_SUBAGENT,
timeout: 0,
},
timeoutMs: 10_000,
});
if (typeof response?.runId === "string" && response.runId) {
runId = response.runId;
}
} catch (err) {
clearSubagentRunSteerRestart(params.entry.runId);
const error = err instanceof Error ? err.message : String(err);
return {
status: "error",
runId,
sessionKey: params.entry.childSessionKey,
sessionId,
error,
};
}
replaceSubagentRunAfterSteer({
previousRunId: params.entry.runId,
nextRunId: runId,
fallback: params.entry,
runTimeoutSeconds: params.entry.runTimeoutSeconds ?? 0,
});
return {
status: "accepted",
runId,
sessionKey: params.entry.childSessionKey,
sessionId,
mode: "restart",
label: resolveSubagentLabel(params.entry),
text: `steered ${resolveSubagentLabel(params.entry)}.`,
};
}
export async function sendControlledSubagentMessage(params: {
cfg: OpenClawConfig;
entry: SubagentRunRecord;
message: string;
}) {
const targetSessionKey = params.entry.childSessionKey;
const parsed = parseAgentSessionKey(targetSessionKey);
const storePath = resolveStorePath(params.cfg.session?.store, { agentId: parsed?.agentId });
const store = loadSessionStore(storePath);
const targetSessionEntry = store[targetSessionKey];
const targetSessionId =
typeof targetSessionEntry?.sessionId === "string" && targetSessionEntry.sessionId.trim()
? targetSessionEntry.sessionId.trim()
: undefined;
const idempotencyKey = crypto.randomUUID();
let runId: string = idempotencyKey;
const response = await callGateway<{ runId: string }>({
method: "agent",
params: {
message: params.message,
sessionKey: targetSessionKey,
sessionId: targetSessionId,
idempotencyKey,
deliver: false,
channel: INTERNAL_MESSAGE_CHANNEL,
lane: AGENT_LANE_SUBAGENT,
timeout: 0,
},
timeoutMs: 10_000,
});
const responseRunId = typeof response?.runId === "string" ? response.runId : undefined;
if (responseRunId) {
runId = responseRunId;
}
const waitMs = 30_000;
const wait = await callGateway<{ status?: string; error?: string }>({
method: "agent.wait",
params: { runId, timeoutMs: waitMs },
timeoutMs: waitMs + 2_000,
});
if (wait?.status === "timeout") {
return { status: "timeout" as const, runId };
}
if (wait?.status === "error") {
const waitError = typeof wait.error === "string" ? wait.error : "unknown error";
return { status: "error" as const, runId, error: waitError };
}
const history = await callGateway<{ messages: Array<unknown> }>({
method: "chat.history",
params: { sessionKey: targetSessionKey, limit: 50 },
});
const filtered = stripToolMessages(Array.isArray(history?.messages) ? history.messages : []);
const last = filtered.length > 0 ? filtered[filtered.length - 1] : undefined;
const replyText = last ? extractAssistantText(last) : undefined;
return { status: "ok" as const, runId, replyText };
}
export function resolveControlledSubagentTarget(
runs: SubagentRunRecord[],
token: string | undefined,
options?: { recentMinutes?: number; isActive?: (entry: SubagentRunRecord) => boolean },
): SubagentTargetResolution {
return resolveSubagentTargetFromRuns({
runs,
token,
recentWindowMinutes: options?.recentMinutes ?? DEFAULT_RECENT_MINUTES,
label: (entry) => resolveSubagentLabel(entry),
isActive: options?.isActive,
errors: {
missingTarget: "Missing subagent target.",
invalidIndex: (value) => `Invalid subagent index: ${value}`,
unknownSession: (value) => `Unknown subagent session: ${value}`,
ambiguousLabel: (value) => `Ambiguous subagent label: ${value}`,
ambiguousLabelPrefix: (value) => `Ambiguous subagent label prefix: ${value}`,
ambiguousRunIdPrefix: (value) => `Ambiguous subagent run id prefix: ${value}`,
unknownTarget: (value) => `Unknown subagent target: ${value}`,
},
});
}

View File

@ -1,6 +1,10 @@
import type { DeliveryContext } from "../utils/delivery-context.js";
import type { SubagentRunRecord } from "./subagent-registry.types.js";
function resolveControllerSessionKey(entry: SubagentRunRecord): string {
return entry.controllerSessionKey?.trim() || entry.requesterSessionKey;
}
export function findRunIdsByChildSessionKeyFromRuns(
runs: Map<string, SubagentRunRecord>,
childSessionKey: string,
@ -51,6 +55,17 @@ export function listRunsForRequesterFromRuns(
});
}
export function listRunsForControllerFromRuns(
runs: Map<string, SubagentRunRecord>,
controllerSessionKey: string,
): SubagentRunRecord[] {
const key = controllerSessionKey.trim();
if (!key) {
return [];
}
return [...runs.values()].filter((entry) => resolveControllerSessionKey(entry) === key);
}
function findLatestRunForChildSession(
runs: Map<string, SubagentRunRecord>,
childSessionKey: string,
@ -104,9 +119,9 @@ export function shouldIgnorePostCompletionAnnounceForSessionFromRuns(
export function countActiveRunsForSessionFromRuns(
runs: Map<string, SubagentRunRecord>,
requesterSessionKey: string,
controllerSessionKey: string,
): number {
const key = requesterSessionKey.trim();
const key = controllerSessionKey.trim();
if (!key) {
return 0;
}
@ -123,7 +138,7 @@ export function countActiveRunsForSessionFromRuns(
let count = 0;
for (const entry of runs.values()) {
if (entry.requesterSessionKey !== key) {
if (resolveControllerSessionKey(entry) !== key) {
continue;
}
if (typeof entry.endedAt !== "number") {

View File

@ -45,6 +45,7 @@ import {
countPendingDescendantRunsExcludingRunFromRuns,
countPendingDescendantRunsFromRuns,
findRunIdsByChildSessionKeyFromRuns,
listRunsForControllerFromRuns,
listDescendantRunsForRequesterFromRuns,
listRunsForRequesterFromRuns,
resolveRequesterForChildSessionFromRuns,
@ -1146,6 +1147,7 @@ export function replaceSubagentRunAfterSteer(params: {
export function registerSubagentRun(params: {
runId: string;
childSessionKey: string;
controllerSessionKey?: string;
requesterSessionKey: string;
requesterOrigin?: DeliveryContext;
requesterDisplayKey: string;
@ -1173,6 +1175,7 @@ export function registerSubagentRun(params: {
subagentRuns.set(params.runId, {
runId: params.runId,
childSessionKey: params.childSessionKey,
controllerSessionKey: params.controllerSessionKey ?? params.requesterSessionKey,
requesterSessionKey: params.requesterSessionKey,
requesterOrigin,
requesterDisplayKey: params.requesterDisplayKey,
@ -1419,6 +1422,13 @@ export function listSubagentRunsForRequester(
return listRunsForRequesterFromRuns(subagentRuns, requesterSessionKey, options);
}
export function listSubagentRunsForController(controllerSessionKey: string): SubagentRunRecord[] {
return listRunsForControllerFromRuns(
getSubagentRunsSnapshotForRead(subagentRuns),
controllerSessionKey,
);
}
export function countActiveRunsForSession(requesterSessionKey: string): number {
return countActiveRunsForSessionFromRuns(
getSubagentRunsSnapshotForRead(subagentRuns),

View File

@ -6,6 +6,7 @@ import type { SpawnSubagentMode } from "./subagent-spawn.js";
export type SubagentRunRecord = {
runId: string;
childSessionKey: string;
controllerSessionKey?: string;
requesterSessionKey: string;
requesterOrigin?: DeliveryContext;
requesterDisplayKey: string;

View File

@ -27,6 +27,7 @@ import {
materializeSubagentAttachments,
type SubagentAttachmentReceiptFile,
} from "./subagent-attachments.js";
import { resolveSubagentCapabilities } from "./subagent-capabilities.js";
import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
import { countActiveRunsForSession, registerSubagentRun } from "./subagent-registry.js";
import { readStringParam } from "./tools/common.js";
@ -376,6 +377,10 @@ export async function spawnSubagentDirect(
}
const childDepth = callerDepth + 1;
const spawnedByKey = requesterInternalKey;
const childCapabilities = resolveSubagentCapabilities({
depth: childDepth,
maxSpawnDepth,
});
const targetAgentConfig = resolveAgentConfig(cfg, targetAgentId);
const resolvedModel = resolveSubagentSpawnModelSelection({
cfg,
@ -414,7 +419,11 @@ export async function spawnSubagentDirect(
}
};
const spawnDepthPatchError = await patchChildSession({ spawnDepth: childDepth });
const spawnDepthPatchError = await patchChildSession({
spawnDepth: childDepth,
subagentRole: childCapabilities.role === "main" ? null : childCapabilities.role,
subagentControlScope: childCapabilities.controlScope,
});
if (spawnDepthPatchError) {
return {
status: "error",
@ -643,6 +652,7 @@ export async function spawnSubagentDirect(
registerSubagentRun({
runId: childRunId,
childSessionKey,
controllerSessionKey: requesterInternalKey,
requesterSessionKey: requesterInternalKey,
requesterOrigin,
requesterDisplayKey,

View File

@ -97,11 +97,11 @@ describe("createNodesTool screen_record duration guardrails", () => {
if (payload?.command === "system.run.prepare") {
return {
payload: {
cmdText: "echo hi",
plan: {
argv: ["bash", "-lc", "echo hi"],
cwd: null,
rawCommand: null,
commandText: 'bash -lc "echo hi"',
commandPreview: "echo hi",
agentId: null,
sessionKey: null,
},

View File

@ -664,7 +664,7 @@ export function createNodesTool(options?: {
}
const runParams = {
command: prepared.plan.argv,
rawCommand: prepared.plan.rawCommand ?? prepared.cmdText,
rawCommand: prepared.plan.commandText,
cwd: prepared.plan.cwd ?? cwd,
env,
timeoutMs: commandTimeoutMs,
@ -699,8 +699,6 @@ export function createNodesTool(options?: {
{ ...gatewayOpts, timeoutMs: APPROVAL_TIMEOUT_MS + 5_000 },
{
id: approvalId,
command: prepared.cmdText,
commandArgv: prepared.plan.argv,
systemRunPlan: prepared.plan,
cwd: prepared.plan.cwd ?? cwd,
nodeId,

View File

@ -1,58 +1,26 @@
import crypto from "node:crypto";
import { Type } from "@sinclair/typebox";
import { clearSessionQueues } from "../../auto-reply/reply/queue.js";
import {
resolveSubagentLabel,
resolveSubagentTargetFromRuns,
sortSubagentRuns,
type SubagentTargetResolution,
} from "../../auto-reply/reply/subagents-utils.js";
import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../../config/agent-limits.js";
import { loadConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { loadSessionStore, resolveStorePath, updateSessionStore } from "../../config/sessions.js";
import { callGateway } from "../../gateway/call.js";
import { logVerbose } from "../../globals.js";
import {
isSubagentSessionKey,
parseAgentSessionKey,
type ParsedAgentSessionKey,
} from "../../routing/session-key.js";
import {
formatDurationCompact,
formatTokenUsageDisplay,
resolveTotalTokens,
truncateLine,
} from "../../shared/subagents-format.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
import { AGENT_LANE_SUBAGENT } from "../lanes.js";
import { abortEmbeddedPiRun } from "../pi-embedded.js";
import { optionalStringEnum } from "../schema/typebox.js";
import { getSubagentDepthFromSessionStore } from "../subagent-depth.js";
import {
clearSubagentRunSteerRestart,
countPendingDescendantRuns,
listSubagentRunsForRequester,
markSubagentRunTerminated,
markSubagentRunForSteerRestart,
replaceSubagentRunAfterSteer,
type SubagentRunRecord,
} from "../subagent-registry.js";
buildSubagentList,
DEFAULT_RECENT_MINUTES,
isActiveSubagentRun,
killAllControlledSubagentRuns,
killControlledSubagentRun,
listControlledSubagentRuns,
MAX_RECENT_MINUTES,
MAX_STEER_MESSAGE_CHARS,
resolveControlledSubagentTarget,
resolveSubagentController,
steerControlledSubagentRun,
createPendingDescendantCounter,
} from "../subagent-control.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
const SUBAGENT_ACTIONS = ["list", "kill", "steer"] as const;
type SubagentAction = (typeof SUBAGENT_ACTIONS)[number];
const DEFAULT_RECENT_MINUTES = 30;
const MAX_RECENT_MINUTES = 24 * 60;
const MAX_STEER_MESSAGE_CHARS = 4_000;
const STEER_RATE_LIMIT_MS = 2_000;
const STEER_ABORT_SETTLE_TIMEOUT_MS = 5_000;
const steerRateLimit = new Map<string, number>();
const SubagentsToolSchema = Type.Object({
action: optionalStringEnum(SUBAGENT_ACTIONS),
target: Type.Optional(Type.String()),
@ -60,292 +28,6 @@ const SubagentsToolSchema = Type.Object({
recentMinutes: Type.Optional(Type.Number({ minimum: 1 })),
});
type SessionEntryResolution = {
storePath: string;
entry: SessionEntry | undefined;
};
type ResolvedRequesterKey = {
requesterSessionKey: string;
callerSessionKey: string;
callerIsSubagent: boolean;
};
function resolveRunStatus(entry: SubagentRunRecord, options?: { pendingDescendants?: number }) {
const pendingDescendants = Math.max(0, options?.pendingDescendants ?? 0);
if (pendingDescendants > 0) {
const childLabel = pendingDescendants === 1 ? "child" : "children";
return `active (waiting on ${pendingDescendants} ${childLabel})`;
}
if (!entry.endedAt) {
return "running";
}
const status = entry.outcome?.status ?? "done";
if (status === "ok") {
return "done";
}
if (status === "error") {
return "failed";
}
return status;
}
function resolveModelRef(entry?: SessionEntry) {
const model = typeof entry?.model === "string" ? entry.model.trim() : "";
const provider = typeof entry?.modelProvider === "string" ? entry.modelProvider.trim() : "";
if (model.includes("/")) {
return model;
}
if (model && provider) {
return `${provider}/${model}`;
}
if (model) {
return model;
}
if (provider) {
return provider;
}
// Fall back to override fields which are populated at spawn time,
// before the first run completes and writes model/modelProvider.
const overrideModel = typeof entry?.modelOverride === "string" ? entry.modelOverride.trim() : "";
const overrideProvider =
typeof entry?.providerOverride === "string" ? entry.providerOverride.trim() : "";
if (overrideModel.includes("/")) {
return overrideModel;
}
if (overrideModel && overrideProvider) {
return `${overrideProvider}/${overrideModel}`;
}
if (overrideModel) {
return overrideModel;
}
return overrideProvider || undefined;
}
function resolveModelDisplay(entry?: SessionEntry, fallbackModel?: string) {
const modelRef = resolveModelRef(entry) || fallbackModel || undefined;
if (!modelRef) {
return "model n/a";
}
const slash = modelRef.lastIndexOf("/");
if (slash >= 0 && slash < modelRef.length - 1) {
return modelRef.slice(slash + 1);
}
return modelRef;
}
function resolveSubagentTarget(
runs: SubagentRunRecord[],
token: string | undefined,
options?: { recentMinutes?: number; isActive?: (entry: SubagentRunRecord) => boolean },
): SubagentTargetResolution {
return resolveSubagentTargetFromRuns({
runs,
token,
recentWindowMinutes: options?.recentMinutes ?? DEFAULT_RECENT_MINUTES,
label: (entry) => resolveSubagentLabel(entry),
isActive: options?.isActive,
errors: {
missingTarget: "Missing subagent target.",
invalidIndex: (value) => `Invalid subagent index: ${value}`,
unknownSession: (value) => `Unknown subagent session: ${value}`,
ambiguousLabel: (value) => `Ambiguous subagent label: ${value}`,
ambiguousLabelPrefix: (value) => `Ambiguous subagent label prefix: ${value}`,
ambiguousRunIdPrefix: (value) => `Ambiguous subagent run id prefix: ${value}`,
unknownTarget: (value) => `Unknown subagent target: ${value}`,
},
});
}
function resolveStorePathForKey(
cfg: ReturnType<typeof loadConfig>,
key: string,
parsed?: ParsedAgentSessionKey | null,
) {
return resolveStorePath(cfg.session?.store, {
agentId: parsed?.agentId,
});
}
function resolveSessionEntryForKey(params: {
cfg: ReturnType<typeof loadConfig>;
key: string;
cache: Map<string, Record<string, SessionEntry>>;
}): SessionEntryResolution {
const parsed = parseAgentSessionKey(params.key);
const storePath = resolveStorePathForKey(params.cfg, params.key, parsed);
let store = params.cache.get(storePath);
if (!store) {
store = loadSessionStore(storePath);
params.cache.set(storePath, store);
}
return {
storePath,
entry: store[params.key],
};
}
function resolveRequesterKey(params: {
cfg: ReturnType<typeof loadConfig>;
agentSessionKey?: string;
}): ResolvedRequesterKey {
const { mainKey, alias } = resolveMainSessionAlias(params.cfg);
const callerRaw = params.agentSessionKey?.trim() || alias;
const callerSessionKey = resolveInternalSessionKey({
key: callerRaw,
alias,
mainKey,
});
if (!isSubagentSessionKey(callerSessionKey)) {
return {
requesterSessionKey: callerSessionKey,
callerSessionKey,
callerIsSubagent: false,
};
}
// Check if this sub-agent can spawn children (orchestrator).
// If so, it should see its own children, not its parent's children.
const callerDepth = getSubagentDepthFromSessionStore(callerSessionKey, { cfg: params.cfg });
const maxSpawnDepth =
params.cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH;
if (callerDepth < maxSpawnDepth) {
// Orchestrator sub-agent: use its own session key as requester
// so it sees children it spawned.
return {
requesterSessionKey: callerSessionKey,
callerSessionKey,
callerIsSubagent: true,
};
}
// Leaf sub-agent: walk up to its parent so it can see sibling runs.
const cache = new Map<string, Record<string, SessionEntry>>();
const callerEntry = resolveSessionEntryForKey({
cfg: params.cfg,
key: callerSessionKey,
cache,
}).entry;
const spawnedBy = typeof callerEntry?.spawnedBy === "string" ? callerEntry.spawnedBy.trim() : "";
return {
requesterSessionKey: spawnedBy || callerSessionKey,
callerSessionKey,
callerIsSubagent: true,
};
}
async function killSubagentRun(params: {
cfg: ReturnType<typeof loadConfig>;
entry: SubagentRunRecord;
cache: Map<string, Record<string, SessionEntry>>;
}): Promise<{ killed: boolean; sessionId?: string }> {
if (params.entry.endedAt) {
return { killed: false };
}
const childSessionKey = params.entry.childSessionKey;
const resolved = resolveSessionEntryForKey({
cfg: params.cfg,
key: childSessionKey,
cache: params.cache,
});
const sessionId = resolved.entry?.sessionId;
const aborted = sessionId ? abortEmbeddedPiRun(sessionId) : false;
const cleared = clearSessionQueues([childSessionKey, sessionId]);
if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
logVerbose(
`subagents tool kill: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
);
}
if (resolved.entry) {
await updateSessionStore(resolved.storePath, (store) => {
const current = store[childSessionKey];
if (!current) {
return;
}
current.abortedLastRun = true;
current.updatedAt = Date.now();
store[childSessionKey] = current;
});
}
const marked = markSubagentRunTerminated({
runId: params.entry.runId,
childSessionKey,
reason: "killed",
});
const killed = marked > 0 || aborted || cleared.followupCleared > 0 || cleared.laneCleared > 0;
return { killed, sessionId };
}
/**
* Recursively kill all descendant subagent runs spawned by a given parent session key.
* This ensures that when a subagent is killed, all of its children (and their children) are also killed.
*/
async function cascadeKillChildren(params: {
cfg: ReturnType<typeof loadConfig>;
parentChildSessionKey: string;
cache: Map<string, Record<string, SessionEntry>>;
seenChildSessionKeys?: Set<string>;
}): Promise<{ killed: number; labels: string[] }> {
const childRuns = listSubagentRunsForRequester(params.parentChildSessionKey);
const seenChildSessionKeys = params.seenChildSessionKeys ?? new Set<string>();
let killed = 0;
const labels: string[] = [];
for (const run of childRuns) {
const childKey = run.childSessionKey?.trim();
if (!childKey || seenChildSessionKeys.has(childKey)) {
continue;
}
seenChildSessionKeys.add(childKey);
if (!run.endedAt) {
const stopResult = await killSubagentRun({
cfg: params.cfg,
entry: run,
cache: params.cache,
});
if (stopResult.killed) {
killed += 1;
labels.push(resolveSubagentLabel(run));
}
}
// Recurse for grandchildren even if this parent already ended.
const cascade = await cascadeKillChildren({
cfg: params.cfg,
parentChildSessionKey: childKey,
cache: params.cache,
seenChildSessionKeys,
});
killed += cascade.killed;
labels.push(...cascade.labels);
}
return { killed, labels };
}
function buildListText(params: {
active: Array<{ line: string }>;
recent: Array<{ line: string }>;
recentMinutes: number;
}) {
const lines: string[] = [];
lines.push("active subagents:");
if (params.active.length === 0) {
lines.push("(none)");
} else {
lines.push(...params.active.map((entry) => entry.line));
}
lines.push("");
lines.push(`recent (last ${params.recentMinutes}m):`);
if (params.recent.length === 0) {
lines.push("(none)");
} else {
lines.push(...params.recent.map((entry) => entry.line));
}
return lines.join("\n");
}
export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAgentTool {
return {
label: "Subagents",
@ -357,139 +39,69 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge
const params = args as Record<string, unknown>;
const action = (readStringParam(params, "action") ?? "list") as SubagentAction;
const cfg = loadConfig();
const requester = resolveRequesterKey({
const controller = resolveSubagentController({
cfg,
agentSessionKey: opts?.agentSessionKey,
});
const runs = sortSubagentRuns(listSubagentRunsForRequester(requester.requesterSessionKey));
const runs = listControlledSubagentRuns(controller.controllerSessionKey);
const recentMinutesRaw = readNumberParam(params, "recentMinutes");
const recentMinutes = recentMinutesRaw
? Math.max(1, Math.min(MAX_RECENT_MINUTES, Math.floor(recentMinutesRaw)))
: DEFAULT_RECENT_MINUTES;
const pendingDescendantCache = new Map<string, number>();
const pendingDescendantCount = (sessionKey: string) => {
if (pendingDescendantCache.has(sessionKey)) {
return pendingDescendantCache.get(sessionKey) ?? 0;
}
const pending = Math.max(0, countPendingDescendantRuns(sessionKey));
pendingDescendantCache.set(sessionKey, pending);
return pending;
};
const isActiveRun = (entry: SubagentRunRecord) =>
!entry.endedAt || pendingDescendantCount(entry.childSessionKey) > 0;
const pendingDescendantCount = createPendingDescendantCounter();
const isActive = (entry: (typeof runs)[number]) =>
isActiveSubagentRun(entry, pendingDescendantCount);
if (action === "list") {
const now = Date.now();
const recentCutoff = now - recentMinutes * 60_000;
const cache = new Map<string, Record<string, SessionEntry>>();
let index = 1;
const buildListEntry = (entry: SubagentRunRecord, runtimeMs: number) => {
const sessionEntry = resolveSessionEntryForKey({
cfg,
key: entry.childSessionKey,
cache,
}).entry;
const totalTokens = resolveTotalTokens(sessionEntry);
const usageText = formatTokenUsageDisplay(sessionEntry);
const pendingDescendants = pendingDescendantCount(entry.childSessionKey);
const status = resolveRunStatus(entry, {
pendingDescendants,
});
const runtime = formatDurationCompact(runtimeMs);
const label = truncateLine(resolveSubagentLabel(entry), 48);
const task = truncateLine(entry.task.trim(), 72);
const line = `${index}. ${label} (${resolveModelDisplay(sessionEntry, entry.model)}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${task.toLowerCase() !== label.toLowerCase() ? ` - ${task}` : ""}`;
const baseView = {
index,
runId: entry.runId,
sessionKey: entry.childSessionKey,
label,
task,
status,
pendingDescendants,
runtime,
runtimeMs,
model: resolveModelRef(sessionEntry) || entry.model,
totalTokens,
startedAt: entry.startedAt,
};
index += 1;
return { line, view: entry.endedAt ? { ...baseView, endedAt: entry.endedAt } : baseView };
};
const active = runs
.filter((entry) => isActiveRun(entry))
.map((entry) => buildListEntry(entry, now - (entry.startedAt ?? entry.createdAt)));
const recent = runs
.filter(
(entry) =>
!isActiveRun(entry) && !!entry.endedAt && (entry.endedAt ?? 0) >= recentCutoff,
)
.map((entry) =>
buildListEntry(entry, (entry.endedAt ?? now) - (entry.startedAt ?? entry.createdAt)),
);
const text = buildListText({ active, recent, recentMinutes });
const list = buildSubagentList({
cfg,
runs,
recentMinutes,
});
return jsonResult({
status: "ok",
action: "list",
requesterSessionKey: requester.requesterSessionKey,
callerSessionKey: requester.callerSessionKey,
callerIsSubagent: requester.callerIsSubagent,
total: runs.length,
active: active.map((entry) => entry.view),
recent: recent.map((entry) => entry.view),
text,
requesterSessionKey: controller.controllerSessionKey,
callerSessionKey: controller.callerSessionKey,
callerIsSubagent: controller.callerIsSubagent,
total: list.total,
active: list.active.map(({ line: _line, ...view }) => view),
recent: list.recent.map(({ line: _line, ...view }) => view),
text: list.text,
});
}
if (action === "kill") {
const target = readStringParam(params, "target", { required: true });
if (target === "all" || target === "*") {
const cache = new Map<string, Record<string, SessionEntry>>();
const seenChildSessionKeys = new Set<string>();
const killedLabels: string[] = [];
let killed = 0;
for (const entry of runs) {
const childKey = entry.childSessionKey?.trim();
if (!childKey || seenChildSessionKeys.has(childKey)) {
continue;
}
seenChildSessionKeys.add(childKey);
if (!entry.endedAt) {
const stopResult = await killSubagentRun({ cfg, entry, cache });
if (stopResult.killed) {
killed += 1;
killedLabels.push(resolveSubagentLabel(entry));
}
}
// Traverse descendants even when the direct run is already finished.
const cascade = await cascadeKillChildren({
cfg,
parentChildSessionKey: childKey,
cache,
seenChildSessionKeys,
const result = await killAllControlledSubagentRuns({
cfg,
controller,
runs,
});
if (result.status === "forbidden") {
return jsonResult({
status: "forbidden",
action: "kill",
target: "all",
error: result.error,
});
killed += cascade.killed;
killedLabels.push(...cascade.labels);
}
return jsonResult({
status: "ok",
action: "kill",
target: "all",
killed,
labels: killedLabels,
killed: result.killed,
labels: result.labels,
text:
killed > 0
? `killed ${killed} subagent${killed === 1 ? "" : "s"}.`
result.killed > 0
? `killed ${result.killed} subagent${result.killed === 1 ? "" : "s"}.`
: "no running subagents to kill.",
});
}
const resolved = resolveSubagentTarget(runs, target, {
const resolved = resolveControlledSubagentTarget(runs, target, {
recentMinutes,
isActive: isActiveRun,
isActive,
});
if (!resolved.entry) {
return jsonResult({
@ -499,52 +111,25 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge
error: resolved.error ?? "Unknown subagent target.",
});
}
const killCache = new Map<string, Record<string, SessionEntry>>();
const stopResult = await killSubagentRun({
const result = await killControlledSubagentRun({
cfg,
controller,
entry: resolved.entry,
cache: killCache,
});
const seenChildSessionKeys = new Set<string>();
const targetChildKey = resolved.entry.childSessionKey?.trim();
if (targetChildKey) {
seenChildSessionKeys.add(targetChildKey);
}
// Traverse descendants even when the selected run is already finished.
const cascade = await cascadeKillChildren({
cfg,
parentChildSessionKey: resolved.entry.childSessionKey,
cache: killCache,
seenChildSessionKeys,
});
if (!stopResult.killed && cascade.killed === 0) {
return jsonResult({
status: "done",
action: "kill",
target,
runId: resolved.entry.runId,
sessionKey: resolved.entry.childSessionKey,
text: `${resolveSubagentLabel(resolved.entry)} is already finished.`,
});
}
const cascadeText =
cascade.killed > 0
? ` (+ ${cascade.killed} descendant${cascade.killed === 1 ? "" : "s"})`
: "";
return jsonResult({
status: "ok",
status: result.status,
action: "kill",
target,
runId: resolved.entry.runId,
sessionKey: resolved.entry.childSessionKey,
label: resolveSubagentLabel(resolved.entry),
cascadeKilled: cascade.killed,
cascadeLabels: cascade.killed > 0 ? cascade.labels : undefined,
text: stopResult.killed
? `killed ${resolveSubagentLabel(resolved.entry)}${cascadeText}.`
: `killed ${cascade.killed} descendant${cascade.killed === 1 ? "" : "s"} of ${resolveSubagentLabel(resolved.entry)}.`,
runId: result.runId,
sessionKey: result.sessionKey,
label: result.label,
cascadeKilled: "cascadeKilled" in result ? result.cascadeKilled : undefined,
cascadeLabels: "cascadeLabels" in result ? result.cascadeLabels : undefined,
error: "error" in result ? result.error : undefined,
text: result.text,
});
}
if (action === "steer") {
const target = readStringParam(params, "target", { required: true });
const message = readStringParam(params, "message", { required: true });
@ -556,9 +141,9 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge
error: `Message too long (${message.length} chars, max ${MAX_STEER_MESSAGE_CHARS}).`,
});
}
const resolved = resolveSubagentTarget(runs, target, {
const resolved = resolveControlledSubagentTarget(runs, target, {
recentMinutes,
isActive: isActiveRun,
isActive,
});
if (!resolved.entry) {
return jsonResult({
@ -568,140 +153,26 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge
error: resolved.error ?? "Unknown subagent target.",
});
}
if (resolved.entry.endedAt) {
return jsonResult({
status: "done",
action: "steer",
target,
runId: resolved.entry.runId,
sessionKey: resolved.entry.childSessionKey,
text: `${resolveSubagentLabel(resolved.entry)} is already finished.`,
});
}
if (
requester.callerIsSubagent &&
requester.callerSessionKey === resolved.entry.childSessionKey
) {
return jsonResult({
status: "forbidden",
action: "steer",
target,
runId: resolved.entry.runId,
sessionKey: resolved.entry.childSessionKey,
error: "Subagents cannot steer themselves.",
});
}
const rateKey = `${requester.callerSessionKey}:${resolved.entry.childSessionKey}`;
const now = Date.now();
const lastSentAt = steerRateLimit.get(rateKey) ?? 0;
if (now - lastSentAt < STEER_RATE_LIMIT_MS) {
return jsonResult({
status: "rate_limited",
action: "steer",
target,
runId: resolved.entry.runId,
sessionKey: resolved.entry.childSessionKey,
error: "Steer rate limit exceeded. Wait a moment before sending another steer.",
});
}
steerRateLimit.set(rateKey, now);
// Suppress announce for the interrupted run before aborting so we don't
// emit stale pre-steer findings if the run exits immediately.
markSubagentRunForSteerRestart(resolved.entry.runId);
const targetSession = resolveSessionEntryForKey({
const result = await steerControlledSubagentRun({
cfg,
key: resolved.entry.childSessionKey,
cache: new Map<string, Record<string, SessionEntry>>(),
controller,
entry: resolved.entry,
message,
});
const sessionId =
typeof targetSession.entry?.sessionId === "string" && targetSession.entry.sessionId.trim()
? targetSession.entry.sessionId.trim()
: undefined;
// Interrupt current work first so steer takes precedence immediately.
if (sessionId) {
abortEmbeddedPiRun(sessionId);
}
const cleared = clearSessionQueues([resolved.entry.childSessionKey, sessionId]);
if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
logVerbose(
`subagents tool steer: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
);
}
// Best effort: wait for the interrupted run to settle so the steer
// message appends onto the existing conversation context.
try {
await callGateway({
method: "agent.wait",
params: {
runId: resolved.entry.runId,
timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS,
},
timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS + 2_000,
});
} catch {
// Continue even if wait fails; steer should still be attempted.
}
const idempotencyKey = crypto.randomUUID();
let runId: string = idempotencyKey;
try {
const response = await callGateway<{ runId: string }>({
method: "agent",
params: {
message,
sessionKey: resolved.entry.childSessionKey,
sessionId,
idempotencyKey,
deliver: false,
channel: INTERNAL_MESSAGE_CHANNEL,
lane: AGENT_LANE_SUBAGENT,
timeout: 0,
},
timeoutMs: 10_000,
});
if (typeof response?.runId === "string" && response.runId) {
runId = response.runId;
}
} catch (err) {
// Replacement launch failed; restore normal announce behavior for the
// original run so completion is not silently suppressed.
clearSubagentRunSteerRestart(resolved.entry.runId);
const error = err instanceof Error ? err.message : String(err);
return jsonResult({
status: "error",
action: "steer",
target,
runId,
sessionKey: resolved.entry.childSessionKey,
sessionId,
error,
});
}
replaceSubagentRunAfterSteer({
previousRunId: resolved.entry.runId,
nextRunId: runId,
fallback: resolved.entry,
runTimeoutSeconds: resolved.entry.runTimeoutSeconds ?? 0,
});
return jsonResult({
status: "accepted",
status: result.status,
action: "steer",
target,
runId,
sessionKey: resolved.entry.childSessionKey,
sessionId,
mode: "restart",
label: resolveSubagentLabel(resolved.entry),
text: `steered ${resolveSubagentLabel(resolved.entry)}.`,
runId: result.runId,
sessionKey: result.sessionKey,
sessionId: result.sessionId,
mode: "mode" in result ? result.mode : undefined,
label: "label" in result ? result.label : undefined,
error: "error" in result ? result.error : undefined,
text: result.text,
});
}
return jsonResult({
status: "error",
error: "Unsupported action.",

View File

@ -114,7 +114,7 @@ describe("web_fetch Cloudflare Markdown for Agents", () => {
sandboxed: false,
runtimeFirecrawl: {
active: false,
apiKeySource: "secretRef",
apiKeySource: "secretRef", // pragma: allowlist secret
diagnostics: [],
},
});

View File

@ -652,7 +652,7 @@ describe("web_search Perplexity lazy resolution", () => {
web: {
search: {
provider: "gemini",
gemini: { apiKey: "gemini-config-test" },
gemini: { apiKey: "gemini-config-test" }, // pragma: allowlist secret
perplexity: perplexityConfig as { apiKey?: string; baseUrl?: string; model?: string },
},
},

View File

@ -2,7 +2,7 @@ import { getAcpSessionManager } from "../../acp/control-plane/manager.js";
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js";
import {
listSubagentRunsForRequester,
listSubagentRunsForController,
markSubagentRunTerminated,
} from "../../agents/subagent-registry.js";
import {
@ -222,7 +222,7 @@ export function stopSubagentsForRequester(params: {
if (!requesterKey) {
return { stopped: 0 };
}
const runs = listSubagentRunsForRequester(requesterKey);
const runs = listSubagentRunsForController(requesterKey);
if (runs.length === 0) {
return { stopped: 0 };
}

View File

@ -1,5 +1,10 @@
import { getChannelDock } from "../../channels/dock.js";
import { resolveChannelConfigWrites } from "../../channels/plugins/config-writes.js";
import {
authorizeConfigWrite,
canBypassConfigWritePolicy,
formatConfigWriteDeniedMessage,
resolveExplicitConfigWriteTarget,
} from "../../channels/plugins/config-writes.js";
import { listPairingChannels } from "../../channels/plugins/pairing.js";
import type { ChannelId } from "../../channels/plugins/types.js";
import { normalizeChannelId } from "../../channels/registry.js";
@ -231,12 +236,22 @@ function resolveAccountTarget(
const channel = (channels[channelId] ??= {}) as Record<string, unknown>;
const normalizedAccountId = normalizeAccountId(accountId);
if (isBlockedObjectKey(normalizedAccountId)) {
return { target: channel, pathPrefix: `channels.${channelId}`, accountId: DEFAULT_ACCOUNT_ID };
return {
target: channel,
pathPrefix: `channels.${channelId}`,
accountId: DEFAULT_ACCOUNT_ID,
writeTarget: resolveExplicitConfigWriteTarget({ channelId }),
};
}
const hasAccounts = Boolean(channel.accounts && typeof channel.accounts === "object");
const useAccount = normalizedAccountId !== DEFAULT_ACCOUNT_ID || hasAccounts;
if (!useAccount) {
return { target: channel, pathPrefix: `channels.${channelId}`, accountId: normalizedAccountId };
return {
target: channel,
pathPrefix: `channels.${channelId}`,
accountId: normalizedAccountId,
writeTarget: resolveExplicitConfigWriteTarget({ channelId }),
};
}
const accounts = (channel.accounts ??= {}) as Record<string, unknown>;
const existingAccount = Object.hasOwn(accounts, normalizedAccountId)
@ -250,6 +265,10 @@ function resolveAccountTarget(
target: account,
pathPrefix: `channels.${channelId}.accounts.${normalizedAccountId}`,
accountId: normalizedAccountId,
writeTarget: resolveExplicitConfigWriteTarget({
channelId,
accountId: normalizedAccountId,
}),
};
}
@ -585,19 +604,6 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
const shouldTouchStore = parsed.target !== "config" && listPairingChannels().includes(channelId);
if (shouldUpdateConfig) {
const allowWrites = resolveChannelConfigWrites({
cfg: params.cfg,
channelId,
accountId: params.ctx.AccountId,
});
if (!allowWrites) {
const hint = `channels.${channelId}.configWrites=true`;
return {
shouldContinue: false,
reply: { text: `⚠️ Config writes are disabled for ${channelId}. Set ${hint} to enable.` },
};
}
const allowlistPath = resolveChannelAllowFromPaths(channelId, scope);
if (!allowlistPath) {
return {
@ -620,7 +626,26 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
target,
pathPrefix,
accountId: normalizedAccountId,
writeTarget,
} = resolveAccountTarget(parsedConfig, channelId, accountId);
const writeAuth = authorizeConfigWrite({
cfg: params.cfg,
origin: { channelId, accountId: params.ctx.AccountId },
target: writeTarget,
allowBypass: canBypassConfigWritePolicy({
channel: params.command.channel,
gatewayClientScopes: params.ctx.GatewayClientScopes,
}),
});
if (!writeAuth.allowed) {
return {
shouldContinue: false,
reply: {
text: formatConfigWriteDeniedMessage({ result: writeAuth, fallbackChannelId: channelId }),
},
};
}
const existing: string[] = [];
const existingPaths =
scope === "dm" && (channelId === "slack" || channelId === "discord")

View File

@ -1,4 +1,9 @@
import { resolveChannelConfigWrites } from "../../channels/plugins/config-writes.js";
import {
authorizeConfigWrite,
canBypassConfigWritePolicy,
formatConfigWriteDeniedMessage,
resolveConfigWriteTargetFromPath,
} from "../../channels/plugins/config-writes.js";
import { normalizeChannelId } from "../../channels/registry.js";
import {
getConfigValueAtPath,
@ -52,6 +57,7 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma
};
}
let parsedWritePath: string[] | undefined;
if (configCommand.action === "set" || configCommand.action === "unset") {
const missingAdminScope = requireGatewayClientScopeForInternalChannel(params, {
label: "/config write",
@ -61,21 +67,29 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma
if (missingAdminScope) {
return missingAdminScope;
}
const parsedPath = parseConfigPath(configCommand.path);
if (!parsedPath.ok || !parsedPath.path) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` },
};
}
parsedWritePath = parsedPath.path;
const channelId = params.command.channelId ?? normalizeChannelId(params.command.channel);
const allowWrites = resolveChannelConfigWrites({
const writeAuth = authorizeConfigWrite({
cfg: params.cfg,
channelId,
accountId: params.ctx.AccountId,
origin: { channelId, accountId: params.ctx.AccountId },
target: resolveConfigWriteTargetFromPath(parsedWritePath),
allowBypass: canBypassConfigWritePolicy({
channel: params.command.channel,
gatewayClientScopes: params.ctx.GatewayClientScopes,
}),
});
if (!allowWrites) {
const channelLabel = channelId ?? "this channel";
const hint = channelId
? `channels.${channelId}.configWrites=true`
: "channels.<channel>.configWrites=true";
if (!writeAuth.allowed) {
return {
shouldContinue: false,
reply: {
text: `⚠️ Config writes are disabled for ${channelLabel}. Set ${hint} to enable.`,
text: formatConfigWriteDeniedMessage({ result: writeAuth, fallbackChannelId: channelId }),
},
};
}
@ -119,14 +133,7 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma
}
if (configCommand.action === "unset") {
const parsedPath = parseConfigPath(configCommand.path);
if (!parsedPath.ok || !parsedPath.path) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` },
};
}
const removed = unsetConfigValueAtPath(parsedBase, parsedPath.path);
const removed = unsetConfigValueAtPath(parsedBase, parsedWritePath ?? []);
if (!removed) {
return {
shouldContinue: false,
@ -151,14 +158,7 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma
}
if (configCommand.action === "set") {
const parsedPath = parseConfigPath(configCommand.path);
if (!parsedPath.ok || !parsedPath.path) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` },
};
}
setConfigValueAtPath(parsedBase, parsedPath.path, configCommand.value);
setConfigValueAtPath(parsedBase, parsedWritePath ?? [], configCommand.value);
const validated = validateConfigObjectWithPlugins(parsedBase);
if (!validated.ok) {
const issue = validated.issues[0];

View File

@ -1,4 +1,4 @@
import { listSubagentRunsForRequester } from "../../agents/subagent-registry.js";
import { listSubagentRunsForController } from "../../agents/subagent-registry.js";
import { logVerbose } from "../../globals.js";
import { handleSubagentsAgentsAction } from "./commands-subagents/action-agents.js";
import { handleSubagentsFocusAction } from "./commands-subagents/action-focus.js";
@ -61,7 +61,7 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo
params,
handledPrefix,
requesterKey,
runs: listSubagentRunsForRequester(requesterKey),
runs: listSubagentRunsForController(requesterKey),
restTokens,
};

View File

@ -1,19 +1,13 @@
import { abortEmbeddedPiRun } from "../../../agents/pi-embedded.js";
import { markSubagentRunTerminated } from "../../../agents/subagent-registry.js";
import {
loadSessionStore,
resolveStorePath,
updateSessionStore,
} from "../../../config/sessions.js";
import { logVerbose } from "../../../globals.js";
import { stopSubagentsForRequester } from "../abort.js";
killAllControlledSubagentRuns,
killControlledSubagentRun,
} from "../../../agents/subagent-control.js";
import type { CommandHandlerResult } from "../commands-types.js";
import { clearSessionQueues } from "../queue.js";
import { formatRunLabel } from "../subagents-utils.js";
import {
type SubagentsCommandContext,
COMMAND,
loadSubagentSessionEntry,
resolveCommandSubagentController,
resolveSubagentEntryForToken,
stopWithText,
} from "./shared.js";
@ -30,10 +24,18 @@ export async function handleSubagentsKillAction(
}
if (target === "all" || target === "*") {
stopSubagentsForRequester({
const controller = resolveCommandSubagentController(params, requesterKey);
const result = await killAllControlledSubagentRuns({
cfg: params.cfg,
requesterSessionKey: requesterKey,
controller,
runs,
});
if (result.status === "forbidden") {
return stopWithText(`⚠️ ${result.error}`);
}
if (result.killed > 0) {
return { shouldContinue: false };
}
return { shouldContinue: false };
}
@ -45,42 +47,17 @@ export async function handleSubagentsKillAction(
return stopWithText(`${formatRunLabel(targetResolution.entry)} is already finished.`);
}
const childKey = targetResolution.entry.childSessionKey;
const { storePath, store, entry } = loadSubagentSessionEntry(params, childKey, {
loadSessionStore,
resolveStorePath,
});
const sessionId = entry?.sessionId;
if (sessionId) {
abortEmbeddedPiRun(sessionId);
}
const cleared = clearSessionQueues([childKey, sessionId]);
if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
logVerbose(
`subagents kill: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
);
}
if (entry) {
entry.abortedLastRun = true;
entry.updatedAt = Date.now();
store[childKey] = entry;
await updateSessionStore(storePath, (nextStore) => {
nextStore[childKey] = entry;
});
}
markSubagentRunTerminated({
runId: targetResolution.entry.runId,
childSessionKey: childKey,
reason: "killed",
});
stopSubagentsForRequester({
const controller = resolveCommandSubagentController(params, requesterKey);
const result = await killControlledSubagentRun({
cfg: params.cfg,
requesterSessionKey: childKey,
controller,
entry: targetResolution.entry,
});
if (result.status === "forbidden") {
return stopWithText(`⚠️ ${result.error}`);
}
if (result.status === "done") {
return stopWithText(result.text);
}
return { shouldContinue: false };
}

View File

@ -1,79 +1,26 @@
import { countPendingDescendantRuns } from "../../../agents/subagent-registry.js";
import { loadSessionStore, resolveStorePath } from "../../../config/sessions.js";
import { buildSubagentList } from "../../../agents/subagent-control.js";
import type { CommandHandlerResult } from "../commands-types.js";
import { sortSubagentRuns } from "../subagents-utils.js";
import {
type SessionStoreCache,
type SubagentsCommandContext,
RECENT_WINDOW_MINUTES,
formatSubagentListLine,
loadSubagentSessionEntry,
stopWithText,
} from "./shared.js";
import { type SubagentsCommandContext, RECENT_WINDOW_MINUTES, stopWithText } from "./shared.js";
export function handleSubagentsListAction(ctx: SubagentsCommandContext): CommandHandlerResult {
const { params, runs } = ctx;
const sorted = sortSubagentRuns(runs);
const now = Date.now();
const recentCutoff = now - RECENT_WINDOW_MINUTES * 60_000;
const storeCache: SessionStoreCache = new Map();
const pendingDescendantCache = new Map<string, number>();
const pendingDescendantCount = (sessionKey: string) => {
if (pendingDescendantCache.has(sessionKey)) {
return pendingDescendantCache.get(sessionKey) ?? 0;
}
const pending = Math.max(0, countPendingDescendantRuns(sessionKey));
pendingDescendantCache.set(sessionKey, pending);
return pending;
};
const isActiveRun = (entry: (typeof runs)[number]) =>
!entry.endedAt || pendingDescendantCount(entry.childSessionKey) > 0;
let index = 1;
const mapRuns = (entries: typeof runs, runtimeMs: (entry: (typeof runs)[number]) => number) =>
entries.map((entry) => {
const { entry: sessionEntry } = loadSubagentSessionEntry(
params,
entry.childSessionKey,
{
loadSessionStore,
resolveStorePath,
},
storeCache,
);
const line = formatSubagentListLine({
entry,
index,
runtimeMs: runtimeMs(entry),
sessionEntry,
pendingDescendants: pendingDescendantCount(entry.childSessionKey),
});
index += 1;
return line;
});
const activeEntries = sorted.filter((entry) => isActiveRun(entry));
const activeLines = mapRuns(activeEntries, (entry) => now - (entry.startedAt ?? entry.createdAt));
const recentEntries = sorted.filter(
(entry) => !isActiveRun(entry) && !!entry.endedAt && (entry.endedAt ?? 0) >= recentCutoff,
);
const recentLines = mapRuns(
recentEntries,
(entry) => (entry.endedAt ?? now) - (entry.startedAt ?? entry.createdAt),
);
const list = buildSubagentList({
cfg: params.cfg,
runs,
recentMinutes: RECENT_WINDOW_MINUTES,
taskMaxChars: 110,
});
const lines = ["active subagents:", "-----"];
if (activeLines.length === 0) {
if (list.active.length === 0) {
lines.push("(none)");
} else {
lines.push(activeLines.join("\n"));
lines.push(list.active.map((entry) => entry.line).join("\n"));
}
lines.push("", `recent subagents (last ${RECENT_WINDOW_MINUTES}m):`, "-----");
if (recentLines.length === 0) {
if (list.recent.length === 0) {
lines.push("(none)");
} else {
lines.push(recentLines.join("\n"));
lines.push(list.recent.map((entry) => entry.line).join("\n"));
}
return stopWithText(lines.join("\n"));

View File

@ -1,27 +1,15 @@
import crypto from "node:crypto";
import { AGENT_LANE_SUBAGENT } from "../../../agents/lanes.js";
import { abortEmbeddedPiRun } from "../../../agents/pi-embedded.js";
import {
clearSubagentRunSteerRestart,
replaceSubagentRunAfterSteer,
markSubagentRunForSteerRestart,
} from "../../../agents/subagent-registry.js";
import { loadSessionStore, resolveStorePath } from "../../../config/sessions.js";
import { callGateway } from "../../../gateway/call.js";
import { logVerbose } from "../../../globals.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../../utils/message-channel.js";
sendControlledSubagentMessage,
steerControlledSubagentRun,
} from "../../../agents/subagent-control.js";
import type { CommandHandlerResult } from "../commands-types.js";
import { clearSessionQueues } from "../queue.js";
import { formatRunLabel } from "../subagents-utils.js";
import {
type SubagentsCommandContext,
COMMAND,
STEER_ABORT_SETTLE_TIMEOUT_MS,
extractAssistantText,
loadSubagentSessionEntry,
resolveCommandSubagentController,
resolveSubagentEntryForToken,
stopWithText,
stripToolMessages,
} from "./shared.js";
export async function handleSubagentsSendAction(
@ -49,111 +37,41 @@ export async function handleSubagentsSendAction(
return stopWithText(`${formatRunLabel(targetResolution.entry)} is already finished.`);
}
const { entry: targetSessionEntry } = loadSubagentSessionEntry(
params,
targetResolution.entry.childSessionKey,
{
loadSessionStore,
resolveStorePath,
},
);
const targetSessionId =
typeof targetSessionEntry?.sessionId === "string" && targetSessionEntry.sessionId.trim()
? targetSessionEntry.sessionId.trim()
: undefined;
if (steerRequested) {
markSubagentRunForSteerRestart(targetResolution.entry.runId);
if (targetSessionId) {
abortEmbeddedPiRun(targetSessionId);
}
const cleared = clearSessionQueues([targetResolution.entry.childSessionKey, targetSessionId]);
if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
logVerbose(
`subagents steer: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
const controller = resolveCommandSubagentController(params, ctx.requesterKey);
const result = await steerControlledSubagentRun({
cfg: params.cfg,
controller,
entry: targetResolution.entry,
message,
});
if (result.status === "accepted") {
return stopWithText(
`steered ${formatRunLabel(targetResolution.entry)} (run ${result.runId.slice(0, 8)}).`,
);
}
try {
await callGateway({
method: "agent.wait",
params: {
runId: targetResolution.entry.runId,
timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS,
},
timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS + 2_000,
});
} catch {
// Continue even if wait fails; steer should still be attempted.
if (result.status === "done" && result.text) {
return stopWithText(result.text);
}
if (result.status === "error") {
return stopWithText(`send failed: ${result.error ?? "error"}`);
}
return stopWithText(`⚠️ ${result.error ?? "send failed"}`);
}
const idempotencyKey = crypto.randomUUID();
let runId: string = idempotencyKey;
try {
const response = await callGateway<{ runId: string }>({
method: "agent",
params: {
message,
sessionKey: targetResolution.entry.childSessionKey,
sessionId: targetSessionId,
idempotencyKey,
deliver: false,
channel: INTERNAL_MESSAGE_CHANNEL,
lane: AGENT_LANE_SUBAGENT,
timeout: 0,
},
timeoutMs: 10_000,
});
const responseRunId = typeof response?.runId === "string" ? response.runId : undefined;
if (responseRunId) {
runId = responseRunId;
}
} catch (err) {
if (steerRequested) {
clearSubagentRunSteerRestart(targetResolution.entry.runId);
}
const messageText =
err instanceof Error ? err.message : typeof err === "string" ? err : "error";
return stopWithText(`send failed: ${messageText}`);
}
if (steerRequested) {
replaceSubagentRunAfterSteer({
previousRunId: targetResolution.entry.runId,
nextRunId: runId,
fallback: targetResolution.entry,
runTimeoutSeconds: targetResolution.entry.runTimeoutSeconds ?? 0,
});
return stopWithText(
`steered ${formatRunLabel(targetResolution.entry)} (run ${runId.slice(0, 8)}).`,
);
}
const waitMs = 30_000;
const wait = await callGateway<{ status?: string; error?: string }>({
method: "agent.wait",
params: { runId, timeoutMs: waitMs },
timeoutMs: waitMs + 2000,
const result = await sendControlledSubagentMessage({
cfg: params.cfg,
entry: targetResolution.entry,
message,
});
if (wait?.status === "timeout") {
return stopWithText(`⏳ Subagent still running (run ${runId.slice(0, 8)}).`);
if (result.status === "timeout") {
return stopWithText(`⏳ Subagent still running (run ${result.runId.slice(0, 8)}).`);
}
if (wait?.status === "error") {
const waitError = typeof wait.error === "string" ? wait.error : "unknown error";
return stopWithText(`⚠️ Subagent error: ${waitError} (run ${runId.slice(0, 8)}).`);
if (result.status === "error") {
return stopWithText(`⚠️ Subagent error: ${result.error} (run ${result.runId.slice(0, 8)}).`);
}
const history = await callGateway<{ messages: Array<unknown> }>({
method: "chat.history",
params: { sessionKey: targetResolution.entry.childSessionKey, limit: 50 },
});
const filtered = stripToolMessages(Array.isArray(history?.messages) ? history.messages : []);
const last = filtered.length > 0 ? filtered[filtered.length - 1] : undefined;
const replyText = last ? extractAssistantText(last) : undefined;
return stopWithText(
replyText ?? `✅ Sent to ${formatRunLabel(targetResolution.entry)} (run ${runId.slice(0, 8)}).`,
result.replyText ??
`✅ Sent to ${formatRunLabel(targetResolution.entry)} (run ${result.runId.slice(0, 8)}).`,
);
}

View File

@ -1,3 +1,5 @@
import { resolveStoredSubagentCapabilities } from "../../../agents/subagent-capabilities.js";
import type { ResolvedSubagentController } from "../../../agents/subagent-control.js";
import {
countPendingDescendantRuns,
type SubagentRunRecord,
@ -18,6 +20,7 @@ import { parseDiscordTarget } from "../../../discord/targets.js";
import { callGateway } from "../../../gateway/call.js";
import { formatTimeAgo } from "../../../infra/format-time/format-relative.ts";
import { parseAgentSessionKey } from "../../../routing/session-key.js";
import { isSubagentSessionKey } from "../../../routing/session-key.js";
import { looksLikeSessionId } from "../../../sessions/session-id.js";
import { extractTextFromChatContent } from "../../../shared/chat-content.js";
import {
@ -247,6 +250,29 @@ export function resolveRequesterSessionKey(
return resolveInternalSessionKey({ key: raw, alias, mainKey });
}
export function resolveCommandSubagentController(
params: SubagentsCommandParams,
requesterKey: string,
): ResolvedSubagentController {
if (!isSubagentSessionKey(requesterKey)) {
return {
controllerSessionKey: requesterKey,
callerSessionKey: requesterKey,
callerIsSubagent: false,
controlScope: "children",
};
}
const capabilities = resolveStoredSubagentCapabilities(requesterKey, {
cfg: params.cfg,
});
return {
controllerSessionKey: requesterKey,
callerSessionKey: requesterKey,
callerIsSubagent: true,
controlScope: capabilities.controlScope,
};
}
export function resolveHandledPrefix(normalized: string): string | null {
return normalized.startsWith(COMMAND)
? COMMAND

View File

@ -682,6 +682,52 @@ describe("handleCommands /config configWrites gating", () => {
expect(result.reply?.text).toContain("Config writes are disabled");
});
it("blocks /config set when the target account disables writes", async () => {
const previousWriteCount = writeConfigFileMock.mock.calls.length;
const cfg = {
commands: { config: true, text: true },
channels: {
telegram: {
configWrites: true,
accounts: {
work: { configWrites: false, enabled: true },
},
},
},
} as OpenClawConfig;
const params = buildPolicyParams(
"/config set channels.telegram.accounts.work.enabled=false",
cfg,
{
AccountId: "default",
Provider: "telegram",
Surface: "telegram",
},
);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("channels.telegram.accounts.work.configWrites=true");
expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount);
});
it("blocks ambiguous channel-root /config writes from channel commands", async () => {
const previousWriteCount = writeConfigFileMock.mock.calls.length;
const cfg = {
commands: { config: true, text: true },
channels: { telegram: { configWrites: true } },
} as OpenClawConfig;
const params = buildPolicyParams('/config set channels.telegram={"enabled":false}', cfg, {
Provider: "telegram",
Surface: "telegram",
});
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain(
"cannot replace channels, channel roots, or accounts collections",
);
expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount);
});
it("blocks /config set from gateway clients without operator.admin", async () => {
const cfg = {
commands: { config: true, text: true },
@ -739,6 +785,49 @@ describe("handleCommands /config configWrites gating", () => {
expect(writeConfigFileMock).toHaveBeenCalledOnce();
expect(result.reply?.text).toContain("Config updated");
});
it("keeps /config set working for gateway operator.admin on protected account paths", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
valid: true,
parsed: {
channels: {
telegram: {
accounts: {
work: { enabled: true, configWrites: false },
},
},
},
},
});
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
ok: true,
config,
}));
const params = buildParams(
"/config set channels.telegram.accounts.work.enabled=false",
{
commands: { config: true, text: true },
channels: {
telegram: {
accounts: {
work: { enabled: true, configWrites: false },
},
},
},
} as OpenClawConfig,
{
Provider: INTERNAL_MESSAGE_CHANNEL,
Surface: INTERNAL_MESSAGE_CHANNEL,
GatewayClientScopes: ["operator.write", "operator.admin"],
},
);
params.command.channel = INTERNAL_MESSAGE_CHANNEL;
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Config updated");
const written = writeConfigFileMock.mock.calls.at(-1)?.[0] as OpenClawConfig;
expect(written.channels?.telegram?.accounts?.work?.enabled).toBe(false);
});
});
describe("handleCommands bash alias", () => {
@ -891,6 +980,35 @@ describe("handleCommands /allowlist", () => {
});
});
it("blocks config-targeted /allowlist edits when the target account disables writes", async () => {
const previousWriteCount = writeConfigFileMock.mock.calls.length;
const cfg = {
commands: { text: true, config: true },
channels: {
telegram: {
configWrites: true,
accounts: {
work: { configWrites: false, allowFrom: ["123"] },
},
},
},
} as OpenClawConfig;
readConfigFileSnapshotMock.mockResolvedValueOnce({
valid: true,
parsed: structuredClone(cfg),
});
const params = buildPolicyParams("/allowlist add dm --account work --config 789", cfg, {
AccountId: "default",
Provider: "telegram",
Surface: "telegram",
});
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("channels.telegram.accounts.work.configWrites=true");
expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount);
});
it("removes default-account entries from scoped and legacy pairing stores", async () => {
removeChannelAllowFromStoreEntryMock
.mockResolvedValueOnce({

View File

@ -19,6 +19,7 @@ const MODEL_PICK_PROVIDER_PREFERENCE = [
"zai",
"openrouter",
"opencode",
"opencode-go",
"github-copilot",
"groq",
"cerebras",

View File

@ -1,6 +1,8 @@
import type { OpenClawConfig } from "../../config/config.js";
import { resolveAccountEntry } from "../../routing/account-lookup.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import { isInternalMessageChannel } from "../../utils/message-channel.js";
import type { ChannelId } from "./types.js";
type ChannelConfigWithAccounts = {
@ -12,6 +14,25 @@ function resolveAccountConfig(accounts: ChannelConfigWithAccounts["accounts"], a
return resolveAccountEntry(accounts, accountId);
}
export type ConfigWriteScope = {
channelId?: ChannelId | null;
accountId?: string | null;
};
export type ConfigWriteTarget =
| { kind: "global" }
| { kind: "channel"; scope: { channelId: ChannelId } }
| { kind: "account"; scope: { channelId: ChannelId; accountId: string } }
| { kind: "ambiguous"; scopes: ConfigWriteScope[] };
export type ConfigWriteAuthorizationResult =
| { allowed: true }
| {
allowed: false;
reason: "ambiguous-target" | "origin-disabled" | "target-disabled";
blockedScope?: { kind: "origin" | "target"; scope: ConfigWriteScope };
};
export function resolveChannelConfigWrites(params: {
cfg: OpenClawConfig;
channelId?: ChannelId | null;
@ -30,3 +51,133 @@ export function resolveChannelConfigWrites(params: {
const value = accountConfig?.configWrites ?? channelConfig.configWrites;
return value !== false;
}
export function authorizeConfigWrite(params: {
cfg: OpenClawConfig;
origin?: ConfigWriteScope;
target?: ConfigWriteTarget;
allowBypass?: boolean;
}): ConfigWriteAuthorizationResult {
if (params.allowBypass) {
return { allowed: true };
}
if (params.target?.kind === "ambiguous") {
return { allowed: false, reason: "ambiguous-target" };
}
if (
params.origin?.channelId &&
!resolveChannelConfigWrites({
cfg: params.cfg,
channelId: params.origin.channelId,
accountId: params.origin.accountId,
})
) {
return {
allowed: false,
reason: "origin-disabled",
blockedScope: { kind: "origin", scope: params.origin },
};
}
const seen = new Set<string>();
for (const target of listConfigWriteTargetScopes(params.target)) {
if (!target.channelId) {
continue;
}
const key = `${target.channelId}:${normalizeAccountId(target.accountId)}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
if (
!resolveChannelConfigWrites({
cfg: params.cfg,
channelId: target.channelId,
accountId: target.accountId,
})
) {
return {
allowed: false,
reason: "target-disabled",
blockedScope: { kind: "target", scope: target },
};
}
}
return { allowed: true };
}
export function resolveExplicitConfigWriteTarget(scope: ConfigWriteScope): ConfigWriteTarget {
if (!scope.channelId) {
return { kind: "global" };
}
const accountId = normalizeAccountId(scope.accountId);
if (!accountId || accountId === DEFAULT_ACCOUNT_ID) {
return { kind: "channel", scope: { channelId: scope.channelId } };
}
return { kind: "account", scope: { channelId: scope.channelId, accountId } };
}
export function resolveConfigWriteTargetFromPath(path: string[]): ConfigWriteTarget {
if (path[0] !== "channels") {
return { kind: "global" };
}
if (path.length < 2) {
return { kind: "ambiguous", scopes: [] };
}
const channelId = path[1].trim().toLowerCase() as ChannelId;
if (!channelId) {
return { kind: "ambiguous", scopes: [] };
}
if (path.length === 2) {
return { kind: "ambiguous", scopes: [{ channelId }] };
}
if (path[2] !== "accounts") {
return { kind: "channel", scope: { channelId } };
}
if (path.length < 4) {
return { kind: "ambiguous", scopes: [{ channelId }] };
}
return resolveExplicitConfigWriteTarget({
channelId,
accountId: normalizeAccountId(path[3]),
});
}
export function canBypassConfigWritePolicy(params: {
channel?: string | null;
gatewayClientScopes?: string[] | null;
}): boolean {
return (
isInternalMessageChannel(params.channel) &&
params.gatewayClientScopes?.includes("operator.admin") === true
);
}
export function formatConfigWriteDeniedMessage(params: {
result: Exclude<ConfigWriteAuthorizationResult, { allowed: true }>;
fallbackChannelId?: ChannelId | null;
}): string {
if (params.result.reason === "ambiguous-target") {
return "⚠️ Channel-initiated /config writes cannot replace channels, channel roots, or accounts collections. Use a more specific path or gateway operator.admin.";
}
const blocked = params.result.blockedScope?.scope;
const channelLabel = blocked?.channelId ?? params.fallbackChannelId ?? "this channel";
const hint = blocked?.channelId
? blocked.accountId
? `channels.${blocked.channelId}.accounts.${blocked.accountId}.configWrites=true`
: `channels.${blocked.channelId}.configWrites=true`
: params.fallbackChannelId
? `channels.${params.fallbackChannelId}.configWrites=true`
: "channels.<channel>.configWrites=true";
return `⚠️ Config writes are disabled for ${channelLabel}. Set ${hint} to enable.`;
}
function listConfigWriteTargetScopes(target?: ConfigWriteTarget): ConfigWriteScope[] {
if (!target || target.kind === "global") {
return [];
}
if (target.kind === "ambiguous") {
return target.scopes;
}
return [target.scope];
}

View File

@ -19,8 +19,16 @@ import {
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import { withEnvAsync } from "../../test-utils/env.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
import { getChannelPluginCatalogEntry, listChannelPluginCatalogEntries } from "./catalog.js";
import { resolveChannelConfigWrites } from "./config-writes.js";
import {
authorizeConfigWrite,
canBypassConfigWritePolicy,
formatConfigWriteDeniedMessage,
resolveExplicitConfigWriteTarget,
resolveChannelConfigWrites,
resolveConfigWriteTargetFromPath,
} from "./config-writes.js";
import {
listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig,
@ -325,6 +333,98 @@ describe("resolveChannelConfigWrites", () => {
});
});
describe("authorizeConfigWrite", () => {
it("blocks when a target account disables writes", () => {
const cfg = makeSlackConfigWritesCfg("work");
expect(
authorizeConfigWrite({
cfg,
origin: { channelId: "slack", accountId: "default" },
target: resolveExplicitConfigWriteTarget({ channelId: "slack", accountId: "work" }),
}),
).toEqual({
allowed: false,
reason: "target-disabled",
blockedScope: { kind: "target", scope: { channelId: "slack", accountId: "work" } },
});
});
it("blocks when the origin account disables writes", () => {
const cfg = makeSlackConfigWritesCfg("default");
expect(
authorizeConfigWrite({
cfg,
origin: { channelId: "slack", accountId: "default" },
target: resolveExplicitConfigWriteTarget({ channelId: "slack", accountId: "work" }),
}),
).toEqual({
allowed: false,
reason: "origin-disabled",
blockedScope: { kind: "origin", scope: { channelId: "slack", accountId: "default" } },
});
});
it("allows bypass for internal operator.admin writes", () => {
const cfg = makeSlackConfigWritesCfg("work");
expect(
authorizeConfigWrite({
cfg,
origin: { channelId: "slack", accountId: "default" },
target: resolveExplicitConfigWriteTarget({ channelId: "slack", accountId: "work" }),
allowBypass: canBypassConfigWritePolicy({
channel: INTERNAL_MESSAGE_CHANNEL,
gatewayClientScopes: ["operator.admin"],
}),
}),
).toEqual({ allowed: true });
});
it("treats non-channel config paths as global writes", () => {
const cfg = makeSlackConfigWritesCfg("work");
expect(
authorizeConfigWrite({
cfg,
origin: { channelId: "slack", accountId: "default" },
target: resolveConfigWriteTargetFromPath(["messages", "ackReaction"]),
}),
).toEqual({ allowed: true });
});
it("rejects ambiguous channel collection writes", () => {
expect(resolveConfigWriteTargetFromPath(["channels", "telegram"])).toEqual({
kind: "ambiguous",
scopes: [{ channelId: "telegram" }],
});
expect(resolveConfigWriteTargetFromPath(["channels", "telegram", "accounts"])).toEqual({
kind: "ambiguous",
scopes: [{ channelId: "telegram" }],
});
});
it("resolves explicit channel and account targets", () => {
expect(resolveExplicitConfigWriteTarget({ channelId: "slack" })).toEqual({
kind: "channel",
scope: { channelId: "slack" },
});
expect(resolveExplicitConfigWriteTarget({ channelId: "slack", accountId: "work" })).toEqual({
kind: "account",
scope: { channelId: "slack", accountId: "work" },
});
});
it("formats denied messages consistently", () => {
expect(
formatConfigWriteDeniedMessage({
result: {
allowed: false,
reason: "target-disabled",
blockedScope: { kind: "target", scope: { channelId: "slack", accountId: "work" } },
},
}),
).toContain("channels.slack.accounts.work.configWrites=true");
});
});
describe("directory (config-backed)", () => {
it("lists Slack peers/groups from config", async () => {
const cfg = {

View File

@ -43,4 +43,29 @@ describe("resolveGatewayTokenForDriftCheck", () => {
}),
).toThrow(/gateway\.auth\.token/i);
});
it("does not fall back to gateway.remote token for unresolved local token refs", () => {
expect(() =>
resolveGatewayTokenForDriftCheck({
cfg: {
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
mode: "local",
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_LOCAL_TOKEN" },
},
remote: {
token: "remote-token",
},
},
} as OpenClawConfig,
env: {} as NodeJS.ProcessEnv,
}),
).toThrow(/gateway\.auth\.token/i);
});
});

View File

@ -1,16 +1,10 @@
import type { OpenClawConfig } from "../../config/config.js";
import { resolveGatewayCredentialsFromConfig } from "../../gateway/credentials.js";
import { resolveGatewayDriftCheckCredentialsFromConfig } from "../../gateway/credentials.js";
export function resolveGatewayTokenForDriftCheck(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}) {
return resolveGatewayCredentialsFromConfig({
cfg: params.cfg,
env: {} as NodeJS.ProcessEnv,
modeOverride: "local",
// Drift checks should compare the configured local token source against the
// persisted service token, not let exported shell env hide stale service state.
localTokenPrecedence: "config-first",
}).token;
void params.env;
return resolveGatewayDriftCheckCredentialsFromConfig({ cfg: params.cfg }).token;
}

View File

@ -174,7 +174,7 @@ describe("nodes-cli coverage", () => {
expect(invoke?.params?.command).toBe("system.run");
expect(invoke?.params?.params).toEqual({
command: ["echo", "hi"],
rawCommand: null,
rawCommand: "echo hi",
cwd: "/tmp",
env: { FOO: "bar" },
timeoutMs: 1200,
@ -186,11 +186,11 @@ describe("nodes-cli coverage", () => {
});
expect(invoke?.params?.timeoutMs).toBe(5000);
const approval = getApprovalRequestCall();
expect(approval?.params?.["commandArgv"]).toEqual(["echo", "hi"]);
expect(approval?.params?.["systemRunPlan"]).toEqual({
argv: ["echo", "hi"],
cwd: "/tmp",
rawCommand: null,
commandText: "echo hi",
commandPreview: null,
agentId: "main",
sessionKey: null,
});
@ -213,18 +213,18 @@ describe("nodes-cli coverage", () => {
expect(invoke?.params?.command).toBe("system.run");
expect(invoke?.params?.params).toMatchObject({
command: ["/bin/sh", "-lc", "echo hi"],
rawCommand: "echo hi",
rawCommand: '/bin/sh -lc "echo hi"',
agentId: "main",
approved: true,
approvalDecision: "allow-once",
runId: expect.any(String),
});
const approval = getApprovalRequestCall();
expect(approval?.params?.["commandArgv"]).toEqual(["/bin/sh", "-lc", "echo hi"]);
expect(approval?.params?.["systemRunPlan"]).toEqual({
argv: ["/bin/sh", "-lc", "echo hi"],
cwd: null,
rawCommand: "echo hi",
commandText: '/bin/sh -lc "echo hi"',
commandPreview: "echo hi",
agentId: "main",
sessionKey: null,
});

View File

@ -189,7 +189,6 @@ async function maybeRequestNodesRunApproval(params: {
opts: NodesRunOpts;
nodeId: string;
agentId: string | undefined;
preparedCmdText: string;
approvalPlan: ReturnType<typeof requirePreparedRunPayload>["plan"];
hostSecurity: ExecSecurity;
hostAsk: ExecAsk;
@ -215,8 +214,6 @@ async function maybeRequestNodesRunApproval(params: {
params.opts,
{
id: approvalId,
command: params.preparedCmdText,
commandArgv: params.approvalPlan.argv,
systemRunPlan: params.approvalPlan,
cwd: params.approvalPlan.cwd,
nodeId: params.nodeId,
@ -272,7 +269,7 @@ function buildSystemRunInvokeParams(params: {
command: "system.run",
params: {
command: params.approvalPlan.argv,
rawCommand: params.approvalPlan.rawCommand,
rawCommand: params.approvalPlan.commandText,
cwd: params.approvalPlan.cwd,
env: params.nodeEnv,
timeoutMs: params.timeoutMs,
@ -403,7 +400,6 @@ export function registerNodesInvokeCommands(nodes: Command) {
opts,
nodeId,
agentId,
preparedCmdText: preparedContext.prepared.cmdText,
approvalPlan,
hostSecurity: approvals.hostSecurity,
hostAsk: approvals.hostAsk,

View File

@ -168,6 +168,7 @@ export function registerOnboardCommand(program: Command) {
togetherApiKey: opts.togetherApiKey as string | undefined,
huggingfaceApiKey: opts.huggingfaceApiKey as string | undefined,
opencodeZenApiKey: opts.opencodeZenApiKey as string | undefined,
opencodeGoApiKey: opts.opencodeGoApiKey as string | undefined,
xaiApiKey: opts.xaiApiKey as string | undefined,
litellmApiKey: opts.litellmApiKey as string | undefined,
volcengineApiKey: opts.volcengineApiKey as string | undefined,

View File

@ -41,6 +41,7 @@ describe("buildAuthChoiceOptions", () => {
"volcengine-api-key",
"byteplus-api-key",
"vllm",
"opencode-go",
]) {
expect(options.some((opt) => opt.value === value)).toBe(true);
}
@ -80,4 +81,16 @@ describe("buildAuthChoiceOptions", () => {
expect(chutesGroup).toBeDefined();
expect(chutesGroup?.options.some((opt) => opt.value === "chutes")).toBe(true);
});
it("groups OpenCode Zen and Go under one OpenCode entry", () => {
const { groups } = buildAuthChoiceGroups({
store: EMPTY_STORE,
includeSkip: false,
});
const openCodeGroup = groups.find((group) => group.value === "opencode");
expect(openCodeGroup).toBeDefined();
expect(openCodeGroup?.options.some((opt) => opt.value === "opencode-zen")).toBe(true);
expect(openCodeGroup?.options.some((opt) => opt.value === "opencode-go")).toBe(true);
});
});

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