mirror of https://github.com/openclaw/openclaw.git
Merge branch 'main' into main
This commit is contained in:
commit
afc6c1d834
|
|
@ -1 +0,0 @@
|
|||
custom: ["https://github.com/sponsors/steipete"]
|
||||
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]?,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]?,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -86,12 +86,13 @@ OpenClaw ships with the pi‑ai 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 pi‑ai 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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 you’ve 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 you’ve 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 you’re 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 don’t 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: don’t try to hardcode “all models” in docs. The authoritative list is whatever `discoverModels(...)` returns on your machine + whatever keys are available.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)">
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@
|
|||
"dependencies": {
|
||||
"google-auth-library": "^10.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.3.7"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@
|
|||
"private": true,
|
||||
"description": "OpenClaw core memory search plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.3.7"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
149
pnpm-lock.yaml
149
pnpm-lock.yaml
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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",
|
||||
],
|
||||
});
|
||||
}
|
||||
|
|
@ -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:])`;
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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" } }],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
|
|
@ -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}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ describe("web_fetch Cloudflare Markdown for Agents", () => {
|
|||
sandboxed: false,
|
||||
runtimeFirecrawl: {
|
||||
active: false,
|
||||
apiKeySource: "secretRef",
|
||||
apiKeySource: "secretRef", // pragma: allowlist secret
|
||||
diagnostics: [],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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)}).`,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ const MODEL_PICK_PROVIDER_PREFERENCE = [
|
|||
"zai",
|
||||
"openrouter",
|
||||
"opencode",
|
||||
"opencode-go",
|
||||
"github-copilot",
|
||||
"groq",
|
||||
"cerebras",
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue