Merge remote-tracking branch 'origin/main' into jesse/non-clawhub-untrusted-installs

This commit is contained in:
jesse-merhi
2026-07-10 14:49:39 +10:00
231 changed files with 14969 additions and 1712 deletions
@@ -9,7 +9,17 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
## Global rules
- Use the snapshot most closely matching the requested fresh baseline.
- Inventory existing VMs and snapshots before provisioning anything. When a preconfigured pristine
snapshot matches the requested baseline, switch to it and reuse its user, tools, and base setup.
Do not create a new VM, reinstall macOS, or rebuild the guest baseline for a "fresh" run.
- "Fresh" means restoring the closest existing pristine snapshot, not creating another snapshot.
Do not create ad-hoc snapshots unless the user explicitly asks or no suitable baseline exists;
restore the original snapshot and leave the guest stopped after an ad-hoc run.
- Inspect the snapshot state before restoring it. A pristine `poweron` snapshot can contain the
preconfigured logged-in session; switch to it normally so Parallels resumes that session. Do not
pass `--skip-resume` at test entry unless the run intentionally needs to discard the saved session
and boot from the login window. `--skip-resume` is acceptable for final cleanup that must leave the
restored source guest stopped.
- Gateway verification in smoke runs should use `openclaw gateway status --deep --require-rpc` unless the stable version being checked does not support it yet.
- Stable `2026.3.12` pre-upgrade diagnostics may require a plain `gateway status --deep` fallback.
- Treat `precheck=latest-ref-fail` on that stable pre-upgrade lane as baseline, not automatically a regression.
+1 -1
View File
@@ -1948,7 +1948,7 @@ jobs:
run: |
set -euo pipefail
for attempt in 1 2 3; do
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
if swift test --package-path apps/macos --parallel --enable-code-coverage; then
exit 0
fi
echo "swift test failed (attempt $attempt/3). Retrying…"
@@ -2099,7 +2099,7 @@ jobs:
profiles: full
- suite_id: native-live-src-gateway-profiles-deepseek
label: Native live gateway profiles DeepSeek
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=deepseek node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=deepseek OPENCLAW_LIVE_GATEWAY_MODELS=deepseek/deepseek-v4-flash,deepseek/deepseek-v4-pro node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 30
profile_env_only: false
advisory: true
+12 -2
View File
@@ -657,14 +657,15 @@ jobs:
steps:
- name: Verify package acceptance results
env:
ADVISORY: ${{ inputs.advisory }}
DOCKER_RESULT: ${{ needs.docker_acceptance.result }}
PACKAGE_INTEGRITY_RESULT: ${{ needs.package_integrity.result }}
PACKAGE_TELEGRAM_RESULT: ${{ needs.package_telegram.result }}
RESOLVE_RESULT: ${{ needs.resolve_package.result }}
TELEGRAM_ENABLED: ${{ needs.resolve_package.outputs.telegram_enabled }}
shell: bash
run: |
set -euo pipefail
advisory="${{ inputs.advisory }}"
failed=0
for item in \
"resolve_package=${RESOLVE_RESULT}" \
@@ -674,8 +675,17 @@ jobs:
do
name="${item%%=*}"
result="${item#*=}"
result_failed=false
if [[ "$result" != "success" && "$result" != "skipped" ]]; then
if [[ "$advisory" == "true" && "$name" != "resolve_package" ]]; then
result_failed=true
fi
if [[ "$name" == "package_telegram" &&
"$TELEGRAM_ENABLED" == "true" &&
"$result" != "success" ]]; then
result_failed=true
fi
if [[ "$result_failed" == "true" ]]; then
if [[ "$ADVISORY" == "true" && "$name" != "resolve_package" ]]; then
echo "::warning::${name} ended with ${result}; package acceptance is advisory for this caller."
continue
fi
+4
View File
@@ -25,8 +25,12 @@ Docs: https://docs.openclaw.ai
### Fixes
- **OpenAI-compatible streamed tool calls:** execute complete native tool calls from streams that end with SSE `data: [DONE]` but omit `finish_reason`, while keeping transport EOF and visible-text cases fail-closed. (#98124, #97994) Thanks @SunnyShu0925.
- **Doctor state isolation:** prevent automated update and Gateway watch repair from importing and archiving default-home exec or plugin-binding approvals when `OPENCLAW_STATE_DIR` points elsewhere, keep implicit CLI preflight notice-only, and reserve cross-state imports for direct operator doctor runs. (#103247, #103317)
- **Doctor clean-state guidance:** stop suggesting `openclaw doctor --fix` after a clean run with no config changes while preserving targeted repair hints. (#103233)
- **OpenCode Zen model catalog:** refresh the provider-owned static seed for Claude Sonnet 5, Grok 4.5, Hy3 Free, Kimi K2.7 Code, and MiniMax M3 with verified routing, pricing, limits, and input capabilities, remove retired free-tier rows, and expose the same catalog through unauthenticated model listing. (#103184)
- **Managed browser launch:** surface asynchronous Chrome bootstrap and runtime spawn failures as browser errors while keeping Gateway alive, and retain process error handling through later lifecycle failures.
- **Browser node-proxy downloads:** transfer every action-produced download to the Gateway media store, align a 10 MiB per-file and 16 MiB aggregate transport budget, and rewrite plural download paths to Gateway-local files without traversing page-controlled result data.
- **Gateway startup migrations:** release the shared migration lease before exiting when the selected config changes during startup, allowing immediate retries instead of blocking readiness until the five-minute lease expires. (#103145)
- **Apple timeout recovery:** return promptly from shared operation deadlines and caller cancellation even when platform work ignores cancellation, while isolating late Gateway handshakes and cleaning up location and permission waiters. (#103066) Thanks @NianJiuZst.
- **Claude CLI warm sessions:** preserve managed stdio continuity when Claude writes no native transcript, fall back to bounded OpenClaw history only when the exact live child disappears or changes, and keep stateless runs from persisting CLI bindings. (#96841) Thanks @bradreaves.
File diff suppressed because it is too large Load Diff
+1
View File
@@ -20,6 +20,7 @@ OpenClaw Android is the officially released Google Play app. It connects to an O
- [x] Screen tab full functionality
- [x] Skill Workshop settings can filter proposals, inspect proposal content, and apply/reject/quarantine drafts through Gateway RPCs
- [x] Per-app language selection for translated resources follows Android system settings and persistence
- [x] Cron job settings support details, run history, run now, edits, enable/disable, and deletion with admin-scoped Gateway access
## Open in Android Studio
@@ -8,6 +8,8 @@ import kotlinx.serialization.json.buildJsonObject
internal object AndroidScreenshotFixture {
const val mainSessionKey = "agent:main:node-screenshot"
const val primarySessionTitle = "Android release planning"
const val cronJobId = "android-release-digest"
const val cronJobName = "Android release digest"
val agents =
listOf(
@@ -93,9 +95,97 @@ internal object AndroidScreenshotFixture {
"chat.history" -> chatHistory()
"sessions.list" -> sessionList()
"chat.metadata" -> chatMetadata()
"cron.list" -> cronList()
"cron.get" -> cronJob().toString()
"cron.runs" -> cronRuns()
else -> error("Screenshot fixture does not implement gateway method $method with params $paramsJson")
}
private fun cronList(): String =
buildJsonObject {
put(
"jobs",
buildJsonArray {
add(cronJob())
},
)
}.toString()
private fun cronJob() =
buildJsonObject {
put("id", JsonPrimitive(cronJobId))
put("name", JsonPrimitive(cronJobName))
put("enabled", JsonPrimitive(true))
put("createdAtMs", JsonPrimitive(1_783_468_800_000))
put("updatedAtMs", JsonPrimitive(1_783_555_200_000))
put("configRevision", JsonPrimitive("sha256:screenshot-fixture"))
put(
"schedule",
buildJsonObject {
put("kind", JsonPrimitive("every"))
put("everyMs", JsonPrimitive(86_400_000))
put("anchorMs", JsonPrimitive(1_783_468_800_000))
},
)
put("sessionTarget", JsonPrimitive("isolated"))
put("wakeMode", JsonPrimitive("now"))
put(
"payload",
buildJsonObject {
put("kind", JsonPrimitive("agentTurn"))
put("message", JsonPrimitive("Summarize Android release readiness."))
put("model", JsonPrimitive("openai/gpt-5.2"))
},
)
put(
"state",
buildJsonObject {
put("nextRunAtMs", JsonPrimitive(1_783_641_600_000))
put("lastRunAtMs", JsonPrimitive(1_783_555_200_000))
put("lastStatus", JsonPrimitive("ok"))
put("lastDurationMs", JsonPrimitive(1_842))
put("consecutiveErrors", JsonPrimitive(0))
put("consecutiveSkipped", JsonPrimitive(0))
put("lastDeliveryStatus", JsonPrimitive("delivered"))
},
)
}
private fun cronRuns(): String =
buildJsonObject {
put(
"entries",
buildJsonArray {
add(
buildJsonObject {
put("ts", JsonPrimitive(1_783_555_200_000))
put("jobId", JsonPrimitive(cronJobId))
put("runId", JsonPrimitive("android-release-digest-run-2"))
put("action", JsonPrimitive("finished"))
put("status", JsonPrimitive("ok"))
put("summary", JsonPrimitive("Release checklist ready"))
put("durationMs", JsonPrimitive(1_842))
put("deliveryStatus", JsonPrimitive("delivered"))
put("model", JsonPrimitive("openai/gpt-5.2"))
},
)
add(
buildJsonObject {
put("ts", JsonPrimitive(1_783_468_800_000))
put("jobId", JsonPrimitive(cronJobId))
put("runId", JsonPrimitive("android-release-digest-run-1"))
put("action", JsonPrimitive("finished"))
put("status", JsonPrimitive("error"))
put("error", JsonPrimitive("Play publish blocked"))
put("durationMs", JsonPrimitive(927))
put("deliveryStatus", JsonPrimitive("not-requested"))
put("model", JsonPrimitive("openai/gpt-5.2"))
},
)
},
)
}.toString()
private fun chatHistory(): String =
buildJsonObject {
put("sessionId", JsonPrimitive("screenshot-session"))
@@ -15,22 +15,37 @@ data class GatewayCronJobDetail(
val description: String,
val enabled: Boolean,
val deleteAfterRun: Boolean,
val scheduleKind: String,
val scheduleLabel: String,
val scheduleDetail: String,
val scheduleAt: String?,
val scheduleEveryMs: Long?,
val scheduleAnchorMs: Long?,
val scheduleCronExpr: String?,
val scheduleTimezone: String?,
val scheduleStaggerMs: Long?,
val scheduleCommand: String?,
val scheduleCwd: String?,
val sessionTarget: String,
val wakeMode: String,
val payloadKind: String,
val payloadText: String?,
val payloadLabel: String,
val payloadModel: String?,
val payloadThinking: String?,
val payloadCommandArgv: List<String>?,
val payloadCommandCwd: String?,
val deliveryLabel: String,
val failureAlertLabel: String,
val createdAtMs: Long,
val updatedAtMs: Long,
val configRevision: String?,
val nextRunAtMs: Long?,
val runningAtMs: Long?,
val lastRunAtMs: Long?,
val lastRunStatus: String?,
val lastError: String?,
val lastDiagnosticSummary: String?,
val lastDurationMs: Long?,
val consecutiveErrors: Long?,
val consecutiveSkipped: Long?,
@@ -75,6 +90,18 @@ internal class CronJobDetailRequestGuard {
}
}
fun beginIfCurrent(
rawId: String,
onBegin: (CronJobDetailRequest) -> Unit,
): CronJobDetailRequest? {
val id = rawId.trim().takeIf { it.isNotEmpty() } ?: return null
return synchronized(lock) {
if (selectedId != id) return@synchronized null
generation += 1
CronJobDetailRequest(id = id, generation = generation).also(onBegin)
}
}
fun cancel(onCancel: () -> Unit = {}) {
synchronized(lock) {
generation += 1
@@ -83,6 +110,20 @@ internal class CronJobDetailRequestGuard {
}
}
fun cancelIfCurrent(
rawId: String,
onCancel: () -> Unit,
): Boolean {
val id = rawId.trim().takeIf { it.isNotEmpty() } ?: return false
return synchronized(lock) {
if (selectedId != id) return@synchronized false
generation += 1
selectedId = null
onCancel()
true
}
}
fun publishIfCurrent(
request: CronJobDetailRequest,
publish: () -> Unit,
@@ -110,6 +151,9 @@ internal fun parseGatewayCronJobDetail(job: JsonObject?): GatewayCronJobDetail?
val sessionTarget = value.string("sessionTarget") ?: return null
val wakeMode = value.string("wakeMode") ?: return null
val payloadKind = payload.string("kind") ?: return null
val scheduleKind = schedule.string("kind") ?: return null
if (scheduleKind !in setOf("at", "every", "cron", "on-exit")) return null
if (payloadKind !in setOf("systemEvent", "agentTurn", "command")) return null
val state = value["state"].asObjectOrNull() ?: return null
return GatewayCronJobDetail(
@@ -118,22 +162,39 @@ internal fun parseGatewayCronJobDetail(job: JsonObject?): GatewayCronJobDetail?
description = value.string("description").orEmpty(),
enabled = value.boolean("enabled"),
deleteAfterRun = value.boolean("deleteAfterRun"),
scheduleKind = scheduleKind,
scheduleLabel = cronScheduleLabel(schedule),
scheduleDetail = cronScheduleDetail(schedule),
scheduleAt = schedule.string("at"),
scheduleEveryMs = schedule.long("everyMs"),
scheduleAnchorMs = schedule.long("anchorMs"),
scheduleCronExpr = schedule.string("expr"),
scheduleTimezone = schedule.string("tz"),
scheduleStaggerMs = schedule.long("staggerMs"),
scheduleCommand = schedule.string("command"),
scheduleCwd = schedule.string("cwd"),
sessionTarget = sessionTarget,
wakeMode = wakeMode,
payloadKind = payloadKind,
payloadText = cronPayloadText(payload),
payloadLabel = cronPayloadLabel(payload),
payloadModel = payload.string("model"),
payloadThinking = payload.string("thinking"),
payloadCommandArgv =
(payload["argv"] as? JsonArray)
?.mapNotNull { it.asStringOrNull() },
payloadCommandCwd = payload.string("cwd"),
deliveryLabel = cronDeliveryLabel(value["delivery"].asObjectOrNull()),
failureAlertLabel = cronFailureAlertLabel(value["failureAlert"]),
createdAtMs = createdAtMs,
updatedAtMs = updatedAtMs,
configRevision = value.string("configRevision"),
nextRunAtMs = state.long("nextRunAtMs"),
runningAtMs = state.long("runningAtMs"),
lastRunAtMs = state.long("lastRunAtMs"),
lastRunStatus = cronJobLastRunStatus(state),
lastError = state.string("lastError"),
lastDiagnosticSummary = state.string("lastDiagnosticSummary"),
lastDurationMs = state.long("lastDurationMs"),
consecutiveErrors = state.long("consecutiveErrors"),
consecutiveSkipped = state.long("consecutiveSkipped"),
@@ -0,0 +1,611 @@
package ai.openclaw.app
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.node.asObjectOrNull
import ai.openclaw.app.node.asStringOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
data class GatewayCronRunSummary(
val ts: Long,
val runId: String?,
val status: String?,
val summary: String?,
val error: String?,
val durationMs: Long?,
val deliveryStatus: String?,
val sessionKey: String?,
val model: String?,
)
sealed interface GatewayCronRunHistoryState {
data object Idle : GatewayCronRunHistoryState
data class Loading(
val id: String,
) : GatewayCronRunHistoryState
data class Loaded(
val id: String,
val runs: List<GatewayCronRunSummary>,
) : GatewayCronRunHistoryState
data class Error(
val id: String,
val message: String,
) : GatewayCronRunHistoryState
}
enum class GatewayCronAction {
Run,
Enable,
Disable,
Save,
Delete,
}
enum class GatewayCronNoticeKind {
Success,
Warning,
Error,
}
sealed interface GatewayCronActionState {
data object Idle : GatewayCronActionState
data class Running(
val id: String,
val action: GatewayCronAction,
) : GatewayCronActionState
data class Notice(
val id: String,
val message: String,
val kind: GatewayCronNoticeKind,
val deleted: Boolean = false,
) : GatewayCronActionState
}
/** Owns one queued manual run id per job so a stale tracker cannot clear a newer run. */
internal class PendingCronRunRegistry {
private val lock = Any()
private val runIdsByJob = linkedMapOf<String, String>()
fun contains(rawJobId: String): Boolean {
val jobId = rawJobId.trim().takeIf { it.isNotEmpty() } ?: return false
return synchronized(lock) { runIdsByJob.containsKey(jobId) }
}
fun begin(
rawJobId: String,
rawRunId: String,
publish: (Set<String>) -> Unit,
): Boolean {
val jobId = rawJobId.trim().takeIf { it.isNotEmpty() } ?: return false
val runId = rawRunId.trim().takeIf { it.isNotEmpty() } ?: return false
return synchronized(lock) {
if (runIdsByJob.containsKey(jobId)) return@synchronized false
runIdsByJob[jobId] = runId
publish(runIdsByJob.keys.toSet())
true
}
}
fun finish(
rawJobId: String,
rawRunId: String,
publish: (Set<String>) -> Unit,
): Boolean {
val jobId = rawJobId.trim().takeIf { it.isNotEmpty() } ?: return false
val runId = rawRunId.trim().takeIf { it.isNotEmpty() } ?: return false
return synchronized(lock) {
if (runIdsByJob[jobId] != runId) return@synchronized false
runIdsByJob.remove(jobId)
publish(runIdsByJob.keys.toSet())
true
}
}
fun clear(publish: (Set<String>) -> Unit) {
synchronized(lock) {
runIdsByJob.clear()
publish(emptySet())
}
}
}
sealed interface GatewayCronScheduleEdit {
data class At(
val at: String,
) : GatewayCronScheduleEdit
data class Every(
val everyMs: String,
val anchorMs: String,
) : GatewayCronScheduleEdit
data class Cron(
val expression: String,
val timezone: String,
val staggerMs: String,
) : GatewayCronScheduleEdit
data class OnExit(
val command: String,
val cwd: String,
) : GatewayCronScheduleEdit
}
sealed interface GatewayCronPayloadEdit {
data class SystemEvent(
val text: String,
) : GatewayCronPayloadEdit
data class AgentTurn(
val message: String,
val model: String,
val thinking: String,
) : GatewayCronPayloadEdit
data class Command(
val argvJson: String,
val cwd: String,
) : GatewayCronPayloadEdit
}
data class GatewayCronJobEdit(
val name: String,
val description: String,
val enabled: Boolean,
val deleteAfterRun: Boolean,
val schedule: GatewayCronScheduleEdit,
val sessionTarget: String,
val wakeMode: String,
val payload: GatewayCronPayloadEdit,
) {
fun withSchedule(value: GatewayCronScheduleEdit): GatewayCronJobEdit =
copy(
schedule = value,
deleteAfterRun = deleteAfterRun && value is GatewayCronScheduleEdit.At,
)
}
internal data class CronEditorDraftState(
val baseline: GatewayCronJobEdit,
val edit: GatewayCronJobEdit,
val savePending: Boolean = false,
val saveSucceeded: Boolean = false,
val hasIncomingConflict: Boolean = false,
) {
val isDirty: Boolean
get() = edit != baseline
val requiresResolution: Boolean
get() = isDirty || hasIncomingConflict
fun withEdit(value: GatewayCronJobEdit): CronEditorDraftState = copy(edit = value)
fun saveStarted(): CronEditorDraftState = copy(savePending = true, saveSucceeded = false)
fun saveAborted(): CronEditorDraftState = copy(savePending = false, saveSucceeded = false)
fun observeSaveNotice(kind: GatewayCronNoticeKind): CronEditorDraftState {
if (!savePending) return this
return if (kind == GatewayCronNoticeKind.Success) {
copy(saveSucceeded = true)
} else {
copy(savePending = false, saveSucceeded = false)
}
}
fun observeJob(job: GatewayCronJobDetail): CronEditorDraftState {
val incoming = job.toCronJobEdit()
if (incoming == edit) {
return CronEditorDraftState(
baseline = incoming,
edit = incoming,
)
}
if (incoming == baseline) {
return copy(hasIncomingConflict = false)
}
val canAdopt = !isDirty || saveSucceeded
if (!canAdopt) {
return copy(hasIncomingConflict = true)
}
return CronEditorDraftState(
baseline = incoming,
edit = incoming,
)
}
companion object {
fun from(job: GatewayCronJobDetail): CronEditorDraftState {
val edit = job.toCronJobEdit()
return CronEditorDraftState(
baseline = edit,
edit = edit,
)
}
}
}
internal fun CronEditorDraftState.reconcileRestoredAction(
isConnected: Boolean,
jobId: String,
actionState: GatewayCronActionState,
): CronEditorDraftState {
if (!savePending) return this
// Activity recreation retains the runtime action; process death does not.
// Preserve pending only when the restored runtime still owns this Save.
val retainedSaveState =
when (actionState) {
is GatewayCronActionState.Running ->
actionState.id == jobId && actionState.action == GatewayCronAction.Save
is GatewayCronActionState.Notice -> actionState.id == jobId
GatewayCronActionState.Idle -> false
}
return if (isConnected && retainedSaveState) this else saveAborted()
}
internal enum class GatewayCronRunSkipReason(
val message: String,
) {
NotDue("Cron job is not due yet."),
AlreadyRunning("Cron job is already running."),
RestartRecoveryPending("Gateway restart recovery is still in progress."),
InvalidSpec("Cron job has an invalid configuration."),
Stopped("Cron scheduler is stopped."),
}
internal sealed interface GatewayCronRunOutcome {
data class Started(
val runId: String?,
) : GatewayCronRunOutcome
data class Skipped(
val reason: GatewayCronRunSkipReason,
) : GatewayCronRunOutcome
data object Rejected : GatewayCronRunOutcome
}
internal fun cronRunShouldRefresh(outcome: GatewayCronRunOutcome): Boolean =
when (outcome) {
is GatewayCronRunOutcome.Started -> true
is GatewayCronRunOutcome.Skipped -> outcome.reason == GatewayCronRunSkipReason.InvalidSpec
GatewayCronRunOutcome.Rejected -> false
}
internal fun cronRunCompletionNotice(
jobId: String,
status: String?,
): GatewayCronActionState.Notice {
val (message, kind) =
when (status) {
"ok" -> "Cron run finished." to GatewayCronNoticeKind.Success
"skipped" -> "Cron run skipped." to GatewayCronNoticeKind.Warning
"error" -> "Cron run failed." to GatewayCronNoticeKind.Error
else -> "Cron run finished with an unknown status." to GatewayCronNoticeKind.Warning
}
return GatewayCronActionState.Notice(id = jobId, message = message, kind = kind)
}
internal fun isCronJobRevisionConflict(error: GatewaySession.ErrorShape): Boolean = error.details?.code == "CRON_JOB_CHANGED"
internal fun GatewayCronJobDetail.toCronJobEdit(): GatewayCronJobEdit =
GatewayCronJobEdit(
name = name,
description = description,
enabled = enabled,
// Gateway deletion only runs after a successful one-shot schedule.
deleteAfterRun = deleteAfterRun && scheduleKind == "at",
schedule =
when (scheduleKind) {
"at" -> GatewayCronScheduleEdit.At(at = scheduleAt.orEmpty())
"every" ->
GatewayCronScheduleEdit.Every(
everyMs = scheduleEveryMs?.toString().orEmpty(),
anchorMs = scheduleAnchorMs?.toString().orEmpty(),
)
"cron" ->
GatewayCronScheduleEdit.Cron(
expression = scheduleCronExpr.orEmpty(),
timezone = scheduleTimezone.orEmpty(),
staggerMs = scheduleStaggerMs?.toString().orEmpty(),
)
"on-exit" ->
GatewayCronScheduleEdit.OnExit(
command = scheduleCommand.orEmpty(),
cwd = scheduleCwd.orEmpty(),
)
else -> error("Unsupported cron schedule kind: $scheduleKind")
},
sessionTarget = sessionTarget,
wakeMode = wakeMode,
payload =
when (payloadKind) {
"systemEvent" -> GatewayCronPayloadEdit.SystemEvent(text = payloadText.orEmpty())
"agentTurn" ->
GatewayCronPayloadEdit.AgentTurn(
message = payloadText.orEmpty(),
model = payloadModel.orEmpty(),
thinking = payloadThinking.orEmpty(),
)
"command" ->
GatewayCronPayloadEdit.Command(
argvJson = JsonArray(payloadCommandArgv.orEmpty().map(::JsonPrimitive)).toString(),
cwd = payloadCommandCwd.orEmpty(),
)
else -> error("Unsupported cron payload kind: $payloadKind")
},
)
internal fun buildCronUpdateParams(
original: GatewayCronJobDetail,
edit: GatewayCronJobEdit,
): String {
val name = edit.name.trim()
require(name.isNotEmpty()) { "Cron job name is required." }
val description = edit.description.trim()
val sessionTarget = edit.sessionTarget.trim()
require(
sessionTarget == "main" ||
sessionTarget == "isolated" ||
sessionTarget == "current" ||
(sessionTarget.startsWith("session:") && sessionTarget.removePrefix("session:").isNotBlank()),
) { "Session target must be main, isolated, current, or session:<id>." }
val wakeMode = edit.wakeMode.trim()
require(wakeMode == "now" || wakeMode == "next-heartbeat") {
"Wake mode must be now or next-heartbeat."
}
val schedulePatch = buildCronSchedulePatch(original = original, edit = edit.schedule)
val payloadPatch = buildCronPayloadPatch(original = original, edit = edit.payload)
val patch =
buildJsonObject {
if (name != original.name) put("name", JsonPrimitive(name))
if (description != original.description) put("description", JsonPrimitive(description))
if (edit.enabled != original.enabled) put("enabled", JsonPrimitive(edit.enabled))
if (edit.deleteAfterRun != original.deleteAfterRun) {
put("deleteAfterRun", JsonPrimitive(edit.deleteAfterRun))
}
schedulePatch?.let { put("schedule", it) }
if (sessionTarget != original.sessionTarget) {
put("sessionTarget", JsonPrimitive(sessionTarget))
}
if (wakeMode != original.wakeMode) put("wakeMode", JsonPrimitive(wakeMode))
payloadPatch?.let { put("payload", it) }
}
require(patch.isNotEmpty()) { "No cron changes to save." }
val configRevision =
requireNotNull(original.configRevision) {
"Update the gateway before saving cron changes from Android."
}
return buildJsonObject {
put("id", JsonPrimitive(original.id))
put("expectedConfigRevision", JsonPrimitive(configRevision))
put("patch", patch)
}.toString()
}
internal fun parseGatewayCronRunOutcome(root: JsonObject?): GatewayCronRunOutcome? {
val value = root ?: return null
val ok = value.optionalBoolean("ok") ?: return null
if (!ok) return GatewayCronRunOutcome.Rejected
if (value.optionalBoolean("ran") == true) {
return GatewayCronRunOutcome.Started(runId = value.string("runId"))
}
if (value.optionalBoolean("enqueued") == true) {
val runId = value.string("runId") ?: return null
return GatewayCronRunOutcome.Started(runId = runId)
}
if (value.optionalBoolean("ran") != false) return null
val reason =
when (value.string("reason")) {
"not-due" -> GatewayCronRunSkipReason.NotDue
"already-running" -> GatewayCronRunSkipReason.AlreadyRunning
"restart-recovery-pending" -> GatewayCronRunSkipReason.RestartRecoveryPending
"invalid-spec" -> GatewayCronRunSkipReason.InvalidSpec
"stopped" -> GatewayCronRunSkipReason.Stopped
else -> return null
}
return GatewayCronRunOutcome.Skipped(reason)
}
internal fun parseGatewayCronRunHistory(entries: JsonArray?): List<GatewayCronRunSummary> =
entries
?.mapNotNull { item ->
val value = item.asObjectOrNull() ?: return@mapNotNull null
val ts = value.long("ts") ?: return@mapNotNull null
GatewayCronRunSummary(
ts = ts,
runId = value.string("runId"),
status = value.string("status"),
summary = value.string("summary"),
error = value.string("error"),
durationMs = value.long("durationMs"),
deliveryStatus = value.string("deliveryStatus"),
sessionKey = value.string("sessionKey"),
model = value.string("model"),
)
}.orEmpty()
private fun buildCronSchedulePatch(
original: GatewayCronJobDetail,
edit: GatewayCronScheduleEdit,
): JsonObject? =
when (edit) {
is GatewayCronScheduleEdit.At -> {
require(original.scheduleKind == "at") { "Changing schedule type is not supported here." }
val at = edit.at.trim()
require(at.isNotEmpty()) { "One-time cron jobs need an ISO time." }
if (at == original.scheduleAt) {
null
} else {
buildJsonObject {
put("kind", JsonPrimitive("at"))
put("at", JsonPrimitive(at))
}
}
}
is GatewayCronScheduleEdit.Every -> {
require(original.scheduleKind == "every") { "Changing schedule type is not supported here." }
val everyMs = edit.everyMs.trim().toLongOrNull()
require(everyMs != null && everyMs > 0L) { "Interval must be a positive number of milliseconds." }
val anchorMs = parseOptionalNonNegativeLong(edit.anchorMs, "Anchor")
if (everyMs == original.scheduleEveryMs && anchorMs == original.scheduleAnchorMs) {
null
} else {
buildJsonObject {
put("kind", JsonPrimitive("every"))
put("everyMs", JsonPrimitive(everyMs))
anchorMs?.let { put("anchorMs", JsonPrimitive(it)) }
}
}
}
is GatewayCronScheduleEdit.Cron -> {
require(original.scheduleKind == "cron") { "Changing schedule type is not supported here." }
val expression = edit.expression.trim()
require(expression.isNotEmpty()) { "Cron expression is required." }
val timezone = edit.timezone.trim().ifEmpty { null }
val requestedStaggerMs = parseOptionalNonNegativeLong(edit.staggerMs, "Stagger")
val staggerMs =
requestedStaggerMs ?: if (original.scheduleStaggerMs != null) 0L else null
if (
expression == original.scheduleCronExpr &&
timezone == original.scheduleTimezone &&
staggerMs == original.scheduleStaggerMs
) {
null
} else {
buildJsonObject {
put("kind", JsonPrimitive("cron"))
put("expr", JsonPrimitive(expression))
timezone?.let { put("tz", JsonPrimitive(it)) }
staggerMs?.let { put("staggerMs", JsonPrimitive(it)) }
}
}
}
is GatewayCronScheduleEdit.OnExit -> {
require(original.scheduleKind == "on-exit") { "Changing schedule type is not supported here." }
val command = edit.command.trim()
require(command.isNotEmpty()) { "On-exit cron jobs need a command." }
val cwd = edit.cwd.trim().ifEmpty { null }
if (command == original.scheduleCommand && cwd == original.scheduleCwd) {
null
} else {
buildJsonObject {
put("kind", JsonPrimitive("on-exit"))
put("command", JsonPrimitive(command))
cwd?.let { put("cwd", JsonPrimitive(it)) }
}
}
}
}
private fun buildCronPayloadPatch(
original: GatewayCronJobDetail,
edit: GatewayCronPayloadEdit,
): JsonObject? =
when (edit) {
is GatewayCronPayloadEdit.SystemEvent -> {
require(original.payloadKind == "systemEvent") { "Changing payload type is not supported here." }
val text = edit.text.trim()
require(text.isNotEmpty()) { "System event text is required." }
if (text == original.payloadText) {
null
} else {
buildJsonObject {
put("kind", JsonPrimitive("systemEvent"))
put("text", JsonPrimitive(text))
}
}
}
is GatewayCronPayloadEdit.AgentTurn -> {
require(original.payloadKind == "agentTurn") { "Changing payload type is not supported here." }
val message = edit.message.trim()
require(message.isNotEmpty()) { "Agent message is required." }
val model = edit.model.trim().ifEmpty { null }
val thinking = edit.thinking.trim().ifEmpty { null }
if (
message == original.payloadText &&
model == original.payloadModel &&
thinking == original.payloadThinking
) {
null
} else {
buildJsonObject {
put("kind", JsonPrimitive("agentTurn"))
if (message != original.payloadText) put("message", JsonPrimitive(message))
if (model != original.payloadModel) put("model", model?.let(::JsonPrimitive) ?: JsonNull)
if (thinking != original.payloadThinking) {
put("thinking", thinking?.let(::JsonPrimitive) ?: JsonNull)
}
}
}
}
is GatewayCronPayloadEdit.Command -> {
require(original.payloadKind == "command") { "Changing payload type is not supported here." }
val argv = parseCommandArgv(edit.argvJson)
val cwd = edit.cwd.trim().ifEmpty { null }
if (cwd == null && original.payloadCommandCwd != null) {
error("The gateway does not support clearing a command working directory.")
}
if (argv == original.payloadCommandArgv && cwd == original.payloadCommandCwd) {
null
} else {
buildJsonObject {
put("kind", JsonPrimitive("command"))
if (argv != original.payloadCommandArgv) {
put("argv", JsonArray(argv.map(::JsonPrimitive)))
}
if (cwd != original.payloadCommandCwd) put("cwd", JsonPrimitive(requireNotNull(cwd)))
}
}
}
}
private fun parseCommandArgv(raw: String): List<String> {
val value =
runCatching { Json.parseToJsonElement(raw) }.getOrNull() as? JsonArray
?: error("Command argv must be a JSON array.")
val argv =
value.map { item ->
val primitive = item as? JsonPrimitive
primitive?.takeIf { it.isString }?.content?.takeIf { it.isNotEmpty() }
?: error("Command argv entries must be non-empty strings.")
}
require(argv.isNotEmpty()) { "Command argv must contain at least one entry." }
return argv
}
private fun parseOptionalNonNegativeLong(
raw: String,
label: String,
): Long? {
val value = raw.trim()
if (value.isEmpty()) return null
val parsed = value.toLongOrNull()
require(parsed != null && parsed >= 0L) { "$label must be a non-negative number of milliseconds." }
return parsed
}
private fun JsonObject.string(key: String): String? =
this[key]
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
private fun JsonObject.long(key: String): Long? =
(this[key] as? JsonPrimitive)
?.content
?.trim()
?.toLongOrNull()
private fun JsonObject.optionalBoolean(key: String): Boolean? = (this[key] as? JsonPrimitive)?.booleanOrNull
@@ -51,6 +51,27 @@ internal fun shouldStartRuntimeOnForeground(
onboardingCompleted: Boolean,
): Boolean = foreground && onboardingCompleted
internal class CronEditorDraftMemory {
private var retained: Pair<String, CronEditorDraftState>? = null
fun get(jobId: String): CronEditorDraftState? = retained?.takeIf { it.first == jobId }?.second
fun set(
jobId: String,
state: CronEditorDraftState?,
) {
if (state == null) {
clear(jobId)
} else {
retained = jobId to state
}
}
fun clear(jobId: String) {
if (retained?.first == jobId) retained = null
}
}
/**
* UI-facing bridge that exposes NodeRuntime and preference state as Compose-friendly StateFlows.
*/
@@ -64,6 +85,10 @@ class MainViewModel(
private val gatewayConfigOperationSeq = AtomicLong()
private val gatewayConfigOperationMutex = Mutex()
// One bounded heap-only slot follows the ViewModel across Activity recreation.
// Detail disposal clears it; process death drops it with the ViewModel.
internal val cronEditorDraftMemory = CronEditorDraftMemory()
@Volatile private var permissionRequester: PermissionRequester? = null
@Volatile private var foreground = false
@@ -196,6 +221,9 @@ class MainViewModel(
val cronRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.cronRefreshing }
val cronErrorText: StateFlow<String?> = runtimeState(initial = null) { it.cronErrorText }
val cronJobDetailState: StateFlow<GatewayCronJobDetailState> = runtimeState(initial = GatewayCronJobDetailState.Idle) { it.cronJobDetailState }
val cronRunHistoryState: StateFlow<GatewayCronRunHistoryState> = runtimeState(initial = GatewayCronRunHistoryState.Idle) { it.cronRunHistoryState }
val cronActionState: StateFlow<GatewayCronActionState> = runtimeState(initial = GatewayCronActionState.Idle) { it.cronActionState }
val pendingCronRunJobIds: StateFlow<Set<String>> = runtimeState(initial = emptySet()) { it.pendingCronRunJobIds }
val usageSummary: StateFlow<GatewayUsageSummary> = runtimeState(initial = GatewayUsageSummary(updatedAtMs = null, providers = emptyList())) { it.usageSummary }
val usageRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.usageRefreshing }
val usageErrorText: StateFlow<String?> = runtimeState(initial = null) { it.usageErrorText }
@@ -736,10 +764,40 @@ class MainViewModel(
ensureRuntime().loadCronJobDetail(id)
}
fun refreshCronRunHistory(id: String) {
ensureRuntime().refreshCronRunHistory(id)
}
fun clearCronJobDetail() {
ensureRuntime().clearCronJobDetail()
}
fun dismissCronActionNotice(id: String) {
ensureRuntime().dismissCronActionNotice(id)
}
fun runCronJob(id: String) {
ensureRuntime().runCronJob(id)
}
fun setCronJobEnabled(
id: String,
enabled: Boolean,
) {
ensureRuntime().setCronJobEnabled(id = id, enabled = enabled)
}
fun updateCronJob(
original: GatewayCronJobDetail,
edit: GatewayCronJobEdit,
) {
ensureRuntime().updateCronJob(original = original, edit = edit)
}
fun deleteCronJob(id: String) {
ensureRuntime().deleteCronJob(id)
}
fun refreshUsage() {
ensureRuntime().refreshUsage()
}
@@ -24,6 +24,7 @@ import ai.openclaw.app.gateway.GatewayDiscovery
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.gateway.GatewayRegistryEntry
import ai.openclaw.app.gateway.GatewayRegistryEntryKind
import ai.openclaw.app.gateway.GatewayRequestRejected
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.gateway.GatewayTlsProbeFailure
import ai.openclaw.app.gateway.GatewayTlsProbeResult
@@ -116,6 +117,7 @@ import java.util.concurrent.atomic.AtomicReference
private const val MAX_PENDING_NOTIFICATION_EVENTS = 128
private const val NODE_APPROVAL_COMMAND_FRESH_MS = 30_000L
private const val CRON_RUN_TRACKING_POLL_MS = 2_000L
private const val OperatorAdminScope = "operator.admin"
private enum class SkillWorkshopGatewayAction(
@@ -374,6 +376,13 @@ class NodeRuntime private constructor(
val generation: Long,
)
private data class CronActionResult(
val message: String,
val kind: GatewayCronNoticeKind,
val refresh: Boolean,
val deleted: Boolean = false,
)
constructor(
context: Context,
prefs: SecurePrefs = SecurePrefs(context.applicationContext),
@@ -715,7 +724,17 @@ class NodeRuntime private constructor(
val cronErrorText: StateFlow<String?> = _cronErrorText.asStateFlow()
private val _cronJobDetailState = MutableStateFlow<GatewayCronJobDetailState>(GatewayCronJobDetailState.Idle)
val cronJobDetailState: StateFlow<GatewayCronJobDetailState> = _cronJobDetailState.asStateFlow()
private val _cronRunHistoryState = MutableStateFlow<GatewayCronRunHistoryState>(GatewayCronRunHistoryState.Idle)
val cronRunHistoryState: StateFlow<GatewayCronRunHistoryState> = _cronRunHistoryState.asStateFlow()
private val _cronActionState = MutableStateFlow<GatewayCronActionState>(GatewayCronActionState.Idle)
val cronActionState: StateFlow<GatewayCronActionState> = _cronActionState.asStateFlow()
private val _pendingCronRunJobIds = MutableStateFlow<Set<String>>(emptySet())
val pendingCronRunJobIds: StateFlow<Set<String>> = _pendingCronRunJobIds.asStateFlow()
private val cronJobDetailRequestGuard = CronJobDetailRequestGuard()
private val cronRunHistoryRequestGuard = CronJobDetailRequestGuard()
private val cronRefreshGuard = LatestGatewayRefreshGuard()
private val cronActionMutex = Mutex()
private val pendingCronRunRegistry = PendingCronRunRegistry()
private val _usageSummary = MutableStateFlow(GatewayUsageSummary(updatedAtMs = null, providers = emptyList()))
val usageSummary: StateFlow<GatewayUsageSummary> = _usageSummary.asStateFlow()
private val _usageRefreshing = MutableStateFlow(false)
@@ -756,7 +775,7 @@ class NodeRuntime private constructor(
val nodesDevicesRefreshing: StateFlow<Boolean> = _nodesDevicesRefreshing.asStateFlow()
private val _nodesDevicesErrorText = MutableStateFlow<String?>(null)
val nodesDevicesErrorText: StateFlow<String?> = _nodesDevicesErrorText.asStateFlow()
private val nodeApprovalRefreshGuard = GatewayNodeApprovalRefreshGuard()
private val nodeApprovalRefreshGuard = LatestGatewayRefreshGuard()
private val _execApprovals = MutableStateFlow<List<GatewayExecApprovalSummary>>(emptyList())
val execApprovals: StateFlow<List<GatewayExecApprovalSummary>> = _execApprovals.asStateFlow()
private val _execApprovalsRefreshing = MutableStateFlow(false)
@@ -848,7 +867,7 @@ class NodeRuntime private constructor(
}
},
onDisconnected = { message ->
clearOperatorGatewayState()
clearOperatorGatewayState(retirePendingCronRuns = false)
chat.applyMainSessionKey(resolveMainSessionKey())
chat.onDisconnected(message)
updateStatus {
@@ -869,7 +888,7 @@ class NodeRuntime private constructor(
customHeadersProvider = prefs::loadGatewayCustomHeaders,
)
private fun clearOperatorGatewayState() {
private fun clearOperatorGatewayState(retirePendingCronRuns: Boolean) {
invalidateNodeCapabilityApprovalState()
_serverName.value = null
_remoteAddress.value = null
@@ -885,11 +904,17 @@ class NodeRuntime private constructor(
_modelCatalogRefreshing.value = false
_modelCatalogErrorText.value = null
_talkSetupReadiness.value = GatewayTalkSetupReadiness.unverified()
cronRefreshGuard.invalidate()
_cronStatus.value = GatewayCronStatus(enabled = false, jobs = 0, nextWakeAtMs = null)
_cronJobs.value = emptyList()
_cronRefreshing.value = false
_cronErrorText.value = null
cronJobDetailRequestGuard.cancel { _cronJobDetailState.value = GatewayCronJobDetailState.Idle }
cronRunHistoryRequestGuard.cancel { _cronRunHistoryState.value = GatewayCronRunHistoryState.Idle }
_cronActionState.value = GatewayCronActionState.Idle
if (retirePendingCronRuns) {
pendingCronRunRegistry.clear { _pendingCronRunJobIds.value = it }
}
_usageSummary.value = GatewayUsageSummary(updatedAtMs = null, providers = emptyList())
_usageRefreshing.value = false
_usageErrorText.value = null
@@ -1408,17 +1433,174 @@ class NodeRuntime private constructor(
}
fun loadCronJobDetail(id: String) {
val request = cronJobDetailRequestGuard.begin(id) ?: return
_cronJobDetailState.value = GatewayCronJobDetailState.Loading(request.id)
scope.launch {
loadCronJobDetailFromGateway(request)
val detailRequest = cronJobDetailRequestGuard.begin(id) ?: return
val historyRequest = cronRunHistoryRequestGuard.begin(detailRequest.id) ?: return
_cronJobDetailState.value = GatewayCronJobDetailState.Loading(detailRequest.id)
_cronRunHistoryState.value = GatewayCronRunHistoryState.Loading(historyRequest.id)
if (mode == NodeRuntimeMode.ScreenshotFixture) {
applyScreenshotCronDetail(detailRequest = detailRequest, historyRequest = historyRequest)
return
}
scope.launch { loadCronJobDetailFromGateway(detailRequest) }
scope.launch { loadCronRunHistoryFromGateway(historyRequest) }
}
fun refreshCronRunHistory(id: String) {
val request = cronRunHistoryRequestGuard.begin(id) ?: return
_cronRunHistoryState.value = GatewayCronRunHistoryState.Loading(request.id)
if (mode == NodeRuntimeMode.ScreenshotFixture) {
publishScreenshotCronHistory(request)
return
}
scope.launch { loadCronRunHistoryFromGateway(request) }
}
fun clearCronJobDetail() {
cronJobDetailRequestGuard.cancel {
_cronJobDetailState.value = GatewayCronJobDetailState.Idle
}
cronRunHistoryRequestGuard.cancel {
_cronRunHistoryState.value = GatewayCronRunHistoryState.Idle
}
}
fun dismissCronActionNotice(id: String) {
val jobId = id.trim().takeIf { it.isNotEmpty() } ?: return
val notice = _cronActionState.value as? GatewayCronActionState.Notice
if (notice?.id == jobId) {
_cronActionState.value = GatewayCronActionState.Idle
}
}
fun runCronJob(id: String) {
val jobId = id.trim().takeIf { it.isNotEmpty() } ?: return
if (pendingCronRunRegistry.contains(jobId)) {
_cronActionState.value =
GatewayCronActionState.Notice(
id = jobId,
message = "This cron job already has a queued run.",
kind = GatewayCronNoticeKind.Warning,
)
return
}
launchCronAction(id = jobId, action = GatewayCronAction.Run) { gatewayScope, actionJobId ->
val response =
requestGatewayData(
gatewayScope,
"cron.run",
buildJsonObject {
put("id", JsonPrimitive(actionJobId))
put("mode", JsonPrimitive("force"))
}.toString(),
)
when (val outcome = parseGatewayCronRunOutcome(json.parseToJsonElement(response).asObjectOrNull())) {
is GatewayCronRunOutcome.Started -> {
outcome.runId?.let { runId ->
var trackingStarted = false
publishGatewayData(gatewayScope) {
trackingStarted =
pendingCronRunRegistry.begin(actionJobId, runId) {
_pendingCronRunJobIds.value = it
}
}
if (trackingStarted) {
trackQueuedCronRun(gatewayScope = gatewayScope, jobId = actionJobId, runId = runId)
}
}
CronActionResult(
message = if (outcome.runId == null) "Cron job started." else "Cron run queued.",
kind = GatewayCronNoticeKind.Success,
refresh = cronRunShouldRefresh(outcome),
)
}
is GatewayCronRunOutcome.Skipped ->
CronActionResult(
message = outcome.reason.message,
kind = GatewayCronNoticeKind.Warning,
refresh = cronRunShouldRefresh(outcome),
)
GatewayCronRunOutcome.Rejected ->
CronActionResult(
message = "Gateway rejected the cron run.",
kind = GatewayCronNoticeKind.Error,
refresh = false,
)
null -> error("Gateway returned an invalid cron run result.")
}
}
}
fun setCronJobEnabled(
id: String,
enabled: Boolean,
) {
launchCronAction(
id = id,
action = if (enabled) GatewayCronAction.Enable else GatewayCronAction.Disable,
) { gatewayScope, jobId ->
requestGatewayData(
gatewayScope,
"cron.update",
buildJsonObject {
put("id", JsonPrimitive(jobId))
put(
"patch",
buildJsonObject {
put("enabled", JsonPrimitive(enabled))
},
)
}.toString(),
)
CronActionResult(
message = if (enabled) "Cron job enabled." else "Cron job disabled.",
kind = GatewayCronNoticeKind.Success,
refresh = true,
)
}
}
fun updateCronJob(
original: GatewayCronJobDetail,
edit: GatewayCronJobEdit,
) {
launchCronAction(id = original.id, action = GatewayCronAction.Save) { gatewayScope, _ ->
try {
requestGatewayData(
gatewayScope,
"cron.update",
buildCronUpdateParams(original = original, edit = edit),
)
} catch (err: GatewayRequestRejected) {
if (!isCronJobRevisionConflict(err.gatewayError)) throw err
reloadCronJobIfSelected(original.id)
return@launchCronAction CronActionResult(
message = "This cron job changed on the gateway. Review the latest version before saving again.",
kind = GatewayCronNoticeKind.Warning,
refresh = false,
)
}
CronActionResult(
message = "Cron job updated.",
kind = GatewayCronNoticeKind.Success,
refresh = true,
)
}
}
fun deleteCronJob(id: String) {
launchCronAction(id = id, action = GatewayCronAction.Delete) { gatewayScope, jobId ->
requestGatewayData(
gatewayScope,
"cron.remove",
buildJsonObject { put("id", JsonPrimitive(jobId)) }.toString(),
)
CronActionResult(
message = "Cron job deleted.",
kind = GatewayCronNoticeKind.Success,
refresh = true,
deleted = true,
)
}
}
fun refreshUsage() {
@@ -1729,9 +1911,11 @@ class NodeRuntime private constructor(
_cronStatus.value =
GatewayCronStatus(
enabled = true,
jobs = 2,
jobs = 1,
nextWakeAtMs = 1_783_641_600_000,
)
_cronJobs.value = parseScreenshotCronJobs()
_operatorScopes.value = listOf(OperatorAdminScope)
_nodesDevicesSummary.value = AndroidScreenshotFixture.nodes
_channelsSummary.value = AndroidScreenshotFixture.channels
_nodeCapabilityApproval.value = GatewayNodeCapabilityApproval.Approved
@@ -1748,6 +1932,44 @@ class NodeRuntime private constructor(
chat.refreshSessions(limit = 20)
}
private fun parseScreenshotCronJobs(): List<GatewayCronJobSummary> {
// Screenshot mode parses gateway-shaped fixtures so UI navigation covers the live data contract.
val list =
json
.parseToJsonElement(AndroidScreenshotFixture.request("cron.list", null))
.asObjectOrNull()
return parseCronJobs(list?.get("jobs") as? JsonArray)
}
private fun applyScreenshotCronDetail(
detailRequest: CronJobDetailRequest,
historyRequest: CronJobDetailRequest,
) {
val detail =
json
.parseToJsonElement(AndroidScreenshotFixture.request("cron.get", cronJobGetParams(detailRequest.id)))
.asObjectOrNull()
?.let(::parseGatewayCronJobDetail)
?.takeIf { it.id == detailRequest.id }
cronJobDetailRequestGuard.publishIfCurrent(detailRequest) {
_cronJobDetailState.value =
detail?.let(GatewayCronJobDetailState::Loaded)
?: GatewayCronJobDetailState.Error(detailRequest.id, "Gateway returned an invalid cron job.")
}
publishScreenshotCronHistory(historyRequest)
}
private fun publishScreenshotCronHistory(request: CronJobDetailRequest) {
val history =
json
.parseToJsonElement(AndroidScreenshotFixture.request("cron.runs", cronJobGetParams(request.id)))
.asObjectOrNull()
val runs = parseGatewayCronRunHistory(history?.get("entries") as? JsonArray)
cronRunHistoryRequestGuard.publishIfCurrent(request) {
_cronRunHistoryState.value = GatewayCronRunHistoryState.Loaded(id = request.id, runs = runs)
}
}
init {
if (mode == NodeRuntimeMode.Live) {
if (prefs.voiceWakeMode.value != VoiceWakeMode.Off) {
@@ -3126,7 +3348,7 @@ class NodeRuntime private constructor(
connectAttemptSeq.incrementAndGet()
synchronized(gatewayDataScopeLock) {
gatewayDataGeneration += 1
clearOperatorGatewayState()
clearOperatorGatewayState(retirePendingCronRuns = true)
}
chat.onGatewayScopeChanging(retireRunState)
stopMessageSpeech()
@@ -3474,6 +3696,15 @@ class NodeRuntime private constructor(
}
}
private inline fun publishCronRefresh(
gatewayScope: GatewayDataScope,
refreshGeneration: Long,
crossinline publish: () -> Unit,
): Boolean =
publishGatewayData(gatewayScope) {
cronRefreshGuard.publishIfCurrent(refreshGeneration) { publish() }
}
private suspend fun refreshBrandingFromGateway() {
val gatewayScope = captureGatewayDataScope() ?: return
if (!gatewayConnectionDisplay.value.isConnected) return
@@ -3611,15 +3842,18 @@ class NodeRuntime private constructor(
}
private suspend fun refreshCronFromGateway() {
val refreshGeneration = cronRefreshGuard.begin()
val gatewayScope = captureGatewayDataScope() ?: return
publishGatewayData(gatewayScope) {
publishCronRefresh(gatewayScope, refreshGeneration) {
_cronRefreshing.value = true
_cronErrorText.value = null
}
if (!operatorConnected) {
_cronStatus.value = GatewayCronStatus(enabled = false, jobs = 0, nextWakeAtMs = null)
_cronJobs.value = emptyList()
_cronRefreshing.value = false
publishCronRefresh(gatewayScope, refreshGeneration) {
_cronStatus.value = GatewayCronStatus(enabled = false, jobs = 0, nextWakeAtMs = null)
_cronJobs.value = emptyList()
_cronRefreshing.value = false
}
return
}
try {
@@ -3635,14 +3869,18 @@ class NodeRuntime private constructor(
val listRes = requestGatewayData(gatewayScope, "cron.list", """{"includeDisabled":true,"limit":20,"sortBy":"nextRunAtMs","sortDir":"asc"}""")
val listRoot = json.parseToJsonElement(listRes).asObjectOrNull()
val jobs = parseCronJobs(listRoot?.get("jobs") as? JsonArray)
publishGatewayData(gatewayScope) {
publishCronRefresh(gatewayScope, refreshGeneration) {
_cronStatus.value = status
_cronJobs.value = jobs
}
} catch (_: Throwable) {
publishGatewayData(gatewayScope) { _cronErrorText.value = "Could not load cron jobs." }
publishCronRefresh(gatewayScope, refreshGeneration) {
_cronErrorText.value = "Could not load cron jobs."
}
} finally {
publishGatewayData(gatewayScope) { _cronRefreshing.value = false }
publishCronRefresh(gatewayScope, refreshGeneration) {
_cronRefreshing.value = false
}
}
}
@@ -3669,6 +3907,230 @@ class NodeRuntime private constructor(
}
}
private suspend fun loadCronRunHistoryFromGateway(request: CronJobDetailRequest) {
val gatewayScope = captureGatewayDataScope() ?: return
if (!operatorConnected) {
cronRunHistoryRequestGuard.publishIfCurrent(request) {
_cronRunHistoryState.value =
GatewayCronRunHistoryState.Error(
id = request.id,
message = "Connect the gateway to inspect cron run history.",
)
}
return
}
try {
val response =
requestGatewayData(
gatewayScope,
"cron.runs",
buildJsonObject {
put("id", JsonPrimitive(request.id))
put("limit", JsonPrimitive(20))
put("sortDir", JsonPrimitive("desc"))
}.toString(),
)
val root = json.parseToJsonElement(response).asObjectOrNull()
val runs = parseGatewayCronRunHistory(root?.get("entries") as? JsonArray)
publishGatewayData(gatewayScope) {
cronRunHistoryRequestGuard.publishIfCurrent(request) {
_cronRunHistoryState.value = GatewayCronRunHistoryState.Loaded(id = request.id, runs = runs)
}
}
} catch (err: CancellationException) {
throw err
} catch (_: Throwable) {
publishGatewayData(gatewayScope) {
cronRunHistoryRequestGuard.publishIfCurrent(request) {
_cronRunHistoryState.value =
GatewayCronRunHistoryState.Error(
id = request.id,
message = "Could not load cron run history.",
)
}
}
}
}
private fun launchCronAction(
id: String,
action: GatewayCronAction,
perform: suspend (GatewayDataScope, String) -> CronActionResult,
) {
val jobId = id.trim().takeIf { it.isNotEmpty() } ?: return
if (!operatorAdminScopeAvailable.value) {
_cronActionState.value =
GatewayCronActionState.Notice(
id = jobId,
message = "Cron changes require operator.admin access.",
kind = GatewayCronNoticeKind.Error,
)
return
}
if (!operatorConnected) {
_cronActionState.value =
GatewayCronActionState.Notice(
id = jobId,
message = "Connect the gateway to manage cron jobs.",
kind = GatewayCronNoticeKind.Error,
)
return
}
if (_cronActionState.value is GatewayCronActionState.Running) return
// One mutating RPC at a time keeps button taps and programmatic calls from racing.
if (!cronActionMutex.tryLock()) {
if (_cronActionState.value !is GatewayCronActionState.Running) {
_cronActionState.value =
GatewayCronActionState.Notice(
id = jobId,
message = "Another cron action is still finishing.",
kind = GatewayCronNoticeKind.Warning,
)
}
return
}
// Publish ownership before returning to Compose so Activity recreation can
// distinguish a retained Save from dead pending state after process death.
val actionScope = captureGatewayDataScope()
if (actionScope == null) {
cronActionMutex.unlock()
return
}
val started =
publishGatewayData(actionScope) {
_cronActionState.value = GatewayCronActionState.Running(id = jobId, action = action)
}
if (!started) {
cronActionMutex.unlock()
return
}
scope.launch {
var completionState: GatewayCronActionState.Notice? = null
try {
val result = perform(actionScope, jobId)
if (result.deleted) {
clearDeletedCronSelection(jobId)
}
if (result.refresh) {
refreshCronFromGateway()
if (!result.deleted) reloadCronJobIfSelected(jobId)
}
completionState =
GatewayCronActionState.Notice(
id = jobId,
message = result.message,
kind = result.kind,
deleted = result.deleted,
)
} catch (err: CancellationException) {
throw err
} catch (err: Throwable) {
val message = err.message?.trim()?.takeIf { it.isNotEmpty() } ?: "Cron action failed."
completionState =
GatewayCronActionState.Notice(
id = jobId,
message = message,
kind = GatewayCronNoticeKind.Error,
)
} finally {
cronActionMutex.unlock()
val notice = completionState
if (notice != null) {
publishGatewayData(actionScope) {
_cronActionState.value = notice
}
}
}
}
}
private fun reloadCronJobIfSelected(jobId: String) {
// Ownership checks and loading publication stay under each guard's lock;
// navigation that wins afterward invalidates these requests before publish.
val detailRequest =
cronJobDetailRequestGuard.beginIfCurrent(jobId) { request ->
_cronJobDetailState.value = GatewayCronJobDetailState.Loading(request.id)
}
val historyRequest =
cronRunHistoryRequestGuard.beginIfCurrent(jobId) { request ->
_cronRunHistoryState.value = GatewayCronRunHistoryState.Loading(request.id)
}
detailRequest?.let { scope.launch { loadCronJobDetailFromGateway(it) } }
historyRequest?.let { scope.launch { loadCronRunHistoryFromGateway(it) } }
}
private fun clearDeletedCronSelection(jobId: String) {
// A completed delete can race navigation to another job. Clear only state
// still owned by the deleted id so the newer detail/history survives.
cronJobDetailRequestGuard.cancelIfCurrent(jobId) {
_cronJobDetailState.value = GatewayCronJobDetailState.Idle
}
cronRunHistoryRequestGuard.cancelIfCurrent(jobId) {
_cronRunHistoryState.value = GatewayCronRunHistoryState.Idle
}
}
private fun trackQueuedCronRun(
gatewayScope: GatewayDataScope,
jobId: String,
runId: String,
) {
// cron.run acknowledges before lane admission. Track its exact run-log id
// so only this job stays deduped until terminal evidence or scope retirement.
scope.launch {
var completedRun: GatewayCronRunSummary? = null
while (isGatewayDataScopeCurrent(gatewayScope) && completedRun == null) {
completedRun =
try {
val response =
requestGatewayData(
gatewayScope,
"cron.runs",
buildJsonObject {
put("id", JsonPrimitive(jobId))
put("runId", JsonPrimitive(runId))
put("limit", JsonPrimitive(1))
put("sortDir", JsonPrimitive("desc"))
}.toString(),
)
val root = json.parseToJsonElement(response).asObjectOrNull()
parseGatewayCronRunHistory(root?.get("entries") as? JsonArray)
.firstOrNull { it.runId == runId }
} catch (err: CancellationException) {
throw err
} catch (_: Throwable) {
if (!isGatewayDataScopeCurrent(gatewayScope)) return@launch
null
}
if (completedRun == null) delay(CRON_RUN_TRACKING_POLL_MS)
}
if (!isGatewayDataScopeCurrent(gatewayScope)) return@launch
val terminalRun = completedRun ?: return@launch
var pendingCleared = false
val scopeCurrent =
publishGatewayData(gatewayScope) {
pendingCleared =
pendingCronRunRegistry.finish(jobId, runId) {
_pendingCronRunJobIds.value = it
}
}
if (!scopeCurrent || !pendingCleared) return@launch
refreshCronFromGateway()
reloadCronJobIfSelected(jobId)
publishGatewayData(gatewayScope) {
val currentAction = _cronActionState.value
val canPublish =
currentAction == GatewayCronActionState.Idle ||
(currentAction is GatewayCronActionState.Notice && currentAction.id == jobId)
if (canPublish) {
_cronActionState.value = cronRunCompletionNotice(jobId, terminalRun.status)
}
}
}
}
private suspend fun refreshUsageFromGateway() {
val gatewayScope = captureGatewayDataScope() ?: return
publishGatewayData(gatewayScope) {
@@ -5339,8 +5801,8 @@ internal fun GatewayNodeCapabilityApproval.withoutExactRequestId(): GatewayNodeC
internal fun GatewayNodesDevicesSummary.withoutExactApprovalRequestIds(): GatewayNodesDevicesSummary = copy(nodes = nodes.map { node -> node.copy(pendingRequestId = null) })
/** Prevents older node.list responses from overwriting newer approval state. */
internal class GatewayNodeApprovalRefreshGuard {
/** Prevents an older gateway response from publishing after a newer refresh begins. */
internal class LatestGatewayRefreshGuard {
private val lock = Any()
private var generation = 0L
@@ -5350,6 +5812,10 @@ internal class GatewayNodeApprovalRefreshGuard {
generation
}
fun invalidate() {
begin()
}
fun publishIfCurrent(
refreshGeneration: Long,
publish: () -> Unit,
@@ -0,0 +1,642 @@
package ai.openclaw.app.ui
import ai.openclaw.app.CronEditorDraftState
import ai.openclaw.app.GatewayCronActionState
import ai.openclaw.app.GatewayCronJobDetail
import ai.openclaw.app.GatewayCronJobEdit
import ai.openclaw.app.GatewayCronNoticeKind
import ai.openclaw.app.GatewayCronPayloadEdit
import ai.openclaw.app.GatewayCronRunHistoryState
import ai.openclaw.app.GatewayCronRunSummary
import ai.openclaw.app.GatewayCronScheduleEdit
import ai.openclaw.app.ui.design.ClawDetailRow
import ai.openclaw.app.ui.design.ClawIconBadge
import ai.openclaw.app.ui.design.ClawListPanel
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawPrimaryButton
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawSegmentedControl
import ai.openclaw.app.ui.design.ClawStatus
import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawTextField
import ai.openclaw.app.ui.design.ClawTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import java.text.DateFormat
import java.util.Date
@Composable
internal fun CronJobManagementPanel(
job: GatewayCronJobDetail,
editorDraft: CronEditorDraftState,
onEditorDraftChange: (CronEditorDraftState) -> Unit,
historyState: GatewayCronRunHistoryState,
actionState: GatewayCronActionState,
runPending: Boolean,
operatorAdminScopeAvailable: Boolean,
onRun: () -> Unit,
onToggleEnabled: () -> Unit,
onSave: (GatewayCronJobEdit) -> Unit,
onRefreshHistory: () -> Unit,
onDelete: () -> Unit,
) {
val busy = actionState is GatewayCronActionState.Running
val notice = (actionState as? GatewayCronActionState.Notice)?.takeIf { it.id == job.id }
var showDeleteConfirmation by remember(job.id) { mutableStateOf(false) }
if (showDeleteConfirmation) {
AlertDialog(
onDismissRequest = { showDeleteConfirmation = false },
confirmButton = {
TextButton(
onClick = {
showDeleteConfirmation = false
onDelete()
},
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = { showDeleteConfirmation = false }) {
Text("Cancel")
}
},
title = { Text("Delete cron job?") },
text = { Text("This permanently removes the scheduled job from the gateway.") },
)
}
notice?.let { value ->
ClawPanel {
Text(
text = value.message,
style = ClawTheme.type.body,
color =
when (value.kind) {
GatewayCronNoticeKind.Success -> ClawTheme.colors.success
GatewayCronNoticeKind.Warning -> ClawTheme.colors.warning
GatewayCronNoticeKind.Error -> ClawTheme.colors.danger
},
)
}
}
if (!operatorAdminScopeAvailable) CronAdminAccessPanel()
if (editorDraft.requiresResolution) {
ClawPanel {
Text(
text =
if (editorDraft.hasIncomingConflict) {
"This job changed while you were editing. Revert to the latest gateway version before saving."
} else {
"Save or revert your edits before running, enabling, disabling, deleting, or refreshing this job."
},
style = ClawTheme.type.body,
color = ClawTheme.colors.warning,
)
}
}
CronActionPanel(
job = job,
enabled = operatorAdminScopeAvailable && !busy && !editorDraft.requiresResolution,
busy = busy,
runPending = runPending,
onRun = onRun,
onToggleEnabled = onToggleEnabled,
onDelete = { showDeleteConfirmation = true },
)
CronEditorPanel(
job = job,
draft = editorDraft,
onDraftChange = onEditorDraftChange,
enabled =
operatorAdminScopeAvailable &&
!busy &&
!editorDraft.savePending &&
!editorDraft.saveSucceeded,
canRevert = !busy && !editorDraft.savePending && !editorDraft.saveSucceeded,
busy = busy,
onSave = onSave,
)
CronRunHistoryPanel(
jobId = job.id,
state = historyState,
onRefresh = onRefreshHistory,
)
}
@Composable
private fun CronAdminAccessPanel() {
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(7.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Default.Lock,
contentDescription = null,
modifier = Modifier.size(17.dp),
tint = ClawTheme.colors.text,
)
Text(text = "Admin access required", style = ClawTheme.type.section, color = ClawTheme.colors.text)
}
Text(
text =
"Cron changes require operator.admin. Setup codes intentionally do not grant it. " +
"Reconnect with the gateway's shared token or password to request admin access. " +
"If this device still lacks it, approve the pending scope upgrade from an existing admin client.",
style = ClawTheme.type.body,
color = ClawTheme.colors.textMuted,
)
}
}
}
@Composable
private fun CronActionPanel(
job: GatewayCronJobDetail,
enabled: Boolean,
busy: Boolean,
runPending: Boolean,
onRun: () -> Unit,
onToggleEnabled: () -> Unit,
onDelete: () -> Unit,
) {
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
ClawPrimaryButton(
text =
when {
busy -> "Working"
runPending -> "Run Pending"
else -> "Run Now"
},
onClick = onRun,
modifier = Modifier.weight(1f),
enabled = enabled && !runPending,
icon = Icons.Default.PlayArrow,
)
ClawSecondaryButton(
text = if (job.enabled) "Disable" else "Enable",
onClick = onToggleEnabled,
modifier = Modifier.weight(1f),
enabled = enabled,
icon = if (job.enabled) Icons.Default.Pause else Icons.Default.PlayArrow,
)
}
ClawSecondaryButton(
text = "Delete Job",
onClick = onDelete,
modifier = Modifier.fillMaxWidth(),
enabled = enabled,
icon = Icons.Default.Delete,
)
}
}
}
@Composable
private fun CronEditorPanel(
job: GatewayCronJobDetail,
draft: CronEditorDraftState,
onDraftChange: (CronEditorDraftState) -> Unit,
enabled: Boolean,
canRevert: Boolean,
busy: Boolean,
onSave: (GatewayCronJobEdit) -> Unit,
) {
val edit = draft.edit
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(9.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = null,
modifier = Modifier.size(17.dp),
tint = ClawTheme.colors.text,
)
Text(text = "Edit Job", style = ClawTheme.type.section, color = ClawTheme.colors.text)
}
CronSwitchRow(
title = "Enabled",
subtitle = "Allow the scheduler to run this job.",
checked = edit.enabled,
onCheckedChange = { onDraftChange(draft.withEdit(edit.copy(enabled = it))) },
enabled = enabled,
)
if (edit.schedule is GatewayCronScheduleEdit.At) {
CronSwitchRow(
title = "Delete after run",
subtitle = "Remove this job after a successful one-shot run.",
checked = edit.deleteAfterRun,
onCheckedChange = { onDraftChange(draft.withEdit(edit.copy(deleteAfterRun = it))) },
enabled = enabled,
)
}
ClawTextField(
value = edit.name,
onValueChange = { onDraftChange(draft.withEdit(edit.copy(name = it))) },
placeholder = "Job name",
label = "Name",
enabled = enabled,
)
ClawTextField(
value = edit.description,
onValueChange = { onDraftChange(draft.withEdit(edit.copy(description = it))) },
placeholder = "Optional description",
label = "Description",
enabled = enabled,
minLines = 2,
)
CronScheduleEditor(
schedule = edit.schedule,
enabled = enabled,
onChange = { onDraftChange(draft.withEdit(edit.withSchedule(it))) },
)
ClawTextField(
value = edit.sessionTarget,
onValueChange = { onDraftChange(draft.withEdit(edit.copy(sessionTarget = it))) },
placeholder = "main, isolated, current, or session:<id>",
label = "Session target",
enabled = enabled,
)
ClawSegmentedControl(
options = listOf("next-heartbeat", "now"),
selected = edit.wakeMode,
onSelect = { onDraftChange(draft.withEdit(edit.copy(wakeMode = it))) },
modifier = Modifier.fillMaxWidth(),
enabledOptions = if (enabled) setOf("next-heartbeat", "now") else emptySet(),
)
CronPayloadEditor(
payload = edit.payload,
originalCommandCwd = job.payloadCommandCwd,
enabled = enabled,
onChange = { onDraftChange(draft.withEdit(edit.copy(payload = it))) },
)
ClawPrimaryButton(
text = if (busy) "Working" else "Save Changes",
onClick = {
onDraftChange(draft.saveStarted())
onSave(edit)
},
modifier = Modifier.fillMaxWidth(),
enabled =
enabled &&
draft.isDirty &&
!draft.hasIncomingConflict &&
!draft.savePending &&
!draft.saveSucceeded,
icon = Icons.Default.Save,
)
if (draft.requiresResolution) {
ClawSecondaryButton(
text = "Revert Changes",
onClick = { onDraftChange(CronEditorDraftState.from(job)) },
modifier = Modifier.fillMaxWidth(),
enabled = canRevert,
)
}
}
}
}
@Composable
private fun CronSwitchRow(
title: String,
subtitle: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
enabled: Boolean,
) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 50.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.text)
Text(
text = subtitle,
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
enabled = enabled,
modifier = Modifier.semantics { contentDescription = title },
)
}
}
@Composable
private fun CronScheduleEditor(
schedule: GatewayCronScheduleEdit,
enabled: Boolean,
onChange: (GatewayCronScheduleEdit) -> Unit,
) {
Text(
text = "Schedule · ${cronScheduleKindLabel(schedule)}",
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
)
when (schedule) {
is GatewayCronScheduleEdit.At ->
ClawTextField(
value = schedule.at,
onValueChange = { onChange(schedule.copy(at = it)) },
placeholder = "ISO time, e.g. 2026-07-09T09:30:00Z",
label = "Run at",
enabled = enabled,
)
is GatewayCronScheduleEdit.Every -> {
ClawTextField(
value = schedule.everyMs,
onValueChange = { onChange(schedule.copy(everyMs = it.filter(Char::isDigit))) },
placeholder = "Milliseconds",
label = "Interval",
enabled = enabled,
)
ClawTextField(
value = schedule.anchorMs,
onValueChange = { onChange(schedule.copy(anchorMs = it.filter(Char::isDigit))) },
placeholder = "Epoch milliseconds (optional)",
label = "Anchor",
enabled = enabled,
)
}
is GatewayCronScheduleEdit.Cron -> {
ClawTextField(
value = schedule.expression,
onValueChange = { onChange(schedule.copy(expression = it)) },
placeholder = "Cron expression, e.g. 0 9 * * *",
label = "Expression",
enabled = enabled,
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ClawTextField(
value = schedule.timezone,
onValueChange = { onChange(schedule.copy(timezone = it)) },
placeholder = "e.g. America/New_York",
label = "Timezone",
enabled = enabled,
modifier = Modifier.weight(1f),
)
ClawTextField(
value = schedule.staggerMs,
onValueChange = { onChange(schedule.copy(staggerMs = it.filter(Char::isDigit))) },
placeholder = "0 = exact",
label = "Stagger ms",
enabled = enabled,
modifier = Modifier.weight(1f),
)
}
}
is GatewayCronScheduleEdit.OnExit -> {
ClawTextField(
value = schedule.command,
onValueChange = { onChange(schedule.copy(command = it)) },
placeholder = "Command to watch",
label = "Command",
enabled = enabled,
)
ClawTextField(
value = schedule.cwd,
onValueChange = { onChange(schedule.copy(cwd = it)) },
placeholder = "Optional path",
label = "Working directory",
enabled = enabled,
)
}
}
}
@Composable
private fun CronPayloadEditor(
payload: GatewayCronPayloadEdit,
originalCommandCwd: String?,
enabled: Boolean,
onChange: (GatewayCronPayloadEdit) -> Unit,
) {
Text(
text = "Payload · ${cronPayloadKindLabel(payload)}",
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
)
when (payload) {
is GatewayCronPayloadEdit.SystemEvent ->
ClawTextField(
value = payload.text,
onValueChange = { onChange(payload.copy(text = it)) },
placeholder = "System event text",
label = "Event text",
enabled = enabled,
minLines = 3,
)
is GatewayCronPayloadEdit.AgentTurn -> {
ClawTextField(
value = payload.message,
onValueChange = { onChange(payload.copy(message = it)) },
placeholder = "Agent message",
label = "Message",
enabled = enabled,
minLines = 3,
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ClawTextField(
value = payload.model,
onValueChange = { onChange(payload.copy(model = it)) },
placeholder = "Optional override",
label = "Model",
enabled = enabled,
modifier = Modifier.weight(1f),
)
ClawTextField(
value = payload.thinking,
onValueChange = { onChange(payload.copy(thinking = it)) },
placeholder = "Optional override",
label = "Thinking",
enabled = enabled,
modifier = Modifier.weight(1f),
)
}
}
is GatewayCronPayloadEdit.Command -> {
val commandCwdCanBeCleared = originalCommandCwd == null
ClawTextField(
value = payload.argvJson,
onValueChange = { onChange(payload.copy(argvJson = it)) },
placeholder = "Command argv JSON array",
label = "Arguments",
enabled = enabled,
minLines = 2,
)
ClawTextField(
value = payload.cwd,
onValueChange = { value ->
if (commandCwdCanBeCleared || value.isNotBlank()) {
onChange(payload.copy(cwd = value))
}
},
placeholder = "Optional path",
label =
if (commandCwdCanBeCleared) {
"Command working directory"
} else {
"Command working directory · cannot clear"
},
enabled = enabled,
)
if (!commandCwdCanBeCleared) {
Text(
text = "The gateway can change this path but cannot clear an existing path.",
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
)
}
}
}
}
@Composable
private fun CronRunHistoryPanel(
jobId: String,
state: GatewayCronRunHistoryState,
onRefresh: () -> Unit,
) {
val loading = (state as? GatewayCronRunHistoryState.Loading)?.id == jobId
val runs = (state as? GatewayCronRunHistoryState.Loaded)?.takeIf { it.id == jobId }?.runs.orEmpty()
val error = (state as? GatewayCronRunHistoryState.Error)?.takeIf { it.id == jobId }?.message
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Default.History,
contentDescription = null,
modifier = Modifier.size(17.dp),
tint = ClawTheme.colors.text,
)
Text(
text = "Recent Runs",
style = ClawTheme.type.section,
color = ClawTheme.colors.text,
modifier = Modifier.weight(1f),
)
ClawSecondaryButton(
text = if (loading) "Loading" else "Reload",
onClick = onRefresh,
enabled = !loading,
icon = Icons.Default.Refresh,
)
}
when {
error != null ->
ClawPanel {
Text(text = error, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
}
runs.isEmpty() ->
ClawPanel {
Text(
text = if (loading) "Loading recent runs…" else "No recent runs yet.",
style = ClawTheme.type.body,
color = ClawTheme.colors.textMuted,
)
}
else -> ClawListPanel(items = runs) { run -> CronRunHistoryRow(run) }
}
}
@Composable
private fun CronRunHistoryRow(run: GatewayCronRunSummary) {
val status = cronRunStatus(run.status)
ClawDetailRow(
title = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(run.ts)),
subtitle = cronRunSubtitle(run),
leading = { ClawIconBadge(icon = Icons.Default.Schedule) },
trailing = { ClawStatusPill(text = cronRunStatusText(run.status), status = status) },
)
}
private fun cronScheduleKindLabel(schedule: GatewayCronScheduleEdit): String =
when (schedule) {
is GatewayCronScheduleEdit.At -> "One time"
is GatewayCronScheduleEdit.Every -> "Interval"
is GatewayCronScheduleEdit.Cron -> "Cron"
is GatewayCronScheduleEdit.OnExit -> "On command exit"
}
private fun cronPayloadKindLabel(payload: GatewayCronPayloadEdit): String =
when (payload) {
is GatewayCronPayloadEdit.SystemEvent -> "System event"
is GatewayCronPayloadEdit.AgentTurn -> "Agent turn"
is GatewayCronPayloadEdit.Command -> "Command"
}
private fun cronRunSubtitle(run: GatewayCronRunSummary): String =
listOfNotNull(
run.durationMs?.let { "${it}ms" },
run.deliveryStatus,
run.model,
run.error ?: run.summary,
).joinToString(" · ").ifBlank { "No details" }
private fun cronRunStatusText(status: String?): String =
when (status?.lowercase()) {
"ok" -> "OK"
"error" -> "Issue"
"skipped" -> "Skipped"
else -> "Unknown"
}
private fun cronRunStatus(status: String?): ClawStatus =
when (status?.lowercase()) {
"ok" -> ClawStatus.Success
"error" -> ClawStatus.Danger
"skipped" -> ClawStatus.Warning
else -> ClawStatus.Neutral
}
@@ -4,12 +4,16 @@ import ai.openclaw.app.AndroidLicenseNotice
import ai.openclaw.app.AppLanguage
import ai.openclaw.app.AppearanceThemeMode
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.CronEditorDraftState
import ai.openclaw.app.GatewayAgentSummary
import ai.openclaw.app.GatewayConnectionDisplay
import ai.openclaw.app.GatewayConnectionProblem
import ai.openclaw.app.GatewayCronActionState
import ai.openclaw.app.GatewayCronJobDetail
import ai.openclaw.app.GatewayCronJobDetailState
import ai.openclaw.app.GatewayCronJobEdit
import ai.openclaw.app.GatewayCronJobSummary
import ai.openclaw.app.GatewayCronRunHistoryState
import ai.openclaw.app.GatewayExecApprovalSummary
import ai.openclaw.app.GatewayTalkSetupReadiness
import ai.openclaw.app.GatewayTalkSetupState
@@ -31,6 +35,7 @@ import ai.openclaw.app.loadAndroidLicenseNotices
import ai.openclaw.app.locationModeAfterBackgroundSettings
import ai.openclaw.app.node.DeviceNotificationListenerService
import ai.openclaw.app.photoReadPermissionsForRequest
import ai.openclaw.app.reconcileRestoredAction
import ai.openclaw.app.setAppLanguage
import ai.openclaw.app.ui.design.ClawDetailRow
import ai.openclaw.app.ui.design.ClawIconBadge
@@ -66,6 +71,7 @@ import android.os.Looper
import android.provider.Settings
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
@@ -299,7 +305,7 @@ private fun CronJobsSettingsScreen(
)
ClawSecondaryButton(text = if (cronRefreshing) "Refreshing" else "Refresh", onClick = viewModel::refreshCronJobs, enabled = isConnected && !cronRefreshing, modifier = Modifier.fillMaxWidth())
ClawPanel {
Text(text = "Android shows scheduled work status. Create and edit schedules from the desktop app.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
Text(text = "Open a job to inspect its configuration and run history. Admin-scoped connections can also run, edit, enable, disable, or delete it.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
cronErrorText?.let { errorText ->
ClawPanel {
@@ -315,7 +321,7 @@ private fun CronJobsSettingsScreen(
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "No scheduled jobs.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "Create recurring OpenClaw work from the desktop app.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
Text(text = "Scheduled work created on the gateway will appear here.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
else -> CronJobsPanel(jobs = cronJobs, onJobClick = { selectedJobId = it.id })
@@ -330,13 +336,29 @@ private fun CronJobDetailSettingsScreen(
jobName: String?,
onBack: () -> Unit,
) {
BackHandler(onBack = onBack)
fun leaveDetail() {
viewModel.cronEditorDraftMemory.clear(jobId)
viewModel.dismissCronActionNotice(jobId)
onBack()
}
BackHandler(onBack = ::leaveDetail)
val detailState by viewModel.cronJobDetailState.collectAsState()
val historyState by viewModel.cronRunHistoryState.collectAsState()
val actionState by viewModel.cronActionState.collectAsState()
val pendingCronRunJobIds by viewModel.pendingCronRunJobIds.collectAsState()
val operatorAdminScopeAvailable by viewModel.operatorAdminScopeAvailable.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
val activity = LocalActivity.current
DisposableEffect(viewModel, jobId) {
onDispose { viewModel.clearCronJobDetail() }
DisposableEffect(activity, viewModel, jobId) {
onDispose {
viewModel.clearCronJobDetail()
if (cronDetailDisposalClearsTransientState(activity?.isChangingConfigurations == true)) {
viewModel.cronEditorDraftMemory.clear(jobId)
viewModel.dismissCronActionNotice(jobId)
}
}
}
LaunchedEffect(isConnected, jobId) {
@@ -346,18 +368,75 @@ private fun CronJobDetailSettingsScreen(
}
val current = (detailState as? GatewayCronJobDetailState.Loaded)?.job?.takeIf { it.id == jobId }
var editorDraft by remember(viewModel, jobId) {
mutableStateOf(viewModel.cronEditorDraftMemory.get(jobId))
}
var restoredDraftNeedsActionCheck by remember(viewModel, jobId) {
mutableStateOf(editorDraft?.savePending == true)
}
fun updateEditorDraft(value: CronEditorDraftState?) {
editorDraft = value
viewModel.cronEditorDraftMemory.set(jobId, value)
}
LaunchedEffect(isConnected, actionState, restoredDraftNeedsActionCheck) {
if (restoredDraftNeedsActionCheck) {
updateEditorDraft(
editorDraft?.reconcileRestoredAction(
isConnected = isConnected,
jobId = jobId,
actionState = actionState,
),
)
restoredDraftNeedsActionCheck = false
}
}
LaunchedEffect(isConnected) {
if (!isConnected) updateEditorDraft(editorDraft?.saveAborted())
}
LaunchedEffect(current) {
current?.let { job ->
updateEditorDraft(editorDraft?.observeJob(job) ?: CronEditorDraftState.from(job))
}
}
LaunchedEffect(actionState, current) {
val notice = actionState as? GatewayCronActionState.Notice
if (notice?.id == jobId) {
val observed = editorDraft?.observeSaveNotice(notice.kind)
updateEditorDraft(
current?.let { job ->
observed?.observeJob(job) ?: CronEditorDraftState.from(job)
} ?: observed,
)
}
}
val loading = (detailState as? GatewayCronJobDetailState.Loading)?.id == jobId
val errorText = (detailState as? GatewayCronJobDetailState.Error)?.takeIf { it.id == jobId }?.message
val deleted =
(actionState as? GatewayCronActionState.Notice)
?.takeIf { it.id == jobId }
?.deleted == true
LaunchedEffect(deleted) {
if (deleted) leaveDetail()
}
SettingsDetailFrame(
title = current?.name ?: jobName ?: "Cron Job",
subtitle = "Inspect scheduled gateway work.",
icon = Icons.Default.Bolt,
onBack = onBack,
onBack = ::leaveDetail,
) {
ClawSecondaryButton(
text = if (loading) "Refreshing" else "Refresh",
onClick = { viewModel.loadCronJobDetail(jobId) },
enabled = isConnected && !loading,
enabled =
cronDetailRefreshEnabled(
isConnected = isConnected,
loading = loading,
hasCurrentJob = current != null,
draftRequiresResolution = editorDraft?.requiresResolution == true,
saveSucceeded = editorDraft?.saveSucceeded == true,
),
modifier = Modifier.fillMaxWidth(),
)
@@ -374,11 +453,40 @@ private fun CronJobDetailSettingsScreen(
ClawPanel {
Text(text = if (loading) "Loading cron job…" else "Cron job not loaded.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
else -> CronJobDetailPanel(current)
else ->
CronJobDetailPanel(
job = current,
editorDraft = editorDraft ?: CronEditorDraftState.from(current),
onEditorDraftChange = ::updateEditorDraft,
historyState = historyState,
actionState = actionState,
runPending = jobId in pendingCronRunJobIds,
operatorAdminScopeAvailable = operatorAdminScopeAvailable,
onRun = { viewModel.runCronJob(current.id) },
onToggleEnabled = {
viewModel.setCronJobEnabled(id = current.id, enabled = !current.enabled)
},
onSave = { edit -> viewModel.updateCronJob(original = current, edit = edit) },
onRefreshHistory = { viewModel.refreshCronRunHistory(current.id) },
onDelete = { viewModel.deleteCronJob(current.id) },
)
}
}
}
internal fun cronDetailRefreshEnabled(
isConnected: Boolean,
loading: Boolean,
hasCurrentJob: Boolean,
draftRequiresResolution: Boolean,
saveSucceeded: Boolean,
): Boolean =
isConnected &&
!loading &&
(!hasCurrentJob || !draftRequiresResolution || saveSucceeded)
internal fun cronDetailDisposalClearsTransientState(isChangingConfigurations: Boolean): Boolean = !isChangingConfigurations
@Composable
private fun AgentsSettingsScreen(
viewModel: MainViewModel,
@@ -1905,7 +2013,7 @@ private fun CronJobListRow(
ClawDetailRow(
title = job.name,
subtitle = cronJobSubtitle(job),
modifier = Modifier.clickable(onClick = onClick),
modifier = Modifier.clickable(onClickLabel = "Open cron job detail", onClick = onClick),
leading = { ClawIconBadge(icon = Icons.Default.Bolt) },
trailing = {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
@@ -1919,7 +2027,32 @@ private fun CronJobListRow(
@Composable
private fun CronJobDetailPanel(
job: GatewayCronJobDetail,
editorDraft: CronEditorDraftState,
onEditorDraftChange: (CronEditorDraftState) -> Unit,
historyState: GatewayCronRunHistoryState,
actionState: GatewayCronActionState,
runPending: Boolean,
operatorAdminScopeAvailable: Boolean,
onRun: () -> Unit,
onToggleEnabled: () -> Unit,
onSave: (GatewayCronJobEdit) -> Unit,
onRefreshHistory: () -> Unit,
onDelete: () -> Unit,
) {
CronJobManagementPanel(
job = job,
editorDraft = editorDraft,
onEditorDraftChange = onEditorDraftChange,
historyState = historyState,
actionState = actionState,
runPending = runPending,
operatorAdminScopeAvailable = operatorAdminScopeAvailable,
onRun = onRun,
onToggleEnabled = onToggleEnabled,
onSave = onSave,
onRefreshHistory = onRefreshHistory,
onDelete = onDelete,
)
SettingsMetricPanel(
rows =
listOf(
@@ -45,6 +45,8 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -506,26 +508,39 @@ internal fun ClawTextField(
placeholder: String,
modifier: Modifier = Modifier,
minLines: Int = 1,
label: String? = null,
enabled: Boolean = true,
) {
val fieldModifier =
if (label == null) modifier else modifier.semantics { contentDescription = label }
BasicTextField(
value = value,
onValueChange = onValueChange,
enabled = enabled,
modifier =
modifier
fieldModifier
.fillMaxWidth()
.clip(RoundedCornerShape(ClawTheme.radii.control))
.background(ClawTheme.colors.surfaceRaised)
.border(1.dp, ClawTheme.colors.border, RoundedCornerShape(ClawTheme.radii.control))
.padding(horizontal = 11.dp, vertical = 8.dp),
textStyle = ClawTheme.type.body.copy(color = ClawTheme.colors.text),
textStyle =
ClawTheme.type.body.copy(
color = if (enabled) ClawTheme.colors.text else ClawTheme.colors.textSubtle,
),
cursorBrush = SolidColor(ClawTheme.colors.primary),
minLines = minLines,
decorationBox = { innerTextField ->
Box(modifier = Modifier.fillMaxWidth()) {
if (value.isEmpty()) {
Text(text = placeholder, style = ClawTheme.type.body, color = ClawTheme.colors.textSubtle)
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
label?.let {
Text(text = it, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
}
Box(modifier = Modifier.fillMaxWidth()) {
if (value.isEmpty()) {
Text(text = placeholder, style = ClawTheme.type.body, color = ClawTheme.colors.textSubtle)
}
innerTextField()
}
innerTextField()
}
},
)
@@ -23,6 +23,22 @@ class AndroidScreenshotFixtureTest {
json
.parseToJsonElement(AndroidScreenshotFixture.request("chat.metadata", null))
.jsonObject
val cronJobs =
json
.parseToJsonElement(AndroidScreenshotFixture.request("cron.list", null))
.jsonObject["jobs"]
?.jsonArray
.orEmpty()
val cronDetail =
json
.parseToJsonElement(AndroidScreenshotFixture.request("cron.get", null))
.jsonObject
val cronRunEntries =
json
.parseToJsonElement(AndroidScreenshotFixture.request("cron.runs", null))
.jsonObject["entries"]
?.jsonArray
val parsedCronRuns = parseGatewayCronRunHistory(cronRunEntries)
assertEquals(3, sessions.size)
assertEquals(
@@ -35,6 +51,20 @@ class AndroidScreenshotFixtureTest {
)
assertEquals(1, metadata["models"]?.jsonArray?.size)
assertEquals(1, metadata["commands"]?.jsonArray?.size)
assertEquals(
AndroidScreenshotFixture.cronJobName,
cronJobs
.single()
.jsonObject["name"]
?.jsonPrimitive
?.content,
)
assertEquals(AndroidScreenshotFixture.cronJobId, cronDetail["id"]?.jsonPrimitive?.content)
assertEquals(2, parsedCronRuns.size)
assertEquals("android-release-digest-run-2", parsedCronRuns.first().runId)
assertEquals("Release checklist ready", parsedCronRuns.first().summary)
assertEquals("android-release-digest-run-1", parsedCronRuns.last().runId)
assertEquals("Play publish blocked", parsedCronRuns.last().error)
}
@Test
@@ -18,10 +18,17 @@ class CronJobDetailTest {
requireNotNull(detail)
assertEquals("job-1", detail.id)
assertEquals("Daily report", detail.name)
assertEquals("sha256:fixture", detail.configRevision)
assertEquals("cron", detail.scheduleKind)
assertEquals("0 9 * * *", detail.scheduleLabel)
assertEquals("0 9 * * * · Europe/Vienna · Stagger Every 5m", detail.scheduleDetail)
assertEquals("0 9 * * *", detail.scheduleCronExpr)
assertEquals("Europe/Vienna", detail.scheduleTimezone)
assertEquals(300000L, detail.scheduleStaggerMs)
assertEquals("Agent turn · openai/gpt-5.5 · Thinking high", detail.payloadLabel)
assertEquals("Summarize the day", detail.payloadText)
assertEquals("openai/gpt-5.5", detail.payloadModel)
assertEquals("high", detail.payloadThinking)
assertEquals("Announce · telegram · chat-42 · Account primary", detail.deliveryLabel)
assertEquals("After 3 · Announce · telegram · ops · Cooldown Every 1h", detail.failureAlertLabel)
assertEquals(2L, detail.consecutiveErrors)
@@ -40,6 +47,7 @@ class CronJobDetailTest {
requireNotNull(detail)
assertEquals("printf done", detail.payloadText)
assertEquals(listOf("printf", "done"), detail.payloadCommandArgv)
assertFalse(detail.payloadText.orEmpty().contains("secret-value"))
}
@@ -75,6 +83,24 @@ class CronJobDetailTest {
assertNull(guard.begin(" "))
}
@Test
fun requestGuardConditionsReloadAndCancellationOnCurrentSelection() {
val guard = CronJobDetailRequestGuard()
requireNotNull(guard.begin("job-a"))
requireNotNull(guard.begin("job-b"))
var loadingId = "none"
var cancelled = false
assertNull(guard.beginIfCurrent("job-a") { loadingId = it.id })
val reload = guard.beginIfCurrent("job-b") { loadingId = it.id }
assertEquals("job-b", reload?.id)
assertEquals("job-b", loadingId)
assertFalse(guard.cancelIfCurrent("job-a") { cancelled = true })
assertFalse(cancelled)
assertTrue(guard.cancelIfCurrent("job-b") { cancelled = true })
assertTrue(cancelled)
}
private fun parseJob(
payload: String =
"""{"kind":"agentTurn","message":"Summarize the day","model":"openai/gpt-5.5","thinking":"high"}""",
@@ -90,6 +116,7 @@ class CronJobDetailTest {
"deleteAfterRun": false,
"createdAtMs": 1000,
"updatedAtMs": 2000,
"configRevision": "sha256:fixture",
"schedule": {"kind":"cron","expr":"0 9 * * *","tz":"Europe/Vienna","staggerMs":300000},
"sessionTarget": "isolated",
"wakeMode": "now",
@@ -0,0 +1,472 @@
package ai.openclaw.app
import ai.openclaw.app.gateway.GatewayConnectErrorDetails
import ai.openclaw.app.gateway.GatewaySession
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class CronJobManagementTest {
@Test
fun parsesEveryClosedCronRunOutcome() {
val started = parseGatewayCronRunOutcome(objectJson("""{"ok":true,"ran":true}"""))
val queued =
parseGatewayCronRunOutcome(
objectJson("""{"ok":true,"enqueued":true,"runId":"run-1"}"""),
)
assertEquals(GatewayCronRunOutcome.Started(runId = null), started)
assertEquals(GatewayCronRunOutcome.Started(runId = "run-1"), queued)
mapOf(
"not-due" to GatewayCronRunSkipReason.NotDue,
"already-running" to GatewayCronRunSkipReason.AlreadyRunning,
"restart-recovery-pending" to GatewayCronRunSkipReason.RestartRecoveryPending,
"invalid-spec" to GatewayCronRunSkipReason.InvalidSpec,
"stopped" to GatewayCronRunSkipReason.Stopped,
).forEach { (raw, reason) ->
assertEquals(
GatewayCronRunOutcome.Skipped(reason),
parseGatewayCronRunOutcome(
objectJson("""{"ok":true,"ran":false,"reason":"$raw"}"""),
),
)
}
assertEquals(
GatewayCronRunOutcome.Rejected,
parseGatewayCronRunOutcome(objectJson("""{"ok":false}""")),
)
assertEquals(null, parseGatewayCronRunOutcome(objectJson("""{"ok":true,"ran":false,"reason":"future"}""")))
assertEquals(null, parseGatewayCronRunOutcome(objectJson("""{"ok":true,"enqueued":true}""")))
}
@Test
fun updatePatchIsMinimalAndClearsAgentOverridesWithNull() {
val original = requireNotNull(parseGatewayCronJobDetail(jobJson()))
val initial = original.toCronJobEdit()
val payload = initial.payload as GatewayCronPayloadEdit.AgentTurn
val edit = initial.copy(payload = payload.copy(model = "", thinking = ""))
val root = objectJson(buildCronUpdateParams(original = original, edit = edit))
val patch = root.getValue("patch").jsonObject
val payloadPatch = patch.getValue("payload").jsonObject
assertEquals("sha256:fixture", root.getValue("expectedConfigRevision").jsonPrimitive.content)
assertEquals(setOf("payload"), patch.keys)
assertEquals("agentTurn", payloadPatch.getValue("kind").jsonPrimitive.content)
assertEquals(JsonNull, payloadPatch["model"])
assertEquals(JsonNull, payloadPatch["thinking"])
assertFalse(payloadPatch.containsKey("message"))
}
@Test
fun intervalPatchPreservesAnchorAndOmitsUnchangedPayload() {
val original =
requireNotNull(
parseGatewayCronJobDetail(
jobJson(schedule = """{"kind":"every","everyMs":60000,"anchorMs":1000}"""),
),
)
val initial = original.toCronJobEdit()
val schedule = initial.schedule as GatewayCronScheduleEdit.Every
val edit = initial.copy(schedule = schedule.copy(everyMs = "120000"))
val patch =
objectJson(buildCronUpdateParams(original = original, edit = edit))
.getValue("patch")
.jsonObject
val schedulePatch = patch.getValue("schedule").jsonObject
assertEquals(setOf("schedule"), patch.keys)
assertEquals("120000", schedulePatch.getValue("everyMs").jsonPrimitive.content)
assertEquals("1000", schedulePatch.getValue("anchorMs").jsonPrimitive.content)
}
@Test
fun deleteAfterRunStaysAvailableOnlyForOneShotSchedules() {
val recurring =
requireNotNull(
parseGatewayCronJobDetail(jobJson(deleteAfterRun = true)),
).toCronJobEdit()
val oneShot =
requireNotNull(
parseGatewayCronJobDetail(
jobJson(
deleteAfterRun = true,
schedule = """{"kind":"at","at":"2026-07-10T09:00:00Z"}""",
),
),
).toCronJobEdit()
assertFalse(recurring.deleteAfterRun)
assertTrue(oneShot.deleteAfterRun)
assertFalse(
oneShot
.withSchedule(GatewayCronScheduleEdit.Every(everyMs = "60000", anchorMs = ""))
.deleteAfterRun,
)
}
@Test
fun commandArgvRejectsNonStringJsonPrimitives() {
val original =
requireNotNull(
parseGatewayCronJobDetail(
jobJson(payload = """{"kind":"command","argv":["echo"],"cwd":"/tmp"}"""),
),
)
val initial = original.toCronJobEdit()
val payload = initial.payload as GatewayCronPayloadEdit.Command
val edit = initial.copy(payload = payload.copy(argvJson = """["echo",1,true,null]"""))
val error = runCatching { buildCronUpdateParams(original = original, edit = edit) }.exceptionOrNull()
assertEquals("Command argv entries must be non-empty strings.", error?.message)
}
@Test
fun commandArgvPreservesWhitespaceOnlyEntriesAllowedByGateway() {
val original =
requireNotNull(
parseGatewayCronJobDetail(
jobJson(payload = """{"kind":"command","argv":["printf"," "],"cwd":"/tmp"}"""),
),
)
val edit = original.toCronJobEdit().copy(name = "Renamed command")
val patch =
objectJson(buildCronUpdateParams(original = original, edit = edit))
.getValue("patch")
.jsonObject
assertEquals(setOf("name"), patch.keys)
assertEquals("Renamed command", patch.getValue("name").jsonPrimitive.content)
}
@Test
fun commandPayloadRejectsClearingAnExistingWorkingDirectory() {
val original =
requireNotNull(
parseGatewayCronJobDetail(
jobJson(payload = """{"kind":"command","argv":["echo"],"cwd":"/tmp"}"""),
),
)
val initial = original.toCronJobEdit()
val payload = initial.payload as GatewayCronPayloadEdit.Command
val error =
runCatching {
buildCronUpdateParams(
original = original,
edit = initial.copy(payload = payload.copy(cwd = "")),
)
}.exceptionOrNull()
assertEquals("The gateway does not support clearing a command working directory.", error?.message)
}
@Test
fun historyParserRequiresTimestampAndKeepsUsefulFields() {
val entries =
Json
.parseToJsonElement(
"""
[
{"ts":1000,"runId":"run-1","status":"ok","summary":"done","durationMs":42},
{"runId":"missing-ts"}
]
""".trimIndent(),
).jsonArray
val runs = parseGatewayCronRunHistory(entries)
assertEquals(1, runs.size)
assertEquals("run-1", runs.single().runId)
assertEquals(42L, runs.single().durationMs)
}
@Test
fun invalidSpecSkipRefreshesPersistedDiagnosticsWithoutRefreshingOtherSkips() {
assertTrue(cronRunShouldRefresh(GatewayCronRunOutcome.Started(runId = "run-1")))
assertTrue(
cronRunShouldRefresh(
GatewayCronRunOutcome.Skipped(GatewayCronRunSkipReason.InvalidSpec),
),
)
assertFalse(
cronRunShouldRefresh(
GatewayCronRunOutcome.Skipped(GatewayCronRunSkipReason.AlreadyRunning),
),
)
assertFalse(cronRunShouldRefresh(GatewayCronRunOutcome.Rejected))
}
@Test
fun queuedRunCompletionNoticeMatchesTerminalHistoryStatus() {
listOf(
Triple("ok", "Cron run finished.", GatewayCronNoticeKind.Success),
Triple("skipped", "Cron run skipped.", GatewayCronNoticeKind.Warning),
Triple("error", "Cron run failed.", GatewayCronNoticeKind.Error),
Triple(null, "Cron run finished with an unknown status.", GatewayCronNoticeKind.Warning),
).forEach { (status, message, kind) ->
assertEquals(
GatewayCronActionState.Notice(id = "job", message = message, kind = kind),
cronRunCompletionNotice("job", status),
)
}
}
@Test
fun pendingRunRegistryDedupesOnlyTheSameJobAndIgnoresStaleTrackers() {
val registry = PendingCronRunRegistry()
val snapshots = mutableListOf<Set<String>>()
assertTrue(registry.begin("job-a", "run-a") { snapshots += it })
assertFalse(registry.begin("job-a", "run-a-duplicate") { snapshots += it })
assertTrue(registry.begin("job-b", "run-b") { snapshots += it })
assertTrue(registry.contains("job-a"))
assertTrue(registry.contains("job-b"))
assertFalse(registry.finish("job-a", "stale-run") { snapshots += it })
assertTrue(registry.finish("job-a", "run-a") { snapshots += it })
assertFalse(registry.contains("job-a"))
assertTrue(registry.contains("job-b"))
registry.clear { snapshots += it }
assertFalse(registry.contains("job-b"))
assertEquals(
listOf(setOf("job-a"), setOf("job-a", "job-b"), setOf("job-b"), emptySet()),
snapshots,
)
}
@Test
fun mapsCronRevisionConflicts() {
val conflict =
GatewaySession.ErrorShape(
code = "INVALID_REQUEST",
message = "changed",
details =
GatewayConnectErrorDetails(
code = "CRON_JOB_CHANGED",
canRetryWithDeviceToken = false,
recommendedNextStep = null,
),
)
val generic = conflict.copy(details = conflict.details?.copy(code = "OTHER"))
assertTrue(isCronJobRevisionConflict(conflict))
assertFalse(isCronJobRevisionConflict(generic))
}
@Test
fun updateFailsClosedWhenGatewayDoesNotProvideConfigRevision() {
val original = requireNotNull(parseGatewayCronJobDetail(jobJson(configRevision = null)))
val error =
runCatching {
buildCronUpdateParams(
original = original,
edit = original.toCronJobEdit().copy(name = "Renamed"),
)
}.exceptionOrNull()
assertEquals("Update the gateway before saving cron changes from Android.", error?.message)
}
@Test
fun detailAndHistoryGenerationsAdvanceIndependently() {
val detailGuard = CronJobDetailRequestGuard()
val historyGuard = CronJobDetailRequestGuard()
val detailA = requireNotNull(detailGuard.begin("job-a"))
val historyA = requireNotNull(historyGuard.begin("job-a"))
val historyB = requireNotNull(historyGuard.begin("job-b"))
var detailPublished = false
var historyPublished = "none"
assertTrue(detailGuard.publishIfCurrent(detailA) { detailPublished = true })
assertFalse(historyGuard.publishIfCurrent(historyA) { historyPublished = "a" })
assertTrue(historyGuard.publishIfCurrent(historyB) { historyPublished = "b" })
assertTrue(detailPublished)
assertEquals("b", historyPublished)
}
@Test
fun editorDraftPreservesDirtyFieldsAndMarksIncomingRevisionConflict() {
val original = requireNotNull(parseGatewayCronJobDetail(jobJson()))
var draft = CronEditorDraftState.from(original)
draft = draft.withEdit(draft.edit.copy(name = "Unsaved name"))
val unrelated =
requireNotNull(
parseGatewayCronJobDetail(
jobJson(name = "Gateway revision"),
),
)
draft = draft.observeJob(unrelated)
assertEquals("Unsaved name", draft.edit.name)
assertTrue(draft.isDirty)
assertTrue(draft.hasIncomingConflict)
assertTrue(draft.requiresResolution)
val returnedToBaseline = draft.withEdit(draft.baseline)
assertFalse(returnedToBaseline.isDirty)
assertTrue(returnedToBaseline.requiresResolution)
val reverted = CronEditorDraftState.from(unrelated)
assertEquals("Gateway revision", reverted.edit.name)
assertFalse(reverted.isDirty)
assertFalse(reverted.hasIncomingConflict)
}
@Test
fun editorDraftIgnoresRuntimeOnlyTimestampUpdates() {
val original = requireNotNull(parseGatewayCronJobDetail(jobJson()))
val draft =
CronEditorDraftState
.from(original)
.withEdit(original.toCronJobEdit().copy(name = "Unsaved name"))
val runtimeUpdate =
requireNotNull(
parseGatewayCronJobDetail(
jobJson(updatedAtMs = 3000),
),
)
val observed = draft.observeJob(runtimeUpdate)
assertEquals("Unsaved name", observed.edit.name)
assertTrue(observed.isDirty)
assertFalse(observed.hasIncomingConflict)
}
@Test
fun editorDraftAdoptsOnlyTheNewRevisionAfterSuccessfulSave() {
val original = requireNotNull(parseGatewayCronJobDetail(jobJson()))
var draft = CronEditorDraftState.from(original)
draft = draft.withEdit(draft.edit.copy(name = "Saved name"))
draft = draft.saveStarted().saveAborted()
assertFalse(draft.savePending)
assertEquals("Saved name", draft.edit.name)
draft = draft.saveStarted().observeSaveNotice(GatewayCronNoticeKind.Error)
assertEquals("Saved name", draft.edit.name)
draft = draft.saveStarted().observeSaveNotice(GatewayCronNoticeKind.Success)
assertEquals("Saved name", draft.observeJob(original).edit.name)
val saved =
requireNotNull(
parseGatewayCronJobDetail(
jobJson(name = "Saved name", updatedAtMs = 4000, configRevision = "sha256:saved"),
),
)
draft = draft.observeJob(saved)
assertEquals("Saved name", draft.baseline.name)
assertEquals(draft.baseline, draft.edit)
assertFalse(draft.savePending)
assertFalse(draft.saveSucceeded)
}
@Test
fun restoredPendingSaveTracksRetainedRuntimeAndRecoversAfterProcessDeath() {
val original = requireNotNull(parseGatewayCronJobDetail(jobJson()))
val pending =
CronEditorDraftState
.from(original)
.withEdit(original.toCronJobEdit().copy(name = "Saved name"))
.saveStarted()
val running = GatewayCronActionState.Running(id = original.id, action = GatewayCronAction.Save)
val success =
GatewayCronActionState.Notice(
id = original.id,
message = "Cron job updated.",
kind = GatewayCronNoticeKind.Success,
)
assertEquals(
pending,
pending.reconcileRestoredAction(isConnected = true, jobId = original.id, actionState = running),
)
assertEquals(
pending,
pending.reconcileRestoredAction(isConnected = true, jobId = original.id, actionState = success),
)
assertFalse(
pending
.reconcileRestoredAction(
isConnected = true,
jobId = original.id,
actionState = GatewayCronActionState.Idle,
).savePending,
)
assertFalse(
pending
.reconcileRestoredAction(
isConnected = false,
jobId = original.id,
actionState = running,
).savePending,
)
val applied =
requireNotNull(
parseGatewayCronJobDetail(
jobJson(name = "Saved name", updatedAtMs = 4000),
),
)
val recovered = pending.saveAborted().observeJob(applied)
assertEquals(recovered.baseline, recovered.edit)
assertFalse(recovered.requiresResolution)
}
@Test
fun latestRefreshGuardRejectsStaleAndInvalidatedResults() {
val guard = LatestGatewayRefreshGuard()
val stale = guard.begin()
val current = guard.begin()
var published = "none"
assertFalse(guard.publishIfCurrent(stale) { published = "stale" })
assertTrue(guard.publishIfCurrent(current) { published = "current" })
guard.invalidate()
assertFalse(guard.publishIfCurrent(current) { published = "invalidated" })
assertEquals("current", published)
}
private fun objectJson(raw: String) = Json.parseToJsonElement(raw).jsonObject
private fun jobJson(
name: String = "Daily report",
updatedAtMs: Long = 2000,
configRevision: String? = "sha256:fixture",
deleteAfterRun: Boolean = false,
schedule: String = """{"kind":"cron","expr":"0 9 * * *","tz":"UTC"}""",
payload: String =
"""{"kind":"agentTurn","message":"Summarize the day","model":"openai/gpt-5.5","thinking":"high"}""",
): JsonObject {
val configRevisionField =
configRevision?.let { """"configRevision":"$it",""" }.orEmpty()
return objectJson(
"""
{
"id":"job-1",
"name":"$name",
"description":"Daily digest",
"enabled":true,
"deleteAfterRun":$deleteAfterRun,
"createdAtMs":1000,
"updatedAtMs":$updatedAtMs,
$configRevisionField
"schedule":$schedule,
"sessionTarget":"isolated",
"wakeMode":"next-heartbeat",
"payload":$payload,
"state":{}
}
""".trimIndent(),
)
}
}
@@ -0,0 +1,220 @@
package ai.openclaw.app
import ai.openclaw.app.gateway.GatewayEndpoint
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.lang.reflect.Field
import java.util.UUID
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class CronRuntimeGuardTest {
@Before
fun clearPlainPrefs() {
RuntimeEnvironment
.getApplication()
.getSharedPreferences("openclaw.node", android.content.Context.MODE_PRIVATE)
.edit()
.clear()
.commit()
}
@Test
fun nonAdminConnectionRejectsMutationBeforeGatewayRequest() {
val runtime = createTestRuntime()
seedConnectedRuntime(runtime)
runtime.runCronJob("job-1")
assertEquals(
GatewayCronActionState.Notice(
id = "job-1",
message = "Cron changes require operator.admin access.",
kind = GatewayCronNoticeKind.Error,
),
runtime.cronActionState.value,
)
}
@Test
fun activeCronActionSerializesLaterMutationCalls() =
runBlocking {
val runtime = createTestRuntime()
seedConnectedRuntime(runtime)
readField<MutableStateFlow<List<String>>>(runtime, "_operatorScopes").value =
listOf("operator.admin")
withTimeout(2_000) {
while (!runtime.operatorAdminScopeAvailable.value) delay(10)
}
val actionMutex = readField<Mutex>(runtime, "cronActionMutex")
actionMutex.lock()
try {
runtime.runCronJob("job-1")
runtime.setCronJobEnabled(id = "job-1", enabled = false)
delay(50)
assertEquals(
GatewayCronActionState.Notice(
id = "job-1",
message = "Another cron action is still finishing.",
kind = GatewayCronNoticeKind.Warning,
),
runtime.cronActionState.value,
)
} finally {
actionMutex.unlock()
}
}
@Test
fun completedDeleteDoesNotClearNewerJobSelection() {
val runtime = createTestRuntime()
val detailState = readField<MutableStateFlow<GatewayCronJobDetailState>>(runtime, "_cronJobDetailState")
val historyState = readField<MutableStateFlow<GatewayCronRunHistoryState>>(runtime, "_cronRunHistoryState")
requireNotNull(readField<CronJobDetailRequestGuard>(runtime, "cronJobDetailRequestGuard").begin("job-b"))
requireNotNull(readField<CronJobDetailRequestGuard>(runtime, "cronRunHistoryRequestGuard").begin("job-b"))
detailState.value = GatewayCronJobDetailState.Loading("job-b")
historyState.value = GatewayCronRunHistoryState.Loading("job-b")
invokeStringMethod(runtime, "clearDeletedCronSelection", "job-a")
assertEquals(GatewayCronJobDetailState.Loading("job-b"), detailState.value)
assertEquals(GatewayCronRunHistoryState.Loading("job-b"), historyState.value)
invokeStringMethod(runtime, "clearDeletedCronSelection", "job-b")
assertEquals(GatewayCronJobDetailState.Idle, detailState.value)
assertEquals(GatewayCronRunHistoryState.Idle, historyState.value)
}
@Test
fun detailDisposalRetainsNoticeUntilExplicitJobDismissal() {
val runtime = createTestRuntime()
val actionState = readField<MutableStateFlow<GatewayCronActionState>>(runtime, "_cronActionState")
val notice =
GatewayCronActionState.Notice(
id = "job-a",
message = "Cron job updated.",
kind = GatewayCronNoticeKind.Success,
)
actionState.value = notice
runtime.clearCronJobDetail()
assertEquals(notice, actionState.value)
runtime.dismissCronActionNotice("job-b")
assertEquals(notice, actionState.value)
runtime.dismissCronActionNotice("job-a")
assertEquals(GatewayCronActionState.Idle, actionState.value)
}
@Test
fun pendingCronRunSurvivesReconnectButClearsWhenGatewayScopeRetires() {
val runtime = createTestRuntime()
val registry = readField<PendingCronRunRegistry>(runtime, "pendingCronRunRegistry")
val pending = readField<MutableStateFlow<Set<String>>>(runtime, "_pendingCronRunJobIds")
assertEquals(true, registry.begin("job-1", "run-1") { pending.value = it })
invokeBooleanMethod(runtime, "clearOperatorGatewayState", false)
assertEquals(setOf("job-1"), pending.value)
invokeBooleanMethod(runtime, "clearOperatorGatewayState", true)
assertEquals(emptySet<String>(), pending.value)
}
@Test
fun runningStateBlocksMutationAfterMutexRelease() =
runBlocking {
val runtime = createTestRuntime()
seedConnectedRuntime(runtime)
readField<MutableStateFlow<List<String>>>(runtime, "_operatorScopes").value =
listOf("operator.admin")
withTimeout(2_000) {
while (!runtime.operatorAdminScopeAvailable.value) delay(10)
}
val running = GatewayCronActionState.Running(id = "job-1", action = GatewayCronAction.Save)
readField<MutableStateFlow<GatewayCronActionState>>(runtime, "_cronActionState").value = running
runtime.runCronJob("job-1")
delay(50)
assertEquals(running, runtime.cronActionState.value)
}
private fun createTestRuntime(): NodeRuntime {
val app = RuntimeEnvironment.getApplication()
val securePrefs =
app.getSharedPreferences(
"openclaw.node.cron.guard.test.${UUID.randomUUID()}",
android.content.Context.MODE_PRIVATE,
)
return NodeRuntime(app, SecurePrefs(app, securePrefsOverride = securePrefs))
}
private fun seedConnectedRuntime(runtime: NodeRuntime) {
writeField(runtime, "connectedEndpoint", GatewayEndpoint.manual("127.0.0.1", 18789))
writeField(runtime, "operatorConnected", true)
}
private fun writeField(
target: Any,
name: String,
value: Any?,
) {
findField(target, name).set(target, value)
}
private fun <T> readField(
target: Any,
name: String,
): T {
@Suppress("UNCHECKED_CAST")
return findField(target, name).get(target) as T
}
private fun findField(
target: Any,
name: String,
): Field {
var type: Class<*>? = target.javaClass
while (type != null) {
try {
return type.getDeclaredField(name).apply { isAccessible = true }
} catch (_: NoSuchFieldException) {
type = type.superclass
}
}
error("Field $name not found on ${target.javaClass.name}")
}
private fun invokeStringMethod(
target: Any,
name: String,
value: String,
) {
target.javaClass
.getDeclaredMethod(name, String::class.java)
.apply { isAccessible = true }
.invoke(target, value)
}
private fun invokeBooleanMethod(
target: Any,
name: String,
value: Boolean,
) {
target.javaClass
.getDeclaredMethod(name, java.lang.Boolean.TYPE)
.apply { isAccessible = true }
.invoke(target, value)
}
}
@@ -180,7 +180,7 @@ class GatewayNodeApprovalStateTest {
@Test
fun ignoresStaleNodeApprovalRefreshResults() {
val guard = GatewayNodeApprovalRefreshGuard()
val guard = LatestGatewayRefreshGuard()
var approvalState = GatewayNodeApprovalState.Loading
val staleRefresh = guard.begin()
val currentRefresh = guard.begin()
@@ -1,6 +1,8 @@
package ai.openclaw.app
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -32,4 +34,41 @@ class MainViewModelTest {
),
)
}
@Test
fun cronEditorDraftMemoryIsBoundedAndClearsOnlyItsOwningJob() {
val memory = CronEditorDraftMemory()
val first = draft("First")
val second = draft("Second")
memory.set("job-a", first)
assertEquals(first, memory.get("job-a"))
assertNull(memory.get("job-b"))
memory.set("job-b", second)
assertNull(memory.get("job-a"))
memory.clear("job-a")
assertEquals(second, memory.get("job-b"))
memory.set("job-b", null)
assertNull(memory.get("job-b"))
}
private fun draft(name: String): CronEditorDraftState {
val edit =
GatewayCronJobEdit(
name = name,
description = "",
enabled = true,
deleteAfterRun = false,
schedule = GatewayCronScheduleEdit.At("2026-07-10T09:00:00Z"),
sessionTarget = "isolated",
wakeMode = "now",
payload = GatewayCronPayloadEdit.SystemEvent("Wake up"),
)
return CronEditorDraftState(
baseline = edit,
edit = edit.copy(name = "$name draft"),
)
}
}
@@ -92,6 +92,36 @@ class SettingsScreensTest {
assertEquals(null, gatewayNodeApprovalCommand(GatewayNodeCapabilityApproval.Approved))
}
@Test
fun cronDetailRefreshRecoversWhenDirtyDraftHasNoLoadedJob() {
assertEquals(
true,
cronDetailRefreshEnabled(
isConnected = true,
loading = false,
hasCurrentJob = false,
draftRequiresResolution = true,
saveSucceeded = false,
),
)
assertEquals(
false,
cronDetailRefreshEnabled(
isConnected = true,
loading = false,
hasCurrentJob = true,
draftRequiresResolution = true,
saveSucceeded = false,
),
)
}
@Test
fun cronDetailDisposalRetainsTransientStateOnlyForActivityRecreation() {
assertEquals(false, cronDetailDisposalClearsTransientState(isChangingConfigurations = true))
assertEquals(true, cronDetailDisposalClearsTransientState(isChangingConfigurations = false))
}
private fun authProblem(code: String): GatewayConnectionProblem =
GatewayConnectionProblem(
code = code,
@@ -0,0 +1,81 @@
package ai.openclaw.app.benchmark
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.Until
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class CronJobNavigationTest {
private lateinit var device: UiDevice
@Before
fun setUp() {
device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
device.executeShellCommand("am force-stop $packageName")
device.executeShellCommand(
"am start -W -n $packageName/.MainActivity " +
"--ez openclaw.screenshotMode true --es openclaw.screenshotScene settings",
)
assertNotNull(device.wait(Until.findObject(By.text("Settings")), waitTimeoutMs))
}
@Test
fun opensCronJobFixtureDetail() {
findTextAfterScrolling("Cron Jobs").click()
val cronJobLabel = findTextAfterScrolling("Android release digest")
val cronJobRow =
checkNotNull(
generateSequence(cronJobLabel) { it.parent }
.firstOrNull { it.isClickable },
) { "Cron fixture row must expose a click action" }
assertTrue("Cron fixture row must expose a click action", cronJobRow.isClickable)
assertFalse(device.hasObject(By.text("Inspect scheduled gateway work.")))
cronJobRow.click()
assertNotNull(device.wait(Until.findObject(By.text("Inspect scheduled gateway work.")), waitTimeoutMs))
assertNotNull(findTextAfterScrolling("Run Now"))
assertNotNull(findTextAfterScrolling("Recent Runs"))
assertNotNull(findTextAfterScrolling("Release checklist ready", exact = false))
assertNotNull(findTextAfterScrolling("OK"))
assertNotNull(findTextAfterScrolling("Play publish blocked", exact = false))
assertNotNull(findTextAfterScrolling("Issue"))
}
private fun findTextAfterScrolling(
text: String,
exact: Boolean = true,
): UiObject2 {
val selector = if (exact) By.text(text) else By.textContains(text)
repeat(maxScrolls + 1) { attempt ->
device.wait(Until.findObject(selector), shortWaitMs)?.let { return it }
if (attempt < maxScrolls) {
device.swipe(
device.displayWidth / 2,
(device.displayHeight * 0.8f).toInt(),
device.displayWidth / 2,
(device.displayHeight * 0.25f).toInt(),
24,
)
device.waitForIdle()
}
}
error("Could not find UI text: $text")
}
private companion object {
const val packageName = "ai.openclaw.app"
const val waitTimeoutMs = 10_000L
const val shortWaitMs = 1_000L
const val maxScrolls = 6
}
}
+41 -4
View File
@@ -60,6 +60,8 @@ struct SettingsProTab: View {
@State var defaultShareInstruction = ""
@State var showQRScanner = false
@State var scannerError: String?
@State var showLocationAccessDialog = false
@State var pendingLocationMode: OpenClawLocationMode?
@State var showResetOnboardingAlert = false
@State var suppressCredentialPersist = false
@State var locationStatusText: String?
@@ -178,6 +180,7 @@ struct SettingsProTab: View {
.onChange(of: self.scenePhase) { _, phase in
if phase == .active {
self.syncSettingsState()
self.applyPendingLocationModeIfAvailable()
self.refreshNotificationSettings()
}
}
@@ -287,8 +290,11 @@ struct SettingsProTab: View {
"QR Scanner Unavailable",
isPresented: Binding(
get: { self.scannerError != nil },
set: { if !$0 { self.scannerError = nil } }))
{
set: {
if !$0 {
self.scannerError = nil
}
})) {
Button(role: .cancel) {} label: {
Text("OK")
.font(OpenClawType.subheadSemiBold)
@@ -297,11 +303,40 @@ struct SettingsProTab: View {
Text(self.scannerError ?? "")
.font(OpenClawType.subhead)
}
.confirmationDialog(
"Access Level",
isPresented: self.$showLocationAccessDialog,
titleVisibility: .visible)
{
Button {
self.selectLocationAccessLevel(.whileUsing)
} label: {
Text("While Using the App")
.font(OpenClawType.subheadSemiBold)
}
Button {
self.selectLocationAccessLevel(.always)
} label: {
Text("Always")
.font(OpenClawType.subheadSemiBold)
}
Button(role: .cancel) {} label: {
Text("Cancel")
.font(OpenClawType.subheadSemiBold)
}
} message: {
Text("Choose when OpenClaw may share this iPhone's location with gateway tools.")
.font(OpenClawType.subhead)
}
.confirmationDialog(
"Forget \(self.pendingForgetGateway?.name ?? "gateway")?",
isPresented: Binding(
get: { self.pendingForgetGateway != nil },
set: { if !$0 { self.pendingForgetGateway = nil } }),
set: {
if !$0 {
self.pendingForgetGateway = nil
}
}),
titleVisibility: .visible)
{
Button(role: .destructive) {
@@ -317,7 +352,9 @@ struct SettingsProTab: View {
.font(OpenClawType.subheadSemiBold)
}
} message: {
Text("This removes saved credentials, device access, TLS trust, and cached chats for this gateway.")
Text(
"This removes saved credentials, device access, TLS trust, " +
"and cached chats for this gateway.")
.font(OpenClawType.subhead)
}
}
@@ -581,6 +581,8 @@ extension SettingsProTab {
if mode == .off {
_ = await self.appModel.requestLocationPermissions(mode: mode)
self.pendingLocationMode = nil
self.locationModeRaw = rawValue
self.previousLocationModeRaw = rawValue
self.refreshLocationPermissionSummary(desiredMode: mode)
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
@@ -590,17 +592,99 @@ extension SettingsProTab {
let granted = await self.appModel.requestLocationPermissions(mode: mode)
self.refreshLocationPermissionSummary(desiredMode: mode)
if granted {
self.pendingLocationMode = nil
self.locationModeRaw = rawValue
self.previousLocationModeRaw = rawValue
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
} else {
self.locationModeRaw = previous
self.previousLocationModeRaw = previous
self.locationStatusText = "Location permission was not granted."
self.refreshLocationPermissionSummary(
desiredMode: OpenClawLocationMode(rawValue: previous) ?? .off)
let presentation = self.locationSettingsPresentation(selectedMode: mode)
self.locationStatusText = presentation.statusText
}
}
var selectedLocationMode: OpenClawLocationMode {
OpenClawLocationMode(rawValue: self.locationModeRaw) ?? .off
}
var displayedLocationMode: OpenClawLocationMode {
self.pendingLocationMode ?? self.selectedLocationMode
}
var locationSettingsPresentation: LocationSettingsPresentation {
self.locationSettingsPresentation(selectedMode: self.displayedLocationMode)
}
func locationSettingsPresentation(selectedMode: OpenClawLocationMode) -> LocationSettingsPresentation {
var summary = self.locationPermissionSummary
summary.desiredMode = selectedMode
return LocationSettingsPresentation(selectedMode: selectedMode, summary: summary)
}
func handleLocationSharingTap() {
guard !self.isChangingLocationMode else { return }
self.performLocationSettingsAction(self.locationSettingsPresentation.toggleAction())
}
func selectLocationAccessLevel(_ mode: OpenClawLocationMode) {
guard mode != .off else { return }
guard !self.isChangingLocationMode else { return }
let presentation = self.locationSettingsPresentation(selectedMode: mode)
self.performLocationSettingsAction(presentation.accessLevelAction(mode: mode))
}
func performLocationSettingsAction(_ action: LocationSettingsAction) {
switch action {
case let .setMode(mode):
self.setLocationMode(mode)
case let .openAppSettings(mode):
self.pendingLocationMode = mode
self.locationStatusText = self.locationSettingsPresentation(selectedMode: mode).statusText
self.openLocationSettings()
}
}
func setLocationMode(_ mode: OpenClawLocationMode) {
let rawValue = mode.rawValue
let previous = self.previousLocationModeRaw
if self.locationModeRaw != rawValue {
self.locationModeRaw = rawValue
return
}
Task {
await self.applyLocationMode(mode, rawValue: rawValue, previous: previous)
}
}
func applyPendingLocationModeIfAvailable() {
guard let mode = self.pendingLocationMode else { return }
Task {
let locationServicesEnabled = await Self.locationServicesEnabled()
let manager = CLLocationManager()
let summary = LocationPermissionSummary(
desiredMode: mode,
locationServicesEnabled: locationServicesEnabled,
authorizationStatus: manager.authorizationStatus,
accuracyAuthorization: manager.accuracyAuthorization)
self.locationPermissionSummary = summary
let unavailableStatus = self.locationSettingsPresentation(selectedMode: mode).statusText
self.pendingLocationMode = nil
guard summary.effectiveMode != .off else {
self.locationStatusText = unavailableStatus
return
}
self.setLocationMode(mode)
}
}
func openLocationSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
}
func refreshNotificationSettings() {
UNUserNotificationCenter.current().getNotificationSettings { settings in
let status = settings.authorizationStatus
@@ -1126,36 +1210,16 @@ extension SettingsProTab {
return diagnosticsIssueCount == 0 ? OpenClawBrand.ok : OpenClawBrand.warn
}
var privacyDetail: String {
let location = OpenClawLocationMode(rawValue: self.locationModeRaw) ?? .off
return switch (location, self.locationPermissionSummary.effectiveMode) {
case (.off, _):
"Location off"
case (.whileUsing, .whileUsing):
"Location While Using"
case (.whileUsing, .off):
"Location While Using, effective Off"
case (.whileUsing, .always):
"Location While Using, effective Always"
case (.always, .always):
"Location Always"
case (.always, .whileUsing):
"Location Always, effective While Using"
case (.always, .off):
"Location Always, effective Off"
}
}
var locationPermissionDetailText: String {
var locationPermissionDetailText: String? {
if self.isChangingLocationMode {
return "Requesting iOS location permission…"
}
return self.locationPermissionSummary.detailText
return self.locationSettingsPresentation.statusText
}
var locationPermissionWarningText: String? {
guard let locationStatusText else { return nil }
guard locationStatusText != self.locationPermissionSummary.detailText else { return nil }
guard locationStatusText != self.locationPermissionDetailText else { return nil }
return locationStatusText
}
@@ -552,13 +552,6 @@ extension SettingsProTab {
Group {
self.notificationsSection
self.detailStatusCard(
icon: "hand.raised",
title: "Privacy",
detail: "Control what device context OpenClaw can expose to the gateway.",
value: self.privacyDetail,
color: .secondary)
self.toggleCard(
title: "Camera Access",
isOn: self.$cameraEnabled)
@@ -778,44 +771,69 @@ extension SettingsProTab {
var locationModeCard: some View {
Section {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
SettingsIcon(
systemName: "location",
color: self.locationModeRaw == OpenClawLocationMode.off.rawValue ? .secondary : OpenClawBrand
.accent)
VStack(alignment: .leading, spacing: 3) {
Button {
self.handleLocationSharingTap()
} label: {
HStack {
Text("Location")
.font(OpenClawType.subheadSemiBold)
Text("Controls whether location can be shared with gateway tools.")
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer(minLength: 8)
if self.isChangingLocationMode {
ProgressView()
.controlSize(.small)
.font(OpenClawType.body)
.foregroundStyle(.primary)
Spacer(minLength: 8)
ZStack {
OpenClawToggleIndicator(isOn: self.locationSettingsPresentation.sharingControlIsOn)
.opacity(self.isChangingLocationMode ? 0 : 1)
if self.isChangingLocationMode {
ProgressView()
.controlSize(.small)
}
}
}
.contentShape(Rectangle())
}
Picker("Location", selection: self.$locationModeRaw) {
Text("Off")
.font(OpenClawType.captionSemiBold)
.tag(OpenClawLocationMode.off.rawValue)
Text("While Using")
.font(OpenClawType.captionSemiBold)
.tag(OpenClawLocationMode.whileUsing.rawValue)
Text("Always")
.font(OpenClawType.captionSemiBold)
.tag(OpenClawLocationMode.always.rawValue)
}
.pickerStyle(.segmented)
.buttonStyle(.plain)
.disabled(self.isChangingLocationMode)
.accessibilityIdentifier("settings-location-sharing-toggle")
.accessibilityLabel("Location Sharing")
.accessibilityValue(self.locationSettingsPresentation.sharingControlIsOn ? "On" : "Off")
Text(self.locationPermissionDetailText)
.font(OpenClawType.caption2)
.foregroundStyle(
self.locationPermissionSummary.needsAttention ? OpenClawBrand.warn : .secondary)
if self.locationSettingsPresentation.showsAccessLevel,
let accessLevelText = self.locationSettingsPresentation.accessLevelText
{
Divider()
Button {
self.showLocationAccessDialog = true
} label: {
HStack(alignment: .firstTextBaseline) {
Text("Access Level")
.font(OpenClawType.body)
.foregroundStyle(.primary)
Spacer(minLength: 8)
Text(accessLevelText)
.font(OpenClawType.subhead)
.foregroundStyle(.secondary)
.multilineTextAlignment(.trailing)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.secondary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(self.isChangingLocationMode)
.accessibilityElement(children: .ignore)
.accessibilityIdentifier("settings-location-access-level")
.accessibilityLabel("Access Level")
.accessibilityValue(accessLevelText)
.accessibilityHint("Chooses While Using the App or Always")
}
if let locationPermissionDetailText {
Text(locationPermissionDetailText)
.font(OpenClawType.caption2)
.foregroundStyle(OpenClawBrand.warn)
}
if let locationPermissionWarningText {
Text(locationPermissionWarningText)
@@ -974,29 +992,45 @@ extension SettingsProTab {
}
func discoveredGatewayRow(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 3) {
Text(verbatim: gateway.name)
.font(OpenClawType.subheadSemiBold)
Text(verbatim: self.gatewayDetailLines(gateway).joined(separator: ""))
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer(minLength: 8)
Button {
Task { await self.connect(gateway) }
} label: {
if self.connectingGatewayID == gateway.id {
ProgressView().controlSize(.small)
let availability = self.gatewayController.discoveredGatewayConnectionAvailability(gateway)
return VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 3) {
Text(verbatim: gateway.name)
.font(OpenClawType.subheadSemiBold)
Text(verbatim: self.gatewayDetailLines(gateway).joined(separator: ""))
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer(minLength: 8)
if availability.canConnect {
Button {
Task { await self.connect(gateway) }
} label: {
if self.connectingGatewayID == gateway.id {
ProgressView().controlSize(.small)
} else {
Text(availability.actionTitle)
.font(OpenClawType.captionSemiBold)
}
}
.font(OpenClawType.captionSemiBold)
.buttonStyle(.bordered)
.disabled(self.connectingGatewayID != nil)
} else {
Text("Connect")
Text(availability.actionTitle)
.font(OpenClawType.captionSemiBold)
.foregroundStyle(OpenClawBrand.warn)
}
}
.font(OpenClawType.captionSemiBold)
.buttonStyle(.bordered)
.disabled(self.connectingGatewayID != nil)
if let guidanceText = availability.guidanceText {
Text(guidanceText)
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
@@ -68,6 +68,34 @@ private func defaultGatewayTLSFingerprintProbe(url: URL) async -> GatewayTLSFing
@MainActor
@Observable
final class GatewayConnectionController {
enum DiscoveredGatewayConnectionAvailability: Equatable {
case available
case secureTransportRequired
var canConnect: Bool {
self == .available
}
var actionTitle: String {
switch self {
case .available:
String(localized: "Connect")
case .secureTransportRequired:
String(localized: "TLS required")
}
}
var guidanceText: String? {
switch self {
case .available:
nil
case .secureTransportRequired:
String(
localized: "Enable Gateway TLS, or enter your Tailscale Serve HTTPS host in Manual Setup. Use Unencrypted only with a trusted private-LAN address.")
}
}
}
static func resolvedManualPort(host: String, port: Int) -> Int? {
if port > 0 {
return port <= 65535 ? port : nil
@@ -251,10 +279,28 @@ final class GatewayConnectionController {
await self.connectDiscoveredGateway(gateway)
}
func discoveredGatewayConnectionAvailability(
_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> DiscoveredGatewayConnectionAvailability
{
if gateway.tlsEnabled || GatewayTLSStore.loadFingerprint(stableID: gateway.stableID) != nil {
return .available
}
return .secureTransportRequired
}
func preferredDiscoveredGateway() -> GatewayDiscoveryModel.DiscoveredGateway? {
self.gateways.first(where: {
self.discoveredGatewayConnectionAvailability($0).canConnect
}) ?? self.gateways.first
}
private func connectDiscoveredGateway(
_ gateway: GatewayDiscoveryModel.DiscoveredGateway,
forceReconnect: Bool = false) async -> String?
{
let availability = self.discoveredGatewayConnectionAvailability(gateway)
guard availability.canConnect else { return availability.guidanceText }
let connectAttempt = self.beginConnectAttempt()
self.pendingConnectionStableID = gateway.stableID
defer { self.finishConnectAttempt(connectAttempt.suppressionLease) }
@@ -285,10 +331,6 @@ final class GatewayConnectionController {
let tlsRequired = true
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
guard gateway.tlsEnabled || stored != nil else {
return "Discovered gateway is missing TLS and no trusted fingerprint is stored."
}
if tlsRequired, stored == nil {
guard let url = self.buildGatewayURL(host: target.host, port: target.port, useTLS: true)
else { return "Failed to build TLS URL for trust verification." }
@@ -1256,7 +1298,12 @@ final class GatewayConnectionController {
{
switch failure {
case .endpointUnreachable:
"Can't reach gateway at \(host):\(port). Check Tailscale or LAN."
if host.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".")).hasSuffix(".ts.net") {
String(
localized: "Can't reach gateway at \(host):\(port). Verify Tailscale Serve is enabled and publishes this Gateway.")
} else {
String(localized: "Can't reach gateway at \(host):\(port). Check Tailscale or LAN.")
}
case .tlsHandshakeTimeout:
"TLS fingerprint verification timed out for \(host):\(port). "
+ "Secure endpoint was reached, but TLS did not finish in time."
@@ -10,11 +10,17 @@ struct GatewayQuickSetupSheet: View {
@Environment(GatewayConnectionController.self) private var gatewayController
@Environment(\.dismiss) private var dismiss
let onUseManualSetup: () -> Void
@AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false
@State private var connecting: Bool = false
@State private var connectError: String?
@State private var showGatewayProblemDetails: Bool = false
init(onUseManualSetup: @escaping () -> Void = {}) {
self.onUseManualSetup = onUseManualSetup
}
var body: some View {
NavigationStack {
ScrollView {
@@ -34,6 +40,7 @@ struct GatewayQuickSetupSheet: View {
}
if let candidate = self.bestCandidate {
let availability = self.gatewayController.discoveredGatewayConnectionAvailability(candidate)
GatewayQuickSetupCandidatePanel(
name: candidate.name,
debugID: candidate.debugID,
@@ -44,33 +51,58 @@ struct GatewayQuickSetupSheet: View {
nodeStatusText: self.appModel.nodeStatusText,
operatorStatusText: self.appModel.operatorStatusText)
Button {
self.connectError = nil
self.connecting = true
Task {
let err = await self.gatewayController.connectWithDiagnostics(candidate)
await MainActor.run {
self.connecting = false
self.connectError = err
if availability.canConnect {
Button {
self.connectError = nil
self.connecting = true
Task {
let err = await self.gatewayController.connectWithDiagnostics(candidate)
await MainActor.run {
self.connecting = false
self.connectError = err
}
}
}
} label: {
Group {
if self.connecting {
HStack(spacing: 8) {
ProgressView().progressViewStyle(.circular)
Text("Connecting…")
} label: {
Group {
if self.connecting {
HStack(spacing: 8) {
ProgressView().progressViewStyle(.circular)
Text("Connecting…")
.font(OpenClawType.subheadSemiBold)
}
} else {
Text("Connect to this Gateway")
.font(OpenClawType.subheadSemiBold)
}
} else {
Text("Connect to this Gateway")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(OpenClawPrimaryActionButtonStyle())
.disabled(self.connecting)
} else if let guidanceText = availability.guidanceText {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "lock.shield.fill")
.foregroundStyle(OpenClawBrand.warn)
VStack(alignment: .leading, spacing: 3) {
Text(availability.actionTitle)
.font(OpenClawType.subheadSemiBold)
Text(guidanceText)
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity)
.accessibilityElement(children: .combine)
Button {
self.onUseManualSetup()
} label: {
Text("Use Manual Setup")
.font(OpenClawType.subheadSemiBold)
.frame(maxWidth: .infinity)
}
.buttonStyle(OpenClawSecondaryActionButtonStyle())
}
.buttonStyle(OpenClawPrimaryActionButtonStyle())
.disabled(self.connecting)
if let connectError {
GatewayQuickSetupErrorView(message: connectError)
@@ -124,7 +156,7 @@ struct GatewayQuickSetupSheet: View {
}
private var bestCandidate: GatewayDiscoveryModel.DiscoveredGateway? {
self.gatewayController.gateways.first
self.gatewayController.preferredDiscoveredGateway()
}
private func fullRowToggle(_ title: LocalizedStringKey, isOn: Binding<Bool>) -> some View {
@@ -160,6 +192,11 @@ struct GatewayQuickSetupSheet: View {
}
guard problem.retryable else { return }
guard let candidate = self.bestCandidate else { return }
let availability = self.gatewayController.discoveredGatewayConnectionAvailability(candidate)
guard availability.canConnect else {
self.connectError = availability.guidanceText
return
}
self.connectError = nil
self.connecting = true
let err = await self.gatewayController.connectWithDiagnostics(candidate)
@@ -0,0 +1,87 @@
import CoreLocation
import Foundation
import OpenClawKit
enum LocationSettingsAction: Equatable {
case setMode(OpenClawLocationMode)
case openAppSettings(OpenClawLocationMode)
}
struct LocationSettingsPresentation: Equatable {
var selectedMode: OpenClawLocationMode
var summary: LocationPermissionSummary
var sharingControlIsOn: Bool {
self.selectedMode != .off
}
var showsAccessLevel: Bool {
self.selectedMode != .off
}
var accessLevelText: String? {
self.selectedMode.locationAccessLevelText
}
var statusText: String? {
guard self.selectedMode != .off else { return nil }
guard self.summary.needsAttention else { return nil }
if !self.summary.locationServicesEnabled {
return String(localized: "Location Services are off in iOS Settings.")
}
switch self.summary.authorizationStatus {
case .notDetermined:
return String(localized: "iOS permission is required to share location.")
case .denied:
return String(localized: "Location permission is denied in iOS Settings.")
case .restricted:
return String(localized: "Location permission is restricted on this device.")
case .authorizedWhenInUse where self.selectedMode == .always:
return String(localized: "iOS currently allows location only while using the app.")
case .authorizedWhenInUse, .authorizedAlways:
return nil
default:
return String(localized: "OpenClaw cannot determine the current iOS location permission.")
}
}
func toggleAction(defaultEnabledMode: OpenClawLocationMode = .whileUsing) -> LocationSettingsAction {
if self.sharingControlIsOn {
return .setMode(.off)
}
let mode = self.selectedMode == .off ? defaultEnabledMode : self.selectedMode
return self.enableAction(mode: mode)
}
func accessLevelAction(mode: OpenClawLocationMode) -> LocationSettingsAction {
self.enableAction(mode: mode)
}
private func enableAction(mode: OpenClawLocationMode) -> LocationSettingsAction {
if !self.summary.locationServicesEnabled {
return .openAppSettings(mode)
}
switch self.summary.authorizationStatus {
case .denied, .restricted:
return .openAppSettings(mode)
default:
return .setMode(mode)
}
}
}
extension OpenClawLocationMode {
var locationAccessLevelText: String? {
switch self {
case .off:
nil
case .whileUsing:
String(localized: "While Using the App")
case .always:
String(localized: "Always")
}
}
}
@@ -719,35 +719,47 @@ struct OnboardingWizardView: View {
.foregroundStyle(.secondary)
} else {
ForEach(self.gatewayController.gateways) { gateway in
let hasHost = self.gatewayHasResolvableHost(gateway)
let availability = self.gatewayController.discoveredGatewayConnectionAvailability(gateway)
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(gateway.name)
.font(OpenClawType.body)
if let host = gateway.lanHost ?? gateway.tailnetDns {
Text(host)
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 6) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(gateway.name)
.font(OpenClawType.body)
if let host = gateway.lanHost ?? gateway.tailnetDns {
Text(host)
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
}
}
}
Spacer()
Button {
Task { await self.connectDiscoveredGateway(gateway) }
} label: {
if self.connectingGatewayID == gateway.id {
ProgressView()
.progressViewStyle(.circular)
} else if !hasHost {
Text("Resolving…")
.font(OpenClawType.subheadSemiBold)
Spacer()
if availability.canConnect {
Button {
Task { await self.connectDiscoveredGateway(gateway) }
} label: {
if self.connectingGatewayID == gateway.id {
ProgressView()
.progressViewStyle(.circular)
} else {
Text(availability.actionTitle)
.font(OpenClawType.subheadSemiBold)
}
}
.font(OpenClawType.subheadSemiBold)
.disabled(self.connectingGatewayID != nil)
} else {
Text("Connect")
Text(availability.actionTitle)
.font(OpenClawType.subheadSemiBold)
.foregroundStyle(OpenClawBrand.warn)
}
}
.font(OpenClawType.subheadSemiBold)
.disabled(self.connectingGatewayID != nil || !hasHost)
if let guidanceText = availability.guidanceText {
Text(guidanceText)
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
@@ -1660,13 +1672,6 @@ extension OnboardingWizardView {
}
}
private func gatewayHasResolvableHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> Bool {
let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !lanHost.isEmpty { return true }
let tailnetDns = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return !tailnetDns.isEmpty
}
private func connectManual(setupAttemptID: UUID? = nil) async {
if let setupAttemptID {
guard self.setupAttemptID == setupAttemptID else { return }
+7 -4
View File
@@ -909,10 +909,13 @@ struct RootTabs: View {
.sheet(item: self.$presentedSheet) { sheet in
switch sheet {
case .quickSetup:
GatewayQuickSetupSheet()
.environment(self.appModel)
.environment(self.gatewayController)
.openClawSheetChrome()
GatewayQuickSetupSheet(onUseManualSetup: {
self.presentedSheet = nil
self.selectSettingsRoute(.gateway)
})
.environment(self.appModel)
.environment(self.gatewayController)
.openClawSheetChrome()
}
}
.fullScreenCover(isPresented: self.$showOnboarding) {
@@ -16,7 +16,8 @@ import Testing
lanHost: String?,
tailnetDns: String?,
gatewayPort: Int?,
fingerprint: String?) -> GatewayDiscoveryModel.DiscoveredGateway
fingerprint: String?,
tlsEnabled: Bool = true) -> GatewayDiscoveryModel.DiscoveredGateway
{
let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
return GatewayDiscoveryModel.DiscoveredGateway(
@@ -28,7 +29,7 @@ import Testing
tailnetDns: tailnetDns,
gatewayPort: gatewayPort,
canvasPort: nil,
tlsEnabled: true,
tlsEnabled: tlsEnabled,
tlsFingerprintSha256: fingerprint,
cliPath: nil)
}
@@ -75,6 +76,117 @@ import Testing
#expect(params?.allowTOFU == false)
}
@Test @MainActor func `discovered gateway availability requires advertised TLS or a stored pin`() {
let unpinnedID = "test|\(UUID().uuidString)"
let pinnedID = "test|\(UUID().uuidString)"
defer {
clearTLSFingerprint(stableID: unpinnedID)
clearTLSFingerprint(stableID: pinnedID)
}
self.clearTLSFingerprint(stableID: unpinnedID)
self.clearTLSFingerprint(stableID: pinnedID)
let controller = self.makeController()
let unavailable = self.makeDiscoveredGateway(
stableID: unpinnedID,
lanHost: "gateway.local",
tailnetDns: nil,
gatewayPort: 18789,
fingerprint: "untrusted-txt-fingerprint",
tlsEnabled: false)
let advertisedTLS = self.makeDiscoveredGateway(
stableID: unpinnedID,
lanHost: "gateway.local",
tailnetDns: nil,
gatewayPort: 18789,
fingerprint: nil)
let pinned = self.makeDiscoveredGateway(
stableID: pinnedID,
lanHost: "gateway.local",
tailnetDns: nil,
gatewayPort: 18789,
fingerprint: nil,
tlsEnabled: false)
#expect(controller.discoveredGatewayConnectionAvailability(unavailable) == .secureTransportRequired)
#expect(controller.discoveredGatewayConnectionAvailability(unavailable).canConnect == false)
#expect(controller.discoveredGatewayConnectionAvailability(unavailable).guidanceText?
.contains("trusted private-LAN") == true)
#expect(controller.discoveredGatewayConnectionAvailability(advertisedTLS) == .available)
GatewayTLSStore.saveFingerprint("stored-pin", stableID: pinnedID)
#expect(controller.discoveredGatewayConnectionAvailability(pinned) == .available)
}
@Test @MainActor func `blocked discovered gateway does no connection work`() async {
let stableID = "test|\(UUID().uuidString)"
defer { clearTLSFingerprint(stableID: stableID) }
self.clearTLSFingerprint(stableID: stableID)
let tcpCalls = OSAllocatedUnfairLock(initialState: 0)
let tlsCalls = OSAllocatedUnfairLock(initialState: 0)
let resolverCalls = OSAllocatedUnfairLock(initialState: 0)
let appModel = NodeAppModel()
let controller = GatewayConnectionController(
appModel: appModel,
startDiscovery: false,
tcpReachabilityProbe: { _, _, _, _ in
tcpCalls.withLock { $0 += 1 }
return true
},
tlsFingerprintProbe: { _ in
tlsCalls.withLock { $0 += 1 }
return .fingerprint("unexpected")
},
serviceEndpointResolver: { _ in
resolverCalls.withLock { $0 += 1 }
return (host: "unexpected.example", port: 443)
})
let gateway = self.makeDiscoveredGateway(
stableID: stableID,
lanHost: "untrusted-txt.example",
tailnetDns: nil,
gatewayPort: 18789,
fingerprint: "untrusted-txt-fingerprint",
tlsEnabled: false)
let message = await controller.connectWithDiagnostics(gateway)
#expect(message?.contains("Manual Setup") == true)
#expect(resolverCalls.withLock { $0 } == 0)
#expect(tcpCalls.withLock { $0 } == 0)
#expect(tlsCalls.withLock { $0 } == 0)
#expect(controller.pendingTrustPrompt == nil)
#expect(appModel.activeGatewayConnectConfig == nil)
}
@Test @MainActor func `quick setup prefers an eligible discovered gateway`() {
let blockedID = "test|\(UUID().uuidString)"
let eligibleID = "test|\(UUID().uuidString)"
defer {
clearTLSFingerprint(stableID: blockedID)
clearTLSFingerprint(stableID: eligibleID)
}
self.clearTLSFingerprint(stableID: blockedID)
self.clearTLSFingerprint(stableID: eligibleID)
let controller = self.makeController()
let blocked = self.makeDiscoveredGateway(
stableID: blockedID,
lanHost: nil,
tailnetDns: nil,
gatewayPort: nil,
fingerprint: nil,
tlsEnabled: false)
let eligible = self.makeDiscoveredGateway(
stableID: eligibleID,
lanHost: nil,
tailnetDns: nil,
gatewayPort: nil,
fingerprint: nil)
controller._test_setGateways([blocked, eligible])
#expect(controller.preferredDiscoveredGateway()?.stableID == eligibleID)
}
@Test @MainActor func `autoconnect requires stored pin for discovered gateways`() {
let stableID = "test|\(UUID().uuidString)"
defer { clearTLSFingerprint(stableID: stableID) }
@@ -288,6 +400,24 @@ import Testing
#expect(appModel.gatewayStatusText == "Can't reach gateway at \(host):\(port). Check Tailscale or LAN.")
}
@Test @MainActor func `unreachable tailscale host explains serve publishing`() async {
let host = "gateway-\(UUID().uuidString).example.ts.net"
let port = 443
let stableID = "manual|\(host.lowercased())|\(port)"
defer { clearTLSFingerprint(stableID: stableID) }
self.clearTLSFingerprint(stableID: stableID)
let appModel = NodeAppModel()
let controller = GatewayConnectionController(
appModel: appModel,
startDiscovery: false,
tcpReachabilityProbe: { _, _, _, _ in false })
await controller.connectManual(host: host, port: port, useTLS: true)
#expect(appModel.gatewayStatusText ==
"Can't reach gateway at \(host):\(port). Verify Tailscale Serve is enabled and publishes this Gateway.")
}
@Test @MainActor func `manual first use TLS probe reports handshake timeout without trust prompt`() async {
let host = "gateway-\(UUID().uuidString).example.com"
let port = 18789
@@ -4,6 +4,157 @@ import Testing
@testable import OpenClawKit
@Suite(.serialized) struct LocationPermissionSummaryTests {
@Test func `location settings presentation uses apple access labels`() {
let whileUsing = LocationSettingsPresentation(
selectedMode: .whileUsing,
summary: LocationPermissionSummary(
desiredMode: .whileUsing,
locationServicesEnabled: true,
authorizationStatus: .authorizedWhenInUse,
accuracyAuthorization: .fullAccuracy))
let always = LocationSettingsPresentation(
selectedMode: .always,
summary: LocationPermissionSummary(
desiredMode: .always,
locationServicesEnabled: true,
authorizationStatus: .authorizedAlways,
accuracyAuthorization: .fullAccuracy))
let whileUsingWithAlwaysGrant = LocationSettingsPresentation(
selectedMode: .whileUsing,
summary: LocationPermissionSummary(
desiredMode: .whileUsing,
locationServicesEnabled: true,
authorizationStatus: .authorizedAlways,
accuracyAuthorization: .fullAccuracy))
#expect(whileUsing.accessLevelText == "While Using the App")
#expect(always.accessLevelText == "Always")
#expect(whileUsingWithAlwaysGrant.accessLevelText == "While Using the App")
#expect(OpenClawLocationMode.off.locationAccessLevelText == nil)
}
@Test func `location sharing control follows selected mode while permission is pending`() {
let presentation = LocationSettingsPresentation(
selectedMode: .whileUsing,
summary: LocationPermissionSummary(
desiredMode: .whileUsing,
locationServicesEnabled: true,
authorizationStatus: .notDetermined,
accuracyAuthorization: .fullAccuracy))
#expect(presentation.sharingControlIsOn)
#expect(presentation.showsAccessLevel)
#expect(presentation.accessLevelText == "While Using the App")
#expect(presentation.statusText == "iOS permission is required to share location.")
#expect(presentation.toggleAction() == .setMode(.off))
}
@Test func `location sharing toggle from off requests while using by default`() {
let presentation = LocationSettingsPresentation(
selectedMode: .off,
summary: LocationPermissionSummary(
desiredMode: .off,
locationServicesEnabled: true,
authorizationStatus: .notDetermined,
accuracyAuthorization: .fullAccuracy))
#expect(!presentation.sharingControlIsOn)
#expect(!presentation.showsAccessLevel)
#expect(presentation.toggleAction() == .setMode(.whileUsing))
}
@Test func `access level stays hidden when sharing is off despite retained ios grant`() {
let presentation = LocationSettingsPresentation(
selectedMode: .off,
summary: LocationPermissionSummary(
desiredMode: .off,
locationServicesEnabled: true,
authorizationStatus: .authorizedAlways,
accuracyAuthorization: .fullAccuracy))
#expect(!presentation.sharingControlIsOn)
#expect(!presentation.showsAccessLevel)
#expect(presentation.accessLevelText == nil)
}
@Test func `location sharing toggle opens app settings when denied`() {
let presentation = LocationSettingsPresentation(
selectedMode: .off,
summary: LocationPermissionSummary(
desiredMode: .off,
locationServicesEnabled: true,
authorizationStatus: .denied,
accuracyAuthorization: .fullAccuracy))
#expect(!presentation.sharingControlIsOn)
#expect(!presentation.showsAccessLevel)
#expect(presentation.accessLevelText == nil)
#expect(presentation.statusText == nil)
#expect(presentation.toggleAction() == .openAppSettings(.whileUsing))
}
@Test func `access level reports selection and warns when ios grant is lower`() {
let presentation = LocationSettingsPresentation(
selectedMode: .always,
summary: LocationPermissionSummary(
desiredMode: .always,
locationServicesEnabled: true,
authorizationStatus: .authorizedWhenInUse,
accuracyAuthorization: .fullAccuracy))
#expect(presentation.sharingControlIsOn)
#expect(presentation.showsAccessLevel)
#expect(presentation.accessLevelText == "Always")
#expect(presentation.statusText == "iOS currently allows location only while using the app.")
#expect(presentation.accessLevelAction(mode: .always) == .setMode(.always))
#expect(presentation.accessLevelAction(mode: .whileUsing) == .setMode(.whileUsing))
#expect(presentation.toggleAction() == .setMode(.off))
}
@Test func `healthy location sharing hides redundant status copy`() {
let presentation = LocationSettingsPresentation(
selectedMode: .whileUsing,
summary: LocationPermissionSummary(
desiredMode: .whileUsing,
locationServicesEnabled: true,
authorizationStatus: .authorizedWhenInUse,
accuracyAuthorization: .fullAccuracy))
#expect(presentation.sharingControlIsOn)
#expect(presentation.statusText == nil)
}
@Test func `global location services off opens app settings action`() {
let presentation = LocationSettingsPresentation(
selectedMode: .off,
summary: LocationPermissionSummary(
desiredMode: .off,
locationServicesEnabled: false,
authorizationStatus: .authorizedWhenInUse,
accuracyAuthorization: .fullAccuracy))
#expect(!presentation.sharingControlIsOn)
#expect(!presentation.showsAccessLevel)
#expect(presentation.statusText == nil)
#expect(presentation.toggleAction() == .openAppSettings(.whileUsing))
}
@Test func `restricted location permission shows settings guidance`() {
let presentation = LocationSettingsPresentation(
selectedMode: .whileUsing,
summary: LocationPermissionSummary(
desiredMode: .whileUsing,
locationServicesEnabled: true,
authorizationStatus: .restricted,
accuracyAuthorization: .fullAccuracy))
#expect(presentation.sharingControlIsOn)
#expect(presentation.showsAccessLevel)
#expect(presentation.statusText == "Location permission is restricted on this device.")
#expect(presentation.toggleAction() == .setMode(.off))
#expect(presentation.accessLevelAction(mode: .whileUsing) == .openAppSettings(.whileUsing))
}
@Test func `always desired when in use authorized needs attention`() {
let summary = LocationPermissionSummary(
desiredMode: .always,
+79 -2
View File
@@ -790,13 +790,55 @@ struct RootTabsSourceGuardTests {
sectionsSource,
from: "var privacyDestination: some View",
to: "var notificationsDestination: some View")
let locationCard = try Self.extract(
sectionsSource,
from: "var locationModeCard: some View",
to: "var agentSelectionCard: some View")
let pendingLocationApplication = try Self.extract(
actionsSource,
from: "func applyPendingLocationModeIfAvailable()",
to: "func openLocationSettings()")
#expect(!settingsList.contains("route: .notifications"))
#expect(privacyDestination.contains("self.notificationsSection"))
#expect(privacyDestination.contains("title: \"Camera Access\""))
#expect(privacyDestination.contains("self.locationModeCard"))
#expect(privacyDestination.contains("title: \"Background Listening\""))
#expect(!privacyDestination.contains("title: \"Privacy\""))
#expect(sectionsSource.contains("Toggle(\"Notifications\", isOn: self.notificationToggleBinding)"))
#expect(locationCard.contains("Text(\"Location\")"))
#expect(locationCard.contains(".font(OpenClawType.body)"))
#expect(locationCard.contains(".accessibilityLabel(\"Location Sharing\")"))
#expect(!locationCard.contains("Text(\"Location Sharing\")"))
#expect(!locationCard.contains("SettingsIcon("))
#expect(locationCard.contains("Text(\"Access Level\")"))
#expect(!locationCard.contains("Text(\"Open iOS Settings\")"))
#expect(locationCard.contains(".opacity(self.isChangingLocationMode ? 0 : 1)"))
#expect(locationCard.contains(".multilineTextAlignment(.trailing)"))
#expect(locationCard.contains(".lineLimit(2)"))
#expect(locationCard.contains(".accessibilityElement(children: .ignore)"))
#expect(locationCard.contains(".accessibilityLabel(\"Access Level\")"))
#expect(!locationCard.contains(".minimumScaleFactor("))
#expect(locationCard.contains("showLocationAccessDialog"))
#expect(locationCard.contains("chevron.up.chevron.down"))
#expect(locationCard.contains("Chooses While Using the App or Always"))
#expect(!locationCard.contains("Picker(\"Location\""))
#expect(!locationCard.contains("Text(\"While Using\")"))
#expect(!locationCard.contains("Choose a location mode"))
#expect(!actionsSource.contains("Location permission was not granted."))
#expect(!actionsSource.contains("presentation.showsOpenSettingsAction"))
#expect(actionsSource.contains("func selectLocationAccessLevel"))
#expect(actionsSource.contains("presentation.accessLevelAction(mode: mode)"))
#expect(actionsSource.contains("self.pendingLocationMode ?? self.selectedLocationMode"))
#expect(pendingLocationApplication.contains(
"self.locationSettingsPresentation(selectedMode: mode).statusText"))
let pendingClear = try #require(pendingLocationApplication.range(of: "self.pendingLocationMode = nil"))
let unavailableReturn = try #require(
pendingLocationApplication.range(of: "guard summary.effectiveMode != .off else"))
#expect(pendingClear.lowerBound < unavailableReturn.lowerBound)
#expect(actionsSource.contains("UIApplication.shared.unregisterForRemoteNotifications()"))
#expect(actionsSource.contains("UIApplication.openNotificationSettingsURLString"))
#expect(!actionsSource.contains("UIApplication.openSettingsURLString"))
#expect(actionsSource.contains("UIApplication.openSettingsURLString"))
}
@Test func `gateway settings keeps pairing trust diagnostics and tailscale actions`() throws {
@@ -1081,6 +1123,34 @@ struct RootTabsSourceGuardTests {
#expect(!modelSource.contains("expectedGeneration: UInt64?"))
}
@Test func `discovered gateway surfaces share secure connection availability`() throws {
let controllerSource = try String(
contentsOf: Self.gatewayConnectionControllerSourceURL(),
encoding: .utf8)
let settingsSource = try String(
contentsOf: Self.settingsProTabSectionsSourceURL(),
encoding: .utf8)
let quickSetupSource = try String(
contentsOf: Self.gatewayQuickSetupSourceURL(),
encoding: .utf8)
let onboardingSource = try String(
contentsOf: Self.onboardingWizardSourceURL(),
encoding: .utf8)
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
#expect(controllerSource.contains("enum DiscoveredGatewayConnectionAvailability"))
#expect(controllerSource.contains("gateway.tlsEnabled || GatewayTLSStore.loadFingerprint"))
#expect(controllerSource.contains("enter your Tailscale Serve HTTPS host in Manual Setup"))
#expect(settingsSource.contains("discoveredGatewayConnectionAvailability(gateway)"))
#expect(quickSetupSource.contains("discoveredGatewayConnectionAvailability(candidate)"))
#expect(quickSetupSource.contains("Text(\"Use Manual Setup\")"))
#expect(quickSetupSource.contains("self.gatewayController.preferredDiscoveredGateway()"))
#expect(onboardingSource.contains("discoveredGatewayConnectionAvailability(gateway)"))
#expect(!onboardingSource.contains("gatewayHasResolvableHost"))
#expect(rootSource.contains("GatewayQuickSetupSheet(onUseManualSetup:"))
#expect(rootSource.contains("self.selectSettingsRoute(.gateway)"))
}
@Test func `gateway credential fields update before endpoint persistence is available`() throws {
let onboardingSource = try String(contentsOf: Self.onboardingWizardSourceURL(), encoding: .utf8)
let settingsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8)
@@ -1110,7 +1180,7 @@ struct RootTabsSourceGuardTests {
let modeDefaults = try Self.extract(
source,
from: "private func applyModeDefaults(_ mode: OnboardingConnectionMode)",
to: "private func gatewayHasResolvableHost")
to: "private func connectManual")
#expect(modeDefaults.contains("let previousStableID = self.currentManualGatewayStableID"))
#expect(modeDefaults.contains("previousStableID != self.currentManualGatewayStableID"))
@@ -1540,6 +1610,13 @@ struct RootTabsSourceGuardTests {
.appendingPathComponent("Sources/Onboarding/OnboardingWizardView.swift")
}
private static func gatewayQuickSetupSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Gateway/GatewayQuickSetupSheet.swift")
}
private static func qrScannerSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
+21 -2
View File
@@ -61,6 +61,25 @@ struct SwiftUIRenderSmokeTests {
}
}
@Test @MainActor func `settings Privacy destination builds across appearance and type size`() {
for scheme in [ColorScheme.light, ColorScheme.dark] {
for typeSize in [DynamicTypeSize.large, .accessibility2] {
let appModel = NodeAppModel()
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let root = SettingsProTab(directRoute: .privacy)
.environment(AppAppearanceModel())
.environment(appModel)
.environment(appModel.voiceWake)
.environment(gatewayController)
.preferredColorScheme(scheme)
.environment(\.dynamicTypeSize, typeSize)
_ = Self.host(root, size: CGSize(width: 393, height: 852))
}
}
}
@Test @MainActor func `settings Licenses destination builds in light and dark mode`() {
var windows: [UIWindow] = []
defer { windows.forEach { $0.isHidden = true } }
@@ -188,7 +207,7 @@ struct SwiftUIRenderSmokeTests {
}
}
@Test @MainActor func gatewayQuickSetupBuildsCandidateAndEmptyStates() {
@Test @MainActor func `gateway quick setup builds candidate and empty states`() {
let gateways: [GatewayDiscoveryModel.DiscoveredGateway?] = [
.previewGateway,
nil,
@@ -211,7 +230,7 @@ struct SwiftUIRenderSmokeTests {
}
}
@Test @MainActor func onboardingActivationScreensBuildAcrossAppearanceAndTypeSize() {
@Test @MainActor func `onboarding activation screens build across appearance and type size`() {
let screens: [AnyView] = [
AnyView(OnboardingIntroStep(onContinue: {})),
AnyView(OnboardingWelcomeStep(
@@ -14,6 +14,7 @@ final class AppState {
private var isInitializing = true
private var isApplyingRemoteTokenConfig = false
private var configWatcher: ConfigFileWatcher?
private var lastConfigFingerprint: Data?
private var suppressVoiceWakeGlobalSync = false
private var voiceWakeGlobalSyncTask: Task<Void, Never>?
@@ -365,6 +366,7 @@ final class AppState {
}
let configRoot = OpenClawConfigFile.loadDict()
self.lastConfigFingerprint = Self.configFingerprint(configRoot)
let configRemoteToken = GatewayRemoteConfig.resolveTokenValue(root: configRoot)
let configRemoteResolution = GatewayRemoteConfig.resolveTransportResolution(root: configRoot)
let configRemoteTransport = configRemoteResolution.transport
@@ -579,8 +581,18 @@ final class AppState {
private func applyConfigFromDisk() {
let root = OpenClawConfigFile.loadDict()
let fingerprint = Self.configFingerprint(root)
let changed = fingerprint != self.lastConfigFingerprint
self.lastConfigFingerprint = fingerprint
self.applyConfigOverrides(root)
MacNodeModeCoordinator.shared.refresh()
if changed {
NotificationCenter.default.post(name: .openclawConfigDidChange, object: nil)
}
}
private static func configFingerprint(_ root: [String: Any]) -> Data? {
try? JSONSerialization.data(withJSONObject: root, options: [.sortedKeys])
}
private func applyConfigOverrides(_ root: [String: Any]) {
@@ -733,6 +745,8 @@ final class AppState {
Self.logger.warning("gateway config sync rejected to protect persisted gateway auth/mode")
return
}
self.lastConfigFingerprint = Self.configFingerprint(synced.root)
NotificationCenter.default.post(name: .openclawConfigDidChange, object: nil)
}
func triggerVoiceEars(ttl: TimeInterval? = 5) {
@@ -83,6 +83,7 @@ enum ConfigStore {
}
}
}
NotificationCenter.default.post(name: .openclawConfigDidChange, object: nil)
}
@MainActor
@@ -153,3 +154,7 @@ enum ConfigStore {
}
#endif
}
extension Notification.Name {
static let openclawConfigDidChange = Notification.Name("openclaw.config.did-change")
}
@@ -1,12 +1,17 @@
import SwiftUI
enum CrestodianAvailability {
static func shouldShow(configuredModel: String?) -> Bool {
!(configuredModel?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
}
}
/// Settings pane hosting the Crestodian setup/repair chat.
///
/// Crestodian answers even when no model is configured (deterministic engine
/// on the gateway), so this pane is the "always works" place to fix config,
/// switch models, connect channels, or run doctor in plain language.
/// The parent settings view exposes this pane only after inference is configured.
struct CrestodianSettings: View {
let isActive: Bool
let onReplyReceived: () -> Void
@State private var chat = CrestodianOnboardingChatModel(
welcomeVariant: nil,
sessionPrefix: "mac-settings-crestodian")
@@ -15,8 +20,8 @@ struct CrestodianSettings: View {
VStack(alignment: .leading, spacing: 20) {
SettingsPageHeader(
title: "Crestodian",
subtitle: "Your setup helper. It can check status, fix config, switch models, " +
"and connect channels — even when the agent itself is not working.")
subtitle: "Your AI-powered setup helper. It can check status, fix config, " +
"switch models, and connect channels.")
SettingsCardGroup("Chat") {
CrestodianOnboardingChatView(model: self.chat)
@@ -31,10 +36,21 @@ struct CrestodianSettings: View {
.settingsDetailContent()
.task(id: self.isActive) {
guard self.isActive else { return }
self.chat.onAgentHandoff = {
AppNavigationActions.openChat()
}
Self.configureChatCallbacks(
for: self.chat,
onReplyReceived: self.onReplyReceived)
await self.chat.startIfNeeded()
}
}
@MainActor
static func configureChatCallbacks(
for chat: CrestodianOnboardingChatModel,
onReplyReceived: @escaping () -> Void)
{
chat.onAgentHandoff = {
AppNavigationActions.openChat()
}
chat.onReplyReceived = onReplyReceived
}
}
@@ -354,6 +354,15 @@ actor GatewayConnection {
}
}
func isCurrentRoute(_ route: Route) async -> Bool {
guard let cfg = try? await configProvider() else { return false }
return route.generation == self.routeGeneration &&
route.matches(cfg) &&
self.configuredURL == route.url &&
self.configuredToken == route.token &&
self.configuredPassword == route.password
}
func supportsServerCapability(
_ capability: GatewayServerCapability,
ifCurrentRoute route: Route) async -> Bool?
@@ -388,6 +397,27 @@ actor GatewayConnection {
return SessionRoutingIdentity(defaultAgentID: result.defaultid, contract: contract)
}
func configuredInferenceModel(ifCurrentRoute route: Route) async throws -> String? {
let data = try await request(
method: "agents.list",
params: [:],
timeoutMs: 15000,
ifCurrentRoute: route)
guard await self.isCurrentRoute(route) else {
throw CancellationError()
}
return try Self.decodeConfiguredInferenceModel(data)
}
static func decodeConfiguredInferenceModel(_ data: Data) throws -> String? {
let result = try JSONDecoder().decode(AgentsListResult.self, from: data)
let primary = result.agents
.first(where: { $0.id == result.defaultid })?
.model?["primary"]?.value as? String
let trimmed = primary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
func authSource() async -> GatewayAuthSource? {
guard let client else { return nil }
return await client.authSource()
+27 -6
View File
@@ -373,7 +373,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
if let state {
let shouldWaitForConnection = state.connectionMode != .unconfigured
if !shouldWaitForConnection {
self.scheduleFirstRunOnboardingIfNeeded(gatewayConnected: false)
Task { @MainActor in
await self.scheduleFirstRunOnboardingIfNeeded(gatewayConnected: false)
}
}
Task { @MainActor in
// Validate PATH selection before local startup. Existing installs may not
@@ -385,7 +387,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
mode: state.connectionMode,
paused: state.isPaused)
guard shouldWaitForConnection else { return }
self.scheduleFirstRunOnboardingIfNeeded(
await self.scheduleFirstRunOnboardingIfNeeded(
gatewayConnected: ControlChannel.shared.state == .connected)
}
}
@@ -457,22 +459,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
connectionMode: AppState.ConnectionMode,
onboardingSeen: Bool,
hasStoredConnectionMode: Bool,
gatewayConnected: Bool) -> Bool
gatewayConnected: Bool,
configuredInferenceModel: String?) -> Bool
{
connectionMode != .unconfigured && !onboardingSeen && !hasStoredConnectionMode && gatewayConnected
let model = configuredInferenceModel?.trimmingCharacters(in: .whitespacesAndNewlines)
return connectionMode != .unconfigured &&
!onboardingSeen &&
!hasStoredConnectionMode &&
gatewayConnected &&
model?.isEmpty == false
}
private func scheduleFirstRunOnboardingIfNeeded(gatewayConnected: Bool) {
private func scheduleFirstRunOnboardingIfNeeded(gatewayConnected: Bool) async {
let connectionMode = AppStateStore.shared.connectionMode
let onboardingSeen = AppStateStore.shared.onboardingSeen
// A stored app mode means onboarding already selected a Gateway; reconnecting
// must not turn an interrupted first-run flow into a completed installation.
let hasStoredConnectionMode = UserDefaults.standard.object(forKey: connectionModeKey) != nil
var configuredInferenceModel: String?
if connectionMode != .unconfigured,
!onboardingSeen,
!hasStoredConnectionMode,
gatewayConnected,
let route = await GatewayConnection.shared.captureRoute()
{
// Bind inference discovery to the connected route. A socket without a
// default-agent model cannot run Crestodian and must stay in onboarding.
configuredInferenceModel = try? await GatewayConnection.shared.configuredInferenceModel(
ifCurrentRoute: route)
}
let shouldOpenDashboard = Self.shouldOpenDashboardInsteadOfOnboarding(
connectionMode: connectionMode,
onboardingSeen: onboardingSeen,
hasStoredConnectionMode: hasStoredConnectionMode,
gatewayConnected: gatewayConnected)
gatewayConnected: gatewayConnected,
configuredInferenceModel: configuredInferenceModel)
if connectionMode != .unconfigured, onboardingSeen || shouldOpenDashboard {
OnboardingController.markComplete()
if shouldOpenDashboard {
+31 -6
View File
@@ -128,7 +128,7 @@ struct OnboardingView: View {
@State var gatewayDiscovery: GatewayDiscoveryModel
@State var onboardingChatModel: OpenClawChatViewModel
@State var onboardingSkillsModel = SkillsSettingsModel()
@State var crestodianChat = CrestodianOnboardingChatModel()
@State var crestodianState = OnboardingCrestodianChatState()
@State var aiSetup = OnboardingAISetupModel()
@State var didLoadOnboardingSkills = false
@State var localGatewayProbe: LocalGatewayProbe?
@@ -147,9 +147,15 @@ struct OnboardingView: View {
let permissionsPageIndex = 5
/// Chat-like pages shrink the mascot so the conversation gets the room.
/// Only the full-page chat shrinks the mascot so the conversation gets the room.
var usesCompactHero: Bool {
[self.aiPageIndex, self.onboardingChatPageIndex].contains(self.activePageIndex)
Self.shouldUseCompactHero(
activePageIndex: self.activePageIndex,
onboardingChatPageIndex: self.onboardingChatPageIndex)
}
static func shouldUseCompactHero(activePageIndex: Int, onboardingChatPageIndex: Int) -> Bool {
activePageIndex == onboardingChatPageIndex
}
var heroFrameHeight: CGFloat {
@@ -228,9 +234,28 @@ struct OnboardingView: View {
/// server-side on that success). "Configure later" on the connection page
/// remains the explicit skip path.
var isAISetupBlocking: Bool {
self.activePageIndex == self.aiPageIndex &&
self.state.connectionMode != .unconfigured &&
!self.aiSetup.connected
Self.shouldBlockAISetup(
currentPage: self.currentPage,
pageOrder: self.pageOrder,
aiPageIndex: self.aiPageIndex,
connectionMode: self.state.connectionMode,
connected: self.aiSetup.connected)
}
static func shouldBlockAISetup(
currentPage: Int,
pageOrder: [Int],
aiPageIndex: Int,
connectionMode: AppState.ConnectionMode,
connected: Bool) -> Bool
{
guard connectionMode != .unconfigured,
!connected,
let aiPageCursor = pageOrder.firstIndex(of: aiPageIndex)
else {
return false
}
return currentPage >= aiPageCursor
}
var canAdvance: Bool {
@@ -1,6 +1,8 @@
import AppKit
import Foundation
import Observation
import OpenClawIPC
import OpenClawKit
import SwiftUI
/// Structured "Connect your AI" onboarding step.
@@ -30,10 +32,19 @@ final class OnboardingAISetupModel {
enum CandidateStatus: Equatable {
case untried
case testing
case failed(message: String)
case failed(Failure)
case connected
}
struct Failure: Equatable {
let summary: String
let detail: String?
var copyText: String {
self.detail ?? self.summary
}
}
enum Phase: Equatable {
case idle
case detecting
@@ -65,14 +76,14 @@ final class OnboardingAISetupModel {
private(set) var selectedKind: String?
private(set) var connectedModelRef: String?
private(set) var connectedLatencyMs: Int?
private(set) var detectError: String?
private(set) var detectError: Failure?
/// Set once every detected candidate failed; opens the manual key form.
private(set) var exhaustedAutoCandidates = false
var manualProviderID = ""
var manualKey: String = ""
private(set) var manualTesting = false
private(set) var manualError: String?
private(set) var manualError: Failure?
var showManualEntry = false
var selectedManualProvider: ManualProvider? {
@@ -125,7 +136,15 @@ final class OnboardingAISetupModel {
}
func retryFromScratch() {
self.resetForGatewayChange()
self.started = true
Task { await self.detectAndAutoConnect() }
}
/// Cancel route-bound work and discard results that belong to the previous Gateway.
func resetForGatewayChange() {
self.attemptToken = UUID()
self.started = false
self.phase = .idle
self.candidates = []
self.manualProviders = []
@@ -133,12 +152,15 @@ final class OnboardingAISetupModel {
self.providerCatalogError = nil
self.statuses = [:]
self.selectedKind = nil
self.connectedModelRef = nil
self.connectedLatencyMs = nil
self.detectError = nil
self.exhaustedAutoCandidates = false
self.manualProviderID = ""
self.manualKey = ""
self.manualError = nil
self.manualTesting = false
self.showManualEntry = false
Task { await self.detectAndAutoConnect() }
}
func detectAndAutoConnect() async {
@@ -186,7 +208,7 @@ final class OnboardingAISetupModel {
} catch {
guard token == self.attemptToken else { return }
self.phase = .ready
self.detectError = Self.friendlyTransportError(error.localizedDescription)
self.detectError = Self.transportFailure(error.localizedDescription)
self.showManualEntry = self.candidates.isEmpty
}
}
@@ -200,6 +222,45 @@ final class OnboardingAISetupModel {
return raw
}
static func activationRequestTimeoutMs(for kind: String) -> Double {
// Codex can spend 305s installing its runtime plugin before the 90s live probe.
// Keep a bounded client deadline with room for registry refresh and finalization.
kind == "codex-cli" ? 480_000 : 150_000
}
static func activationOutcomeDeadlineMs(for kind: String) -> Double {
// A request timeout removes only the client waiter. Keep a short final window
// to observe config that the still-running Gateway operation just persisted.
self.activationRequestTimeoutMs(for: kind) + 30000
}
static func activationIsPersisted(
expectedModel: String,
setupComplete: Bool,
configuredModel: String?) -> Bool
{
setupComplete && configuredModel == expectedModel
}
enum ActivationReconciliationMode: Equatable {
case none
case immediate
case polling
}
static func activationReconciliationMode(after error: Error) -> ActivationReconciliationMode {
// Decode failures happen after the side-effectful RPC returned bytes, so check persisted
// state once. Only transport-unknown outcomes need the bounded polling window.
if error is DecodingError { return .immediate }
if error is GatewayResponseError ||
error is GatewayConnectAuthError ||
error is GatewayTLSValidationError
{
return .none
}
return .polling
}
/// Candidates the automatic ladder may try: skip definitively logged-out
/// installs and anything already attempted.
private func autoCandidateAfter(kind: String?) -> Candidate? {
@@ -222,6 +283,10 @@ final class OnboardingAISetupModel {
func activate(kind: String) async {
let token = self.attemptToken
let clock = ContinuousClock()
let requestTimeoutMs = Self.activationRequestTimeoutMs(for: kind)
let outcomeDeadlineMs = Self.activationOutcomeDeadlineMs(for: kind)
let reconciliationDeadline = clock.now.advanced(by: .milliseconds(Int64(outcomeDeadlineMs)))
self.selectedKind = kind
self.phase = .testing
self.statuses[kind] = .testing
@@ -229,14 +294,14 @@ final class OnboardingAISetupModel {
let data = try await GatewayConnection.shared.request(
method: "crestodian.setup.activate",
params: ["kind": AnyCodable(kind)],
timeoutMs: 150_000,
timeoutMs: requestTimeoutMs,
retryTransportFailures: false)
guard token == self.attemptToken else { return }
let result = try JSONDecoder().decode(ActivateResult.self, from: data)
if result.ok {
self.finishConnected(kind: kind, result: result)
} else {
self.statuses[kind] = .failed(message: Self.friendlyFailure(
self.statuses[kind] = .failed(Self.failure(
label: self.candidates.first { $0.kind == kind }?.label ?? kind,
status: result.status,
error: result.error))
@@ -244,15 +309,27 @@ final class OnboardingAISetupModel {
}
} catch {
guard token == self.attemptToken else { return }
// Activating a CLI candidate can install a provider plugin (Codex),
// and the gateway restarts itself to load it dropping this RPC's
// socket after the server already tested and persisted the model.
// A transport error means "outcome unknown", not "failed": re-read
// server state before reporting failure.
if await self.reconcileActivationAfterTransportDrop(kind: kind, token: token) { return }
// Activation can persist config before a response is decoded, and Codex plugin
// setup can outlive a dropped socket. Re-read state with an error-specific budget.
switch Self.activationReconciliationMode(after: error) {
case .none:
break
case .immediate:
if await self.reconcilePersistedActivation(kind: kind, token: token) { return }
case .polling:
if await self.reconcileActivationAfterTransportDrop(
kind: kind,
token: token,
deadline: reconciliationDeadline)
{
return
}
}
guard token == self.attemptToken else { return }
self.statuses[kind] = .failed(message: Self.friendlyTransportError(error.localizedDescription))
await self.tryNextAfterFailure(of: kind)
self.statuses[kind] = .failed(Self.transportFailure(error.localizedDescription))
// Do not start another provider after an RPC or protocol failure: setup may
// already have applied, or a late Codex completion could race the next attempt.
self.phase = .ready
}
}
@@ -260,34 +337,50 @@ final class OnboardingAISetupModel {
/// (the gateway restart takes a few seconds) and count the attempt as
/// connected only when the server persisted exactly the model this
/// candidate would have written. Returns true when reconciled.
private func reconcileActivationAfterTransportDrop(kind: String, token: UUID) async -> Bool {
guard let expected = self.candidates.first(where: { $0.kind == kind })?.modelRef else {
return false
}
for delayMs in [2000, 4000, 6000] {
try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
guard token == self.attemptToken else { return false }
guard let data = try? await GatewayConnection.shared.request(
method: "crestodian.setup.detect",
params: [:],
timeoutMs: 10000,
retryTransportFailures: true)
else { continue }
guard token == self.attemptToken else { return false }
guard let result = try? JSONDecoder().decode(DetectResult.self, from: data) else { return false }
if result.setupComplete, result.configuredModel == expected {
self.finishConnected(
kind: kind,
result: ActivateResult(ok: true, modelRef: expected, latencyMs: nil, status: nil, error: nil))
return true
private func reconcileActivationAfterTransportDrop(
kind: String,
token: UUID,
deadline: ContinuousClock.Instant) async -> Bool
{
let clock = ContinuousClock()
var delayMs: UInt64 = 2000
while clock.now < deadline {
do {
try await Task.sleep(nanoseconds: delayMs * 1_000_000)
} catch {
return false
}
// The gateway answered and setup is not complete: the activation
// genuinely failed before persisting report the original error.
return false
guard token == self.attemptToken else { return false }
delayMs = min(delayMs * 2, 15000)
if await self.reconcilePersistedActivation(kind: kind, token: token) { return true }
// A healthy detect can race the still-running activation whose socket dropped;
// keep polling instead of falling through to another provider.
}
return false
}
private func reconcilePersistedActivation(kind: String, token: UUID) async -> Bool {
guard let expected = self.candidates.first(where: { $0.kind == kind })?.modelRef,
let data = try? await GatewayConnection.shared.request(
method: "crestodian.setup.detect",
params: [:],
timeoutMs: 10000,
retryTransportFailures: true),
token == self.attemptToken,
let result = try? JSONDecoder().decode(DetectResult.self, from: data),
Self.activationIsPersisted(
expectedModel: expected,
setupComplete: result.setupComplete,
configuredModel: result.configuredModel)
else {
return false
}
self.finishConnected(
kind: kind,
result: ActivateResult(ok: true, modelRef: expected, latencyMs: nil, status: nil, error: nil))
return true
}
func submitManualKey() {
let key = self.manualKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard let provider = self.selectedManualProvider, !key.isEmpty, !self.manualTesting else { return }
@@ -295,7 +388,11 @@ final class OnboardingAISetupModel {
self.manualTesting = true
let token = self.attemptToken
Task {
defer { self.manualTesting = false }
defer {
if token == self.attemptToken {
self.manualTesting = false
}
}
do {
let data = try await GatewayConnection.shared.request(
method: "crestodian.setup.activate",
@@ -312,14 +409,14 @@ final class OnboardingAISetupModel {
self.manualKey = ""
self.finishConnected(kind: "api-key", result: result)
} else {
self.manualError = Self.friendlyFailure(
self.manualError = Self.failure(
label: provider.label,
status: result.status,
error: result.error)
}
} catch {
guard token == self.attemptToken else { return }
self.manualError = error.localizedDescription
self.manualError = Self.transportFailure(error.localizedDescription)
}
}
}
@@ -343,8 +440,23 @@ final class OnboardingAISetupModel {
self.showManualEntry = true
}
/// One friendly sentence per failure bucket; raw detail stays available
/// underneath so support/docs can work with it.
/// Keep the exact Gateway-sanitized error available behind the friendly
/// summary so users can copy it into support or diagnostics.
static func failure(label: String, status: String?, error: String?) -> Failure {
let detail = error?.trimmingCharacters(in: .whitespacesAndNewlines)
return Failure(
summary: self.friendlyFailure(label: label, status: status, error: detail),
detail: detail?.isEmpty == false ? detail : nil)
}
static func transportFailure(_ raw: String) -> Failure {
let detail = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return Failure(
summary: self.friendlyTransportError(detail),
detail: detail.isEmpty ? nil : detail)
}
/// One friendly sentence per failure bucket.
static func friendlyFailure(label: String, status: String?, error: String?) -> String {
let detail = error?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
switch status {
@@ -390,8 +502,8 @@ private enum OnboardingAISetupError: LocalizedError {
struct OnboardingAISetupView: View {
@Bindable var model: OnboardingAISetupModel
@State private var showCrestodianChat = false
var crestodianChat: CrestodianOnboardingChatModel
@Binding var showCrestodianChat: Bool
var body: some View {
VStack(alignment: .leading, spacing: 12) {
@@ -446,7 +558,8 @@ struct OnboardingAISetupView: View {
if let detectError = self.model.detectError {
OnboardingErrorCard(
title: "Couldnt check this Mac for AI accounts",
message: detectError,
message: detectError.summary,
details: detectError.detail,
docsSlug: "start/onboarding",
retryTitle: "Try again")
{
@@ -480,15 +593,17 @@ struct OnboardingAISetupView: View {
self.manualSection
}
HStack {
Spacer(minLength: 0)
Button {
self.showCrestodianChat = true
} label: {
Label("Need help? Chat with Crestodian", systemImage: "questionmark.bubble")
.font(.caption)
if CrestodianAvailability.shouldShow(configuredModel: self.model.connectedModelRef) {
HStack {
Spacer(minLength: 0)
Button {
self.showCrestodianChat = true
} label: {
Label("Need help? Chat with Crestodian", systemImage: "questionmark.bubble")
.font(.caption)
}
.buttonStyle(.link)
}
.buttonStyle(.link)
}
}
@@ -536,41 +651,49 @@ struct OnboardingAISetupView: View {
private func candidateRow(_ candidate: OnboardingAISetupModel.Candidate) -> some View {
let status = self.model.statuses[candidate.kind] ?? .untried
let selected = self.model.selectedKind == candidate.kind
return Button {
self.model.userSelect(kind: candidate.kind)
} label: {
HStack(alignment: .center, spacing: 12) {
Image(systemName: Self.symbol(for: candidate.kind))
.font(.title3.weight(.semibold))
.foregroundStyle(Color.accentColor)
.frame(width: 26)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(candidate.label)
.font(.callout.weight(.semibold))
if candidate.recommended, status != .connected {
Text("Recommended")
.font(.caption2.weight(.semibold))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(Color.accentColor.opacity(0.16)))
.foregroundStyle(Color.accentColor)
return VStack(alignment: .leading, spacing: 0) {
Button {
self.model.userSelect(kind: candidate.kind)
} label: {
HStack(alignment: .center, spacing: 12) {
Image(systemName: Self.symbol(for: candidate.kind))
.font(.title3.weight(.semibold))
.foregroundStyle(Color.accentColor)
.frame(width: 26)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(candidate.label)
.font(.callout.weight(.semibold))
if candidate.recommended, status != .connected {
Text("Recommended")
.font(.caption2.weight(.semibold))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(Color.accentColor.opacity(0.16)))
.foregroundStyle(Color.accentColor)
}
}
Text(self.subtitle(for: candidate, status: status))
.font(.caption)
.foregroundStyle(self.subtitleStyle(for: status))
.lineLimit(2)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
}
Text(self.subtitle(for: candidate, status: status))
.font(.caption)
.foregroundStyle(self.subtitleStyle(for: status))
.lineLimit(2)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
self.trailingIndicator(status: status, selected: selected)
}
Spacer(minLength: 0)
self.trailingIndicator(status: status, selected: selected)
}
.openClawSelectableRowChrome(selected: selected && status != .failed(message: ""))
.buttonStyle(.plain)
.disabled(self.model.isBusy || self.model.connected)
if case let .failed(failure) = status {
OnboardingErrorDetails(text: failure.copyText)
.padding(.leading, 38)
.padding(.top, 6)
}
}
.buttonStyle(.plain)
.disabled(self.model.isBusy || self.model.connected)
.openClawSelectableRowChrome(selected: selected && !Self.isFailed(status))
}
private func subtitle(
@@ -580,8 +703,8 @@ struct OnboardingAISetupView: View {
switch status {
case .testing:
"Testing — asking \(candidate.modelRef) for a quick reply…"
case let .failed(message):
message
case let .failed(failure):
failure.summary
case .connected:
self.model.connectedSummary
case .untried:
@@ -628,6 +751,13 @@ struct OnboardingAISetupView: View {
}
}
private static func isFailed(_ status: OnboardingAISetupModel.CandidateStatus) -> Bool {
if case .failed = status {
return true
}
return false
}
private var manualSection: some View {
VStack(alignment: .leading, spacing: 10) {
if self.model.manualProviders.isEmpty {
@@ -695,7 +825,8 @@ struct OnboardingAISetupView: View {
if let manualError = self.model.manualError {
OnboardingErrorCard(
title: "That key didnt work",
message: manualError,
message: manualError.summary,
details: manualError.detail,
docsSlug: "concepts/model-providers",
retryTitle: nil,
retry: nil)
@@ -740,10 +871,27 @@ struct OnboardingAISetupView: View {
struct OnboardingErrorCard: View {
let title: String
let message: String
var details: String?
let docsSlug: String
var retryTitle: String?
var retry: (() -> Void)?
init(
title: String,
message: String,
details: String? = nil,
docsSlug: String,
retryTitle: String? = nil,
retry: (() -> Void)? = nil)
{
self.title = title
self.message = message
self.details = details
self.docsSlug = docsSlug
self.retryTitle = retryTitle
self.retry = retry
}
var body: some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
@@ -757,6 +905,9 @@ struct OnboardingErrorCard: View {
.foregroundStyle(.secondary)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
if let details = self.details {
OnboardingErrorDetails(text: details)
}
HStack(spacing: 14) {
if let retryTitle = self.retryTitle, let retry = self.retry {
Button(retryTitle, action: retry)
@@ -770,6 +921,13 @@ struct OnboardingErrorCard: View {
}
.buttonStyle(.link)
.font(.caption)
if self.details == nil {
Button("Copy error") {
OnboardingErrorDetails.copy(self.message)
}
.buttonStyle(.link)
.font(.caption)
}
}
.padding(.top, 2)
}
@@ -782,3 +940,49 @@ struct OnboardingErrorCard: View {
.fill(Color.orange.opacity(0.10)))
}
}
private struct OnboardingErrorDetails: View {
let text: String
@State private var expanded = false
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Button {
withAnimation(.easeInOut(duration: 0.15)) {
self.expanded.toggle()
}
} label: {
Label(
self.expanded ? "Hide details" : "Show details",
systemImage: self.expanded ? "chevron.down" : "chevron.right")
}
.buttonStyle(.link)
.font(.caption)
if self.expanded {
Text(self.text)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(Color.primary.opacity(0.05)))
Button {
Self.copy(self.text)
} label: {
Label("Copy error", systemImage: "doc.on.doc")
}
.buttonStyle(.link)
.font(.caption)
}
}
}
static func copy(_ text: String) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
}
}
@@ -3,9 +3,22 @@ import Observation
import OpenClawIPC
import SwiftUI
@MainActor
@Observable
final class OnboardingCrestodianChatState {
var chat = CrestodianOnboardingChatModel()
var isPresented = false
func resetForGatewayChange() {
self.isPresented = false
self.chat.invalidate()
self.chat = CrestodianOnboardingChatModel()
}
}
/// Onboarding talks to Crestodian over the gateway `crestodian.chat` RPC.
/// The conversation is the setup: no wizard steps, no forms. Crestodian works
/// before any model is configured, so this page functions on a fresh machine.
/// The conversation is available after structured setup establishes working
/// inference, so the model-backed helper can answer reliably.
@MainActor
@Observable
final class CrestodianOnboardingChatModel {
@@ -30,15 +43,26 @@ final class CrestodianOnboardingChatModel {
/// Called after every assistant reply (setup may have applied config).
var onReplyReceived: (() -> Void)?
private let sessionId: String
private var sessionId: String
private let sessionPrefix: String
private let gateway: GatewayConnection
/// "onboarding" seeds the first-run setup proposal; nil gets the
/// status/repair greeting (used by Settings Crestodian).
private let welcomeVariant: String?
private var started = false
private var requestGeneration: UInt64? = 0
private var requestTask: Task<Void, Never>?
private var route: GatewayConnection.Route?
init(welcomeVariant: String? = "onboarding", sessionPrefix: String = "mac-onboarding") {
init(
welcomeVariant: String? = "onboarding",
sessionPrefix: String = "mac-onboarding",
gateway: GatewayConnection = .shared)
{
self.welcomeVariant = welcomeVariant
self.sessionPrefix = sessionPrefix
self.sessionId = "\(sessionPrefix)-\(UUID().uuidString)"
self.gateway = gateway
}
private struct ChatResult: Decodable {
@@ -49,29 +73,96 @@ final class CrestodianOnboardingChatModel {
}
func startIfNeeded() async {
guard !self.started else { return }
guard !self.started,
self.errorMessage == nil,
let generation = self.requestGeneration
else { return }
self.started = true
await self.requestReply(message: nil)
await self.requestReply(message: nil, generation: generation)
if Task.isCancelled, self.requestGeneration == generation {
self.started = false
self.errorMessage = "Crestodian was interrupted. Restart to try again."
}
}
func send() {
@discardableResult
func send() -> Task<Void, Never>? {
let text = self.input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, !self.isSending, self.errorMessage == nil else { return }
guard let generation = self.requestGeneration,
!text.isEmpty,
!self.isSending,
self.errorMessage == nil
else { return nil }
self.input = ""
self.messages.append(Message(
role: .user,
text: self.expectsSensitiveReply ? "<redacted secret>" : text))
Task { await self.requestReply(message: text) }
let task = Task { [weak self] in
guard let self else { return }
await self.requestReply(message: text, generation: generation)
}
self.requestTask = task
return task
}
func restartAfterError() {
Task { await self.requestReply(message: nil, reset: true) }
@discardableResult
func restartAfterError() -> Task<Void, Never>? {
guard let previousGeneration = self.requestGeneration else { return nil }
let generation = previousGeneration &+ 1
self.requestGeneration = generation
self.requestTask?.cancel()
self.sessionId = "\(self.sessionPrefix)-\(UUID().uuidString)"
self.route = nil
self.started = true
self.messages.removeAll()
self.input = ""
self.expectsSensitiveReply = false
let task = Task { [weak self] in
guard let self else { return }
await self.requestReply(message: nil, generation: generation)
}
self.requestTask = task
return task
}
private func requestReply(message: String?, reset: Bool = false) async {
/// Invalidate before replacing the model so queued secret-bearing sends cannot
/// resume against whichever Gateway route becomes current next.
func invalidate() {
self.requestGeneration = nil
self.requestTask?.cancel()
self.requestTask = nil
self.isSending = false
}
private func isCurrentRequest(_ generation: UInt64) -> Bool {
self.requestGeneration == generation && !Task.isCancelled
}
private func sessionRoute(for generation: UInt64) async throws -> GatewayConnection.Route {
if let route = self.route {
return route
}
guard let route = await self.gateway.captureRoute() else {
guard self.isCurrentRequest(generation) else { throw CancellationError() }
throw NSError(
domain: "Gateway",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "gateway not configured"])
}
guard self.isCurrentRequest(generation) else { throw CancellationError() }
self.route = route
return route
}
private func requestReply(message: String?, generation: UInt64) async {
guard self.isCurrentRequest(generation) else { return }
self.isSending = true
self.errorMessage = nil
defer { self.isSending = false }
defer {
if self.requestGeneration == generation {
self.isSending = false
}
}
do {
var params: [String: AnyCodable] = [
"sessionId": AnyCodable(self.sessionId),
@@ -82,19 +173,17 @@ final class CrestodianOnboardingChatModel {
if let message {
params["message"] = AnyCodable(message)
}
if reset {
params["reset"] = AnyCodable(true)
}
let data = try await GatewayConnection.shared.request(
let route = try await self.sessionRoute(for: generation)
guard self.isCurrentRequest(generation) else { return }
let data = try await self.gateway.request(
method: "crestodian.chat",
params: params,
timeoutMs: 190_000,
retryTransportFailures: false)
ifCurrentRoute: route)
guard self.isCurrentRequest(generation) else { return }
guard await self.gateway.isCurrentRoute(route) else { throw CancellationError() }
let result = try JSONDecoder().decode(ChatResult.self, from: data)
if reset {
self.messages.removeAll()
self.input = ""
}
guard self.isCurrentRequest(generation) else { return }
self.expectsSensitiveReply = result.sensitive == true
self.messages.append(Message(role: .assistant, text: result.reply))
self.onReplyReceived?()
@@ -102,6 +191,14 @@ final class CrestodianOnboardingChatModel {
self.onAgentHandoff?()
}
} catch {
guard self.requestGeneration == generation else { return }
if error is CancellationError || Task.isCancelled {
self.started = false
self.errorMessage = Task.isCancelled
? "Crestodian was interrupted. Restart to try again."
: "The Gateway connection changed. Restart Crestodian to reconnect."
return
}
self.errorMessage = error.localizedDescription
}
}
@@ -3,7 +3,8 @@ import SwiftUI
extension OnboardingView {
/// Structured AI setup: detect what's already on this machine, test the
/// best option live, fall through automatically, offer an API-key form
/// when nothing works. Crestodian chat stays one click away for help.
/// when nothing works. Crestodian becomes available only after inference
/// has completed a live round-trip.
func aiSetupPage() -> some View {
VStack(spacing: 12) {
Text("Connect your AI")
@@ -16,7 +17,10 @@ extension OnboardingView {
.fixedSize(horizontal: false, vertical: true)
ScrollView {
OnboardingAISetupView(model: self.aiSetup, crestodianChat: self.crestodianChat)
OnboardingAISetupView(
model: self.aiSetup,
crestodianChat: self.crestodianState.chat,
showCrestodianChat: self.$crestodianState.isPresented)
.padding(.vertical, 4)
.padding(.trailing, 12)
}
@@ -41,9 +41,7 @@ extension OnboardingView {
self.updateMonitoring(for: self.activePageIndex(for: newValue))
}
.onChange(of: state.connectionMode) { _, _ in
let oldActive = self.activePageIndex
self.reconcilePageForModeChange(previousActivePageIndex: oldActive)
self.updateDiscoveryMonitoring(for: self.activePageIndex)
self.handleConnectionModeChange()
}
.onChange(of: needsBootstrap) { _, _ in
if self.currentPage >= self.pageOrder.count {
@@ -87,6 +85,59 @@ extension OnboardingView {
withAnimation { self.currentPage = max(0, self.pageOrder.count - 1) }
}
func handleConnectionModeChange(updatePageMonitoring: ((Int) -> Void)? = nil) {
self.resetGatewayBoundAIState()
let oldActive = self.activePageIndex
self.reconcilePageForModeChange(previousActivePageIndex: oldActive)
self.returnToInferenceSetupIfNeeded()
if let updatePageMonitoring {
updatePageMonitoring(self.activePageIndex)
return
}
// A mode swap can keep the same page cursor, so its onChange hook may not restart AI setup.
self.updateMonitoring(for: self.activePageIndex)
}
func resetGatewayBoundAIState() {
self.aiSetup.resetForGatewayChange()
// Crestodian sessions belong to one Gateway. Dismiss and replace the chat so
// changing routes cannot send an old session ID to the new endpoint.
self.crestodianState.resetForGatewayChange()
}
func restartGatewayBoundAISetup(updatePageMonitoring: ((Int) -> Void)? = nil) {
self.resetGatewayBoundAIState()
self.returnToInferenceSetupIfNeeded()
if let updatePageMonitoring {
updatePageMonitoring(self.activePageIndex)
return
}
// A route edit can leave the page cursor unchanged, so explicitly restart its work.
self.updateMonitoring(for: self.activePageIndex)
}
private func returnToInferenceSetupIfNeeded() {
let targetPage = Self.pageCursorAfterGatewayReset(
currentPage: self.currentPage,
pageOrder: self.pageOrder,
aiPageIndex: self.aiPageIndex)
guard targetPage != self.currentPage else { return }
withAnimation { self.currentPage = targetPage }
}
static func pageCursorAfterGatewayReset(
currentPage: Int,
pageOrder: [Int],
aiPageIndex: Int) -> Int
{
guard let aiPageCursor = pageOrder.firstIndex(of: aiPageIndex),
currentPage >= aiPageCursor
else {
return currentPage
}
return aiPageCursor
}
var navigationBar: some View {
let connectionLockIndex = pageOrder.firstIndex(of: connectionPageIndex)
let cliLockIndex = pageOrder.firstIndex(of: cliPageIndex)
@@ -163,6 +163,12 @@ extension OnboardingView {
.onChange(of: self.state.remoteUrl) { _, _ in
self.resetRemoteProbeFeedback()
}
.onChange(of: self.state.remoteToken) { _, _ in
self.resetRemoteProbeFeedback()
}
.onChange(of: self.state.remoteIdentity) { _, _ in
self.resetRemoteProbeFeedback()
}
}
private var localGatewaySubtitle: String {
@@ -533,6 +539,7 @@ extension OnboardingView {
private func resetRemoteProbeFeedback() {
self.remoteProbeState = .idle
self.remoteAuthIssue = nil
self.restartGatewayBoundAISetup()
}
static func remoteAuthPromptStyle(
@@ -8,18 +8,33 @@ struct SettingsRootView: View {
@State private var monitoringPermissions = false
@State private var selectedTab: SettingsTab = .general
@State private var cachedTabs: Set<SettingsTab>
@State private var inferenceConfiguration: InferenceConfiguration
@State private var trackedInferenceGatewayID: String?
@State private var inferenceRefreshTrigger = InferenceRefreshTrigger.invalidate(UUID())
@State private var crestodianChatIdentity = UUID()
@State private var deferredTab: SettingsTab?
@State private var columnVisibility: NavigationSplitViewVisibility = .all
@State private var snapshotPaths: (configPath: String?, stateDir: String?) = (nil, nil)
let updater: UpdaterProviding?
private let isPreview = ProcessInfo.processInfo.isPreview
private let isNixMode = ProcessInfo.processInfo.isNixMode
init(state: AppState, updater: UpdaterProviding?, initialTab: SettingsTab? = nil) {
init(
state: AppState,
updater: UpdaterProviding?,
initialTab: SettingsTab? = nil,
configuredInferenceModel: String? = nil)
{
let initial = initialTab ?? .general
self.state = state
self.updater = updater
self._selectedTab = State(initialValue: initial)
self._cachedTabs = State(initialValue: [initial])
self._inferenceConfiguration = State(initialValue: configuredInferenceModel.map {
.loaded($0)
} ?? .loading)
self._trackedInferenceGatewayID = State(initialValue: nil)
self._deferredTab = State(initialValue: nil)
}
var body: some View {
@@ -45,43 +60,75 @@ struct SettingsRootView: View {
.onReceive(NotificationCenter.default.publisher(for: .openclawSelectSettingsTab)) { note in
if let tab = note.object as? SettingsTab {
withAnimation(.spring(response: 0.32, dampingFraction: 0.85)) {
self.selectedTab = self.validTab(for: tab)
self.selectRequestedTab(tab)
}
}
}
.onAppear {
if let pending = SettingsTabRouter.consumePending() {
self.selectedTab = self.validTab(for: pending)
self.selectRequestedTab(pending)
} else {
self.selectRequestedTab(self.selectedTab)
}
self.cacheSelectedTab()
self.updatePermissionMonitoring(for: self.selectedTab)
self.trackedInferenceGatewayID = MacChatTranscriptCache.currentGatewayID()
}
.onChange(of: self.state.debugPaneEnabled) { _, enabled in
if !enabled, self.selectedTab == .debug {
self.selectedTab = .general
}
}
.onChange(of: self.inferenceConfiguration) { _, configuration in
if !CrestodianAvailability.shouldShow(configuredModel: configuration.configuredModel),
self.selectedTab == .crestodian
{
self.selectedTab = .general
}
}
.onChange(of: self.selectedTab) { _, newValue in
self.cachedTabs.insert(newValue)
self.updatePermissionMonitoring(for: newValue)
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
guard self.selectedTab == .permissions else { return }
Task { await self.refreshPerms() }
if self.selectedTab == .permissions {
Task { await self.refreshPerms() }
}
self.scheduleInferenceRefresh(clearPrevious: false)
}
.onReceive(NotificationCenter.default.publisher(for: .openclawConfigDidChange)) { _ in
let gatewayID = MacChatTranscriptCache.currentGatewayID()
let plan = Self.configRefreshPlan(
selectedTab: self.selectedTab,
previousGatewayID: self.trackedInferenceGatewayID,
currentGatewayID: gatewayID)
self.trackedInferenceGatewayID = gatewayID
self.scheduleInferenceRefresh(
clearPrevious: plan.clearsPrevious,
resetCrestodian: plan.resetsCrestodian)
}
.onDisappear { self.stopPermissionMonitoring() }
.task {
guard !self.isPreview else { return }
await self.refreshPerms()
}
.task(id: self.state.connectionMode) {
.onChange(of: self.state.connectionMode) { _, _ in
self.trackedInferenceGatewayID = MacChatTranscriptCache.currentGatewayID()
self.scheduleInferenceRefresh(clearPrevious: true, resetCrestodian: true)
}
.task(id: self.inferenceRefreshTrigger) {
guard !self.isPreview else { return }
await self.refreshSnapshotPaths()
await self.refreshInferenceConfiguration(
clearPrevious: self.inferenceRefreshTrigger.clearsPrevious)
}
}
private var visibleGroups: [SettingsTabGroup] {
SettingsTabGroup.defaultGroups(showDebug: self.state.debugPaneEnabled)
SettingsTabGroup.defaultGroups(
showDebug: self.state.debugPaneEnabled,
showCrestodian: CrestodianAvailability.shouldShow(
configuredModel: self.inferenceConfiguration.configuredModel))
}
private var sidebarSelection: Binding<SettingsTab?> {
@@ -89,7 +136,7 @@ struct SettingsRootView: View {
get: { self.selectedTab },
set: { tab in
guard let tab else { return }
self.selectedTab = self.validTab(for: tab)
self.selectRequestedTab(tab)
})
}
@@ -178,7 +225,12 @@ struct SettingsRootView: View {
case .voiceWake:
AnyView(VoiceWakeSettings(state: self.state, isActive: self.selectedTab == .voiceWake))
case .crestodian:
AnyView(CrestodianSettings(isActive: self.selectedTab == tab))
AnyView(CrestodianSettings(
isActive: self.selectedTab == tab,
onReplyReceived: {
self.scheduleInferenceRefresh(clearPrevious: false)
})
.id(self.crestodianChatIdentity))
case .channels:
AnyView(ChannelsSettings(isActive: self.selectedTab == tab))
case .skills:
@@ -200,8 +252,49 @@ struct SettingsRootView: View {
}
}
private func validTab(for requested: SettingsTab) -> SettingsTab {
if requested == .debug, !self.state.debugPaneEnabled { return .general }
private func selectRequestedTab(_ requested: SettingsTab) {
let selection = Self.tabSelection(
requested: requested,
showDebug: self.state.debugPaneEnabled,
inferenceConfiguration: self.inferenceConfiguration)
self.deferredTab = selection.deferred
self.selectedTab = selection.selected
}
struct TabSelection: Equatable {
let selected: SettingsTab
let deferred: SettingsTab?
}
static func tabSelection(
requested: SettingsTab,
showDebug: Bool,
inferenceConfiguration: InferenceConfiguration) -> TabSelection
{
let showCrestodian = CrestodianAvailability.shouldShow(
configuredModel: inferenceConfiguration.configuredModel)
let deferred = requested == .crestodian && !showCrestodian && !inferenceConfiguration.isLoaded
? requested
: nil
return TabSelection(
selected: Self.normalizedTab(
requested,
showDebug: showDebug,
showCrestodian: showCrestodian),
deferred: deferred)
}
static func normalizedTab(
_ requested: SettingsTab,
showDebug: Bool,
showCrestodian: Bool) -> SettingsTab
{
if requested == .debug, !showDebug {
return .general
}
if requested == .crestodian, !showCrestodian {
return .general
}
return requested
}
@@ -215,6 +308,113 @@ struct SettingsRootView: View {
self.snapshotPaths = paths
}
@MainActor
private func refreshInferenceConfiguration(clearPrevious: Bool) async {
if clearPrevious {
self.inferenceConfiguration = .loading
}
guard let route = await GatewayConnection.shared.captureRoute() else { return }
do {
let model = try await GatewayConnection.shared.configuredInferenceModel(
ifCurrentRoute: route)
guard !Task.isCancelled else { return }
self.inferenceConfiguration = Self.configurationAfterInferenceRefresh(
current: self.inferenceConfiguration,
result: .confirmed(model))
if let deferredTab = self.deferredTab {
self.selectRequestedTab(deferredTab)
}
} catch is CancellationError {
// A route change or task cancellation must not apply stale gateway state.
} catch {
guard !Task.isCancelled else { return }
// Preserve only route-confirmed truth. If this route has never loaded, stay hidden
// until app activation, config invalidation, or a route change triggers another probe.
self.inferenceConfiguration = Self.configurationAfterInferenceRefresh(
current: self.inferenceConfiguration,
result: .failed)
}
}
enum InferenceConfiguration: Equatable {
case loading
case loaded(String?)
var configuredModel: String? {
switch self {
case .loading: nil
case let .loaded(model): model
}
}
var isLoaded: Bool {
if case .loaded = self {
true
} else {
false
}
}
}
enum InferenceRefreshResult {
case confirmed(String?)
case failed
}
enum InferenceRefreshTrigger: Equatable {
case invalidate(UUID)
case verify(UUID)
var clearsPrevious: Bool {
switch self {
case .invalidate: true
case .verify: false
}
}
}
struct ConfigRefreshPlan: Equatable {
let clearsPrevious: Bool
let resetsCrestodian: Bool
}
static func configRefreshPlan(
selectedTab: SettingsTab,
previousGatewayID: String?,
currentGatewayID: String?) -> ConfigRefreshPlan
{
let routeChanged = previousGatewayID != currentGatewayID
return ConfigRefreshPlan(
clearsPrevious: routeChanged || selectedTab != .crestodian,
resetsCrestodian: routeChanged)
}
static func configurationAfterInferenceRefresh(
current: InferenceConfiguration,
result: InferenceRefreshResult) -> InferenceConfiguration
{
switch result {
case let .confirmed(model): .loaded(model)
case .failed: current
}
}
private func scheduleInferenceRefresh(clearPrevious: Bool, resetCrestodian: Bool = false) {
if resetCrestodian {
// Crestodian sessions are gateway-owned. Re-key the cached detail so a route
// change cannot send old conversation state to a new endpoint.
self.crestodianChatIdentity = UUID()
}
if clearPrevious {
// Preserve an active or pending Crestodian request while config truth is revalidated.
// A confirmed model restores it; a confirmed missing model leaves General selected.
let requestedTab = self.deferredTab ?? self.selectedTab
self.inferenceConfiguration = .loading
self.selectRequestedTab(requestedTab)
}
self.inferenceRefreshTrigger = clearPrevious ? .invalidate(UUID()) : .verify(UUID())
}
@MainActor
private func refreshPerms() async {
guard !self.isPreview else { return }
@@ -231,7 +431,7 @@ struct SettingsRootView: View {
}
}
private struct SettingsTabGroup: Identifiable {
struct SettingsTabGroup: Identifiable {
let title: String
let tabs: [SettingsTab]
@@ -239,9 +439,12 @@ private struct SettingsTabGroup: Identifiable {
self.title
}
static func defaultGroups(showDebug: Bool) -> [SettingsTabGroup] {
static func defaultGroups(showDebug: Bool, showCrestodian: Bool) -> [SettingsTabGroup] {
let basicTabs: [SettingsTab] = showCrestodian
? [.general, .connection, .permissions, .voiceWake, .crestodian]
: [.general, .connection, .permissions, .voiceWake]
var groups = [
SettingsTabGroup(title: "Basics", tabs: [.general, .connection, .permissions, .voiceWake, .crestodian]),
SettingsTabGroup(title: "Basics", tabs: basicTabs),
SettingsTabGroup(title: "Automation", tabs: [.channels, .skills, .cron, .execApprovals]),
SettingsTabGroup(title: "Data", tabs: [.sessions, .instances]),
SettingsTabGroup(title: "Advanced", tabs: [.config]),
@@ -327,7 +530,11 @@ extension Notification.Name {
struct SettingsRootView_Previews: PreviewProvider {
static var previews: some View {
ForEach(SettingsTab.allCases, id: \.self) { tab in
SettingsRootView(state: .preview, updater: DisabledUpdaterController(), initialTab: tab)
SettingsRootView(
state: .preview,
updater: DisabledUpdaterController(),
initialTab: tab,
configuredInferenceModel: tab == .crestodian ? "openai/gpt-5.5" : nil)
.previewDisplayName(tab.title)
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
}
@@ -40,6 +40,13 @@ struct ConfigStoreTests {
@Test func `save routes to remote in remote mode`() async throws {
var localHit = false
var remoteHit = false
let changeCount = NotificationCount()
let observer = NotificationCenter.default.addObserver(
forName: .openclawConfigDidChange,
object: nil,
queue: nil)
{ _ in changeCount.increment() }
defer { NotificationCenter.default.removeObserver(observer) }
await ConfigStore._testSetOverrides(.init(
isRemoteMode: { true },
saveLocal: { _ in localHit = true },
@@ -50,6 +57,7 @@ struct ConfigStoreTests {
await ConfigStore._testClearOverrides()
#expect(remoteHit)
#expect(!localHit)
#expect(changeCount.value == 1)
}
@Test func `save routes to local in local mode`() async throws {
@@ -67,6 +75,29 @@ struct ConfigStoreTests {
#expect(!remoteHit)
}
@Test func `failed save does not announce config change`() async {
let changeCount = NotificationCount()
let observer = NotificationCenter.default.addObserver(
forName: .openclawConfigDidChange,
object: nil,
queue: nil)
{ _ in changeCount.increment() }
defer { NotificationCenter.default.removeObserver(observer) }
await ConfigStore._testSetOverrides(.init(
isRemoteMode: { true },
saveRemote: { _ in
throw NSError(domain: "ConfigStoreTests", code: 1)
}))
do {
try await ConfigStore.save(["remote": true])
Issue.record("Expected save to fail")
} catch {}
await ConfigStore._testClearOverrides()
#expect(changeCount.value == 0)
}
@Test func `local save does not fall back to direct write after stale gateway rejection`() async throws {
let stateDir = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
@@ -139,3 +170,16 @@ struct ConfigStoreTests {
}
}
}
private final class NotificationCount: @unchecked Sendable {
private let lock = NSLock()
private var count = 0
var value: Int {
self.lock.withLock { self.count }
}
func increment() {
self.lock.withLock { self.count += 1 }
}
}
@@ -239,6 +239,27 @@ private func makeTestGatewayConnection() -> (GatewayConnection, FakeWebSocketSes
#expect(identity.contract == "global|primary|work")
}
@Test(arguments: [
(
#"{"defaultId":"main","mainKey":"main","scope":"per-sender","agents":[{"id":"main","model":{"primary":"openai/gpt-5.5"}}]}"#,
"openai/gpt-5.5"),
(
#"{"defaultId":"work","mainKey":"main","scope":"per-sender","agents":[{"id":"main","model":{"primary":"openai/gpt-5.5"}},{"id":"work","model":{"primary":"anthropic/claude-opus-4-8"}}]}"#,
"anthropic/claude-opus-4-8"),
(
#"{"defaultId":"main","mainKey":"main","scope":"per-sender","agents":[{"id":"main"},{"id":"work","model":{"primary":"openai/gpt-5.5"}}]}"#,
nil),
(
#"{"defaultId":"main","mainKey":"main","scope":"per-sender","agents":[{"id":"main","model":{"primary":" "}}]}"#,
nil),
])
func `configured inference model follows the default agent`(
json: String,
expected: String?) throws
{
#expect(try GatewayConnection.decodeConfiguredInferenceModel(Data(json.utf8)) == expected)
}
private static func messageData(_ message: URLSessionWebSocketTask.Message) -> Data? {
switch message {
case let .string(text):
@@ -81,24 +81,39 @@ struct MenuContentSmokeTests {
#expect(!didOpenDashboard)
}
@Test func `connected configured gateway opens dashboard instead of onboarding`() {
@Test func `connected configured gateway with inference opens dashboard instead of onboarding`() {
for mode in [AppState.ConnectionMode.local, .remote] {
let shouldOpen = AppDelegate.shouldOpenDashboardInsteadOfOnboarding(
connectionMode: mode,
onboardingSeen: false,
hasStoredConnectionMode: false,
gatewayConnected: true)
gatewayConnected: true,
configuredInferenceModel: " openai/gpt-5.5 ")
#expect(shouldOpen)
}
}
@Test func `connected configured gateway without inference keeps onboarding`() {
for model in [String?.none, "", " "] {
let shouldOpen = AppDelegate.shouldOpenDashboardInsteadOfOnboarding(
connectionMode: .remote,
onboardingSeen: false,
hasStoredConnectionMode: false,
gatewayConnected: true,
configuredInferenceModel: model)
#expect(!shouldOpen)
}
}
@Test func `disconnected configured gateway keeps onboarding recovery`() {
let shouldOpen = AppDelegate.shouldOpenDashboardInsteadOfOnboarding(
connectionMode: .remote,
onboardingSeen: false,
hasStoredConnectionMode: false,
gatewayConnected: false)
gatewayConnected: false,
configuredInferenceModel: "openai/gpt-5.5")
#expect(!shouldOpen)
}
@@ -108,7 +123,8 @@ struct MenuContentSmokeTests {
connectionMode: .local,
onboardingSeen: false,
hasStoredConnectionMode: true,
gatewayConnected: true)
gatewayConnected: true,
configuredInferenceModel: "openai/gpt-5.5")
#expect(!shouldOpen)
}
@@ -0,0 +1,91 @@
import Foundation
import OpenClawKit
import Testing
@testable import OpenClaw
@Suite(.serialized)
@MainActor
struct OnboardingAISetupTests {
@Test func `candidate failure keeps friendly summary and exact detail`() {
let failure = OnboardingAISetupModel.failure(
label: "Codex CLI",
status: "auth",
error: "Codex login expired (request 42)")
#expect(failure.summary == "Codex CLI is installed, but the login didnt work. Sign in again, then retry.")
#expect(failure.detail == "Codex login expired (request 42)")
#expect(failure.copyText == "Codex login expired (request 42)")
}
@Test func `candidate failure omits empty detail`() {
let failure = OnboardingAISetupModel.failure(
label: "Codex CLI",
status: "timeout",
error: " ")
#expect(failure.summary == "Codex CLI didnt answer in time.")
#expect(failure.detail == nil)
#expect(failure.copyText == failure.summary)
}
@Test func `transport failure preserves original detail`() {
let failure = OnboardingAISetupModel.transportFailure(
"Gateway request failed: connection reset")
#expect(failure.summary == "Gateway request failed: connection reset")
#expect(failure.detail == "Gateway request failed: connection reset")
}
@Test func `codex activation covers install probe and finalization`() {
#expect(OnboardingAISetupModel.activationRequestTimeoutMs(for: "codex-cli") == 480_000)
#expect(OnboardingAISetupModel.activationRequestTimeoutMs(for: "claude-cli") == 150_000)
#expect(OnboardingAISetupModel.activationRequestTimeoutMs(for: "codex-cli") >= (305 + 90) * 1000)
#expect(OnboardingAISetupModel.activationOutcomeDeadlineMs(for: "codex-cli") == 510_000)
}
@Test func `incomplete detection is not a reconciled activation`() {
#expect(!OnboardingAISetupModel.activationIsPersisted(
expectedModel: "openai/gpt-5.5",
setupComplete: false,
configuredModel: nil))
#expect(OnboardingAISetupModel.activationIsPersisted(
expectedModel: "openai/gpt-5.5",
setupComplete: true,
configuredModel: "openai/gpt-5.5"))
}
@Test func `definitive gateway response does not enter reconciliation`() {
let responseError = GatewayResponseError(
method: "crestodian.setup.activate",
code: "UNKNOWN_METHOD",
message: "unknown method",
details: nil)
let timeout = NSError(
domain: "Gateway",
code: 5,
userInfo: [NSLocalizedDescriptionKey: "gateway request timed out"])
let decodeError = DecodingError.dataCorrupted(.init(
codingPath: [],
debugDescription: "invalid activation response"))
#expect(OnboardingAISetupModel.activationReconciliationMode(after: responseError) == .none)
#expect(OnboardingAISetupModel.activationReconciliationMode(after: decodeError) == .immediate)
#expect(OnboardingAISetupModel.activationReconciliationMode(after: timeout) == .polling)
}
@Test func `gateway change clears route-bound setup state`() {
let model = OnboardingAISetupModel()
model.manualProviderID = "openai"
model.manualKey = "temporary-key"
model.showManualEntry = true
model.resetForGatewayChange()
#expect(model.phase == .idle)
#expect(model.connectedModelRef == nil)
#expect(model.connectedLatencyMs == nil)
#expect(model.manualProviderID.isEmpty)
#expect(model.manualKey.isEmpty)
#expect(!model.showManualEntry)
}
}
@@ -0,0 +1,289 @@
import Foundation
import OpenClawKit
import Testing
@testable import OpenClaw
private actor CrestodianGatewayConfig {
private var token = "a"
func snapshotToken() -> String {
self.token
}
func setToken(_ token: String) {
self.token = token
}
}
private actor CrestodianSessionRecorder {
private var sessionIDs: [String] = []
func record(_ sessionID: String) {
self.sessionIDs.append(sessionID)
}
func snapshot() -> [String] {
self.sessionIDs
}
}
private actor CrestodianRequestGate {
private var consumed = false
private var released = false
private var continuation: CheckedContinuation<Void, Never>?
func waitIfFirst() async -> Bool {
guard !self.consumed else { return false }
self.consumed = true
if !self.released {
await withCheckedContinuation { continuation in
self.continuation = continuation
}
}
return true
}
func release() {
self.released = true
self.continuation?.resume()
self.continuation = nil
}
}
private func crestodianSessionID(from message: URLSessionWebSocketTask.Message) -> String? {
let data: Data? = switch message {
case let .data(data): data
case let .string(string): string.data(using: .utf8)
@unknown default: nil
}
guard let data,
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
object["method"] as? String == "crestodian.chat",
let params = object["params"] as? [String: Any]
else { return nil }
return params["sessionId"] as? String
}
private func crestodianResponse(id: String, action: String = "none") -> Data {
Data(
"""
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": {
"sessionId": "test-session",
"reply": "ready",
"action": "\(action)",
"sensitive": false
}
}
""".utf8)
}
@Suite(.serialized)
@MainActor
struct OnboardingCrestodianChatTests {
@Test func `settings callback refreshes inference after assistant reply`() async throws {
let session = GatewayTestWebSocketSession(taskFactory: {
GatewayTestWebSocketTask(sendHook: { task, message, sendIndex in
guard sendIndex > 0,
let id = GatewayWebSocketTestSupport.requestID(from: message)
else { return }
task.emitReceiveSuccess(.data(crestodianResponse(id: id)))
})
})
let url = try #require(URL(string: "ws://example.invalid"))
let gateway = GatewayConnection(
configProvider: { (url: url, token: nil, password: nil) },
sessionBox: WebSocketSessionBox(session: session))
let chat = CrestodianOnboardingChatModel(gateway: gateway)
var refreshCount = 0
CrestodianSettings.configureChatCallbacks(
for: chat,
onReplyReceived: { refreshCount += 1 })
await chat.startIfNeeded()
#expect(chat.messages.map(\.text) == ["ready"])
#expect(refreshCount == 1)
}
@Test func `gateway reset invalidates queued send and restart tasks`() async throws {
let session = GatewayTestWebSocketSession()
let url = try #require(URL(string: "ws://example.invalid"))
let gateway = GatewayConnection(
configProvider: { (url: url, token: nil, password: nil) },
sessionBox: WebSocketSessionBox(session: session))
let chat = CrestodianOnboardingChatModel(gateway: gateway)
let state = OnboardingCrestodianChatState()
state.chat = chat
var replyCount = 0
var handoffCount = 0
chat.onReplyReceived = { replyCount += 1 }
chat.onAgentHandoff = { handoffCount += 1 }
chat.input = "route-bound secret"
state.isPresented = true
let sendTask = try #require(chat.send())
let restartTask = try #require(chat.restartAfterError())
state.resetForGatewayChange()
await sendTask.value
await restartTask.value
#expect(session.snapshotMakeCount() == 0)
#expect(chat.messages.isEmpty)
#expect(replyCount == 0)
#expect(handoffCount == 0)
#expect(!state.isPresented)
#expect(state.chat !== chat)
#expect(chat.send() == nil)
#expect(chat.restartAfterError() == nil)
}
@Test func `chat session stays bound to its original gateway route`() async throws {
let config = CrestodianGatewayConfig()
let recorder = CrestodianSessionRecorder()
let session = GatewayTestWebSocketSession(taskFactory: {
GatewayTestWebSocketTask(sendHook: { task, message, sendIndex in
guard sendIndex > 0,
let id = GatewayWebSocketTestSupport.requestID(from: message)
else { return }
if let sessionID = crestodianSessionID(from: message) {
await recorder.record(sessionID)
}
task.emitReceiveSuccess(.data(crestodianResponse(id: id)))
})
})
let url = try #require(URL(string: "ws://example.invalid"))
let gateway = GatewayConnection(
configProvider: {
let token = await config.snapshotToken()
return (url: url, token: token, password: nil)
},
sessionBox: WebSocketSessionBox(session: session))
let chat = CrestodianOnboardingChatModel(gateway: gateway)
await chat.startIfNeeded()
#expect(chat.messages.map(\.text) == ["ready"])
#expect(session.snapshotMakeCount() == 1)
#expect(session.latestTask()?.snapshotSendCount() == 2)
let routeASessionIDs = await recorder.snapshot()
#expect(routeASessionIDs.count == 1)
let routeASessionID = try #require(routeASessionIDs.first)
await config.setToken("b")
chat.input = "must stay on route a"
let sendTask = try #require(chat.send())
await sendTask.value
#expect(session.snapshotMakeCount() == 1)
#expect(session.latestTask()?.snapshotSendCount() == 2)
#expect(chat.messages.map(\.text) == ["ready", "must stay on route a"])
#expect(chat.errorMessage == "The Gateway connection changed. Restart Crestodian to reconnect.")
#expect(await recorder.snapshot() == [routeASessionID])
let restartTask = try #require(chat.restartAfterError())
await restartTask.value
#expect(session.snapshotMakeCount() == 2)
#expect(session.latestTask()?.snapshotSendCount() == 2)
#expect(chat.messages.map(\.text) == ["ready"])
#expect(chat.errorMessage == nil)
let sessionIDs = await recorder.snapshot()
#expect(sessionIDs.count == 2)
#expect(sessionIDs.first == routeASessionID)
#expect(sessionIDs.last != routeASessionID)
}
@Test func `route change while reply is in flight discards reply and action`() async throws {
let config = CrestodianGatewayConfig()
let requestGate = CrestodianRequestGate()
let session = GatewayTestWebSocketSession(taskFactory: {
GatewayTestWebSocketTask(sendHook: { task, message, sendIndex in
guard sendIndex > 0,
let id = GatewayWebSocketTestSupport.requestID(from: message)
else { return }
_ = await requestGate.waitIfFirst()
task.emitReceiveSuccess(.data(crestodianResponse(id: id, action: "open-agent")))
})
})
let url = try #require(URL(string: "ws://example.invalid"))
let gateway = GatewayConnection(
configProvider: {
let token = await config.snapshotToken()
return (url: url, token: token, password: nil)
},
sessionBox: WebSocketSessionBox(session: session))
let chat = CrestodianOnboardingChatModel(gateway: gateway)
var replyCount = 0
var handoffCount = 0
chat.onReplyReceived = { replyCount += 1 }
chat.onAgentHandoff = { handoffCount += 1 }
let startTask = Task { await chat.startIfNeeded() }
var requestStarted = false
for _ in 0..<1000 {
if session.latestTask()?.snapshotSendCount() == 2 {
requestStarted = true
break
}
await Task.yield()
}
try #require(requestStarted)
await config.setToken("b")
await requestGate.release()
await startTask.value
#expect(chat.messages.isEmpty)
#expect(replyCount == 0)
#expect(handoffCount == 0)
#expect(chat.errorMessage == "The Gateway connection changed. Restart Crestodian to reconnect.")
}
@Test func `cancelled initial request exposes restart and recovers`() async throws {
let requestGate = CrestodianRequestGate()
let session = GatewayTestWebSocketSession(taskFactory: {
GatewayTestWebSocketTask(sendHook: { task, message, sendIndex in
guard sendIndex > 0,
let id = GatewayWebSocketTestSupport.requestID(from: message)
else { return }
if sendIndex == 1, await requestGate.waitIfFirst() {
throw CancellationError()
}
task.emitReceiveSuccess(.data(crestodianResponse(id: id)))
})
})
let url = try #require(URL(string: "ws://example.invalid"))
let gateway = GatewayConnection(
configProvider: { (url: url, token: nil, password: nil) },
sessionBox: WebSocketSessionBox(session: session))
let chat = CrestodianOnboardingChatModel(gateway: gateway)
let startTask = Task { await chat.startIfNeeded() }
var requestStarted = false
for _ in 0..<1000 {
if session.latestTask()?.snapshotSendCount() == 2 {
requestStarted = true
break
}
await Task.yield()
}
try #require(requestStarted)
startTask.cancel()
await requestGate.release()
await startTask.value
#expect(chat.errorMessage == "Crestodian was interrupted. Restart to try again.")
#expect(!chat.isSending)
#expect(chat.messages.isEmpty)
let restartTask = try #require(chat.restartAfterError())
await restartTask.value
#expect(chat.errorMessage == nil)
#expect(chat.messages.map(\.text) == ["ready"])
#expect(session.snapshotMakeCount() == 2)
#expect(session.latestTask()?.snapshotSendCount() == 2)
}
}
@@ -34,7 +34,7 @@ struct OnboardingViewSmokeTests {
#expect(!order.contains(8))
}
@Test func `fresh local setup installs CLI before the Crestodian chat`() {
@Test func `fresh local setup installs CLI before inference setup`() {
let order = OnboardingView.pageOrder(
for: .local,
showOnboardingChat: false,
@@ -53,6 +53,15 @@ struct OnboardingViewSmokeTests {
#expect(!order.contains(2))
}
@Test func `only full page chat uses compact hero`() {
#expect(!OnboardingView.shouldUseCompactHero(
activePageIndex: 3,
onboardingChatPageIndex: 8))
#expect(OnboardingView.shouldUseCompactHero(
activePageIndex: 8,
onboardingChatPageIndex: 8))
}
@Test func `fresh onboarding defaults to this Mac`() {
let state = AppState(preview: true)
state.onboardingSeen = false
@@ -110,6 +119,45 @@ struct OnboardingViewSmokeTests {
installing: false))
}
@Test func `connection mode change restarts full page monitoring`() {
let state = AppState(preview: true)
let view = OnboardingView(state: state)
var monitoredPage: Int?
let previousCrestodianChat = view.crestodianState.chat
view.aiSetup.manualKey = "route-bound"
view.crestodianState.isPresented = true
view.handleConnectionModeChange { pageIndex in
monitoredPage = pageIndex
}
#expect(view.aiSetup.manualKey.isEmpty)
#expect(!view.crestodianState.isPresented)
#expect(view.crestodianState.chat !== previousCrestodianChat)
#expect(monitoredPage == view.activePageIndex)
}
@Test func `gateway route reset returns later pages to inference setup`() throws {
let order = OnboardingView.pageOrder(
for: .remote,
showOnboardingChat: false,
requiresCLIInstall: false)
let permissionsCursor = try #require(order.firstIndex(of: 5))
let aiCursor = try #require(order.firstIndex(of: 3))
let resetCursor = OnboardingView.pageCursorAfterGatewayReset(
currentPage: permissionsCursor,
pageOrder: order,
aiPageIndex: 3)
#expect(resetCursor == aiCursor)
#expect(OnboardingView.shouldBlockAISetup(
currentPage: resetCursor,
pageOrder: order,
aiPageIndex: 3,
connectionMode: .remote,
connected: false))
}
@Test func `select remote gateway clears stale ssh target when endpoint unresolved`() async {
let override = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-config-\(UUID().uuidString)")
@@ -202,6 +202,83 @@ struct SettingsViewSmokeTests {
_ = view.body
}
@Test func `Crestodian settings require configured inference`() {
#expect(!CrestodianAvailability.shouldShow(configuredModel: nil))
#expect(!CrestodianAvailability.shouldShow(configuredModel: " "))
#expect(CrestodianAvailability.shouldShow(configuredModel: "openai/gpt-5.5"))
let hiddenTabs = SettingsTabGroup.defaultGroups(showDebug: false, showCrestodian: false)
.flatMap(\.tabs)
let visibleTabs = SettingsTabGroup.defaultGroups(showDebug: false, showCrestodian: true)
.flatMap(\.tabs)
#expect(!hiddenTabs.contains(.crestodian))
#expect(visibleTabs.contains(.crestodian))
#expect(SettingsRootView.normalizedTab(
.crestodian,
showDebug: false,
showCrestodian: false) == .general)
#expect(SettingsRootView.normalizedTab(
.crestodian,
showDebug: false,
showCrestodian: true) == .crestodian)
let loadingSelection = SettingsRootView.tabSelection(
requested: .crestodian,
showDebug: false,
inferenceConfiguration: .loading)
#expect(loadingSelection.selected == .general)
#expect(loadingSelection.deferred == .crestodian)
let configuredSelection = SettingsRootView.tabSelection(
requested: loadingSelection.deferred ?? .general,
showDebug: false,
inferenceConfiguration: .loaded("openai/gpt-5.5"))
#expect(configuredSelection.selected == .crestodian)
#expect(configuredSelection.deferred == nil)
let unconfiguredSelection = SettingsRootView.tabSelection(
requested: .crestodian,
showDebug: false,
inferenceConfiguration: .loaded(nil))
#expect(unconfiguredSelection.selected == .general)
#expect(unconfiguredSelection.deferred == nil)
#expect(SettingsRootView.configurationAfterInferenceRefresh(
current: .loaded("openai/gpt-5.5"),
result: .failed) == .loaded("openai/gpt-5.5"))
#expect(SettingsRootView.configurationAfterInferenceRefresh(
current: .loaded("openai/gpt-5.5"),
result: .confirmed(nil)) == .loaded(nil))
}
@Test func `Crestodian preserves same route and resets for gateway changes`() {
let stateDir = URL(fileURLWithPath: "/Users/tester/.openclaw")
let directA = MacChatTranscriptCache.gatewayID(
mode: .remote,
localStateDir: stateDir,
remoteTransport: .direct,
directURL: URL(string: "wss://gateway.example.com/team-a"),
sshTarget: "",
sshRemotePort: 18789)
let directB = MacChatTranscriptCache.gatewayID(
mode: .remote,
localStateDir: stateDir,
remoteTransport: .direct,
directURL: URL(string: "wss://gateway.example.com/team-b"),
sshTarget: "",
sshRemotePort: 18789)
#expect(directA != directB)
#expect(SettingsRootView.configRefreshPlan(
selectedTab: .crestodian,
previousGatewayID: directA,
currentGatewayID: directA) == .init(clearsPrevious: false, resetsCrestodian: false))
#expect(SettingsRootView.configRefreshPlan(
selectedTab: .general,
previousGatewayID: directA,
currentGatewayID: directA) == .init(clearsPrevious: true, resetsCrestodian: false))
#expect(SettingsRootView.configRefreshPlan(
selectedTab: .crestodian,
previousGatewayID: directA,
currentGatewayID: directB) == .init(clearsPrevious: true, resetsCrestodian: true))
}
@Test func `about settings builds body`() {
let view = AboutSettings(updater: nil)
_ = view.body
@@ -7184,6 +7184,7 @@ public struct CronJob: Codable, Sendable {
public let deleteafterrun: Bool?
public let createdatms: Int
public let updatedatms: Int
public let configrevision: String?
public let schedule: AnyCodable
public let trigger: [String: AnyCodable]?
public let sessiontarget: AnyCodable
@@ -7216,6 +7217,7 @@ public struct CronJob: Codable, Sendable {
deleteafterrun: Bool?,
createdatms: Int,
updatedatms: Int,
configrevision: String? = nil,
schedule: AnyCodable,
trigger: [String: AnyCodable]?,
sessiontarget: AnyCodable,
@@ -7247,6 +7249,7 @@ public struct CronJob: Codable, Sendable {
self.deleteafterrun = deleteafterrun
self.createdatms = createdatms
self.updatedatms = updatedatms
self.configrevision = configrevision
self.schedule = schedule
self.trigger = trigger
self.sessiontarget = sessiontarget
@@ -7280,6 +7283,7 @@ public struct CronJob: Codable, Sendable {
case deleteafterrun = "deleteAfterRun"
case createdatms = "createdAtMs"
case updatedatms = "updatedAtMs"
case configrevision = "configRevision"
case schedule
case trigger
case sessiontarget = "sessionTarget"
+30 -1
View File
@@ -47,11 +47,33 @@ openclaw wiki obsidian command workspace:quick-switcher
openclaw wiki obsidian daily
```
## Agent selection
When `plugins.entries.memory-wiki.config.vault.scope` is `agent`, select the
vault with the top-level `--agent <id>` option:
```bash
openclaw wiki --agent support status
openclaw wiki --agent support search "refund policy"
openclaw wiki --agent marketing ingest ./campaign-notes.md
```
In a setup with multiple configured agents, `--agent` is required for CLI
operations so a command cannot read or write an arbitrary default vault. If
only one agent is configured, that agent remains the default. Unknown agent ids
fail before the vault operation starts. The option does not change the selected
path when `vault.scope` is `global`.
Gateway clients follow the same rule: pass `agentId` on vault-backed `wiki.*`
requests in an agent-scoped multi-agent setup. A missing or unknown id is an
error. Agent turns, wiki tools, memory corpus supplements, and compiled prompt
digests already carry the active runtime agent context.
## Commands
### `wiki status`
Show vault mode, health, and Obsidian CLI availability. Use this first to check whether the vault is initialized, bridge mode is healthy, or Obsidian integration is available.
Show vault mode and scope, resolved agent, health, and Obsidian CLI availability. Use this first to check whether the intended vault is initialized, bridge mode is healthy, or Obsidian integration is available.
When bridge mode is active and configured to read memory artifacts, this command queries the running Gateway so it sees the same active memory plugin context as agent/runtime memory.
@@ -198,6 +220,11 @@ Roll back a previously applied ChatGPT import run, removing pages it created and
Obsidian helper commands for vaults running in Obsidian-friendly mode: `status`, `search`, `open`, `command`, `daily`. These require the official `obsidian` CLI on `PATH` when `obsidian.useOfficialCli` is enabled.
Configuration validation rejects `obsidian.useOfficialCli: true` when
`vault.scope` is `agent` because `obsidian.vaultName` is one global setting,
not a per-agent mapping. Obsidian-friendly Markdown rendering remains
available.
## Practical usage guidance
- Use `wiki search` + `wiki get` when provenance and page identity matter.
@@ -212,6 +239,8 @@ Obsidian helper commands for vaults running in Obsidian-friendly mode: `status`,
`openclaw wiki` behavior is shaped by:
- `plugins.entries.memory-wiki.config.vaultMode`
- `plugins.entries.memory-wiki.config.vault.scope`
- `plugins.entries.memory-wiki.config.vault.path`
- `plugins.entries.memory-wiki.config.search.backend`
- `plugins.entries.memory-wiki.config.search.corpus`
- `plugins.entries.memory-wiki.config.bridge.*`
+11 -1
View File
@@ -122,6 +122,7 @@ A plugin can register a context engine using the plugin API:
```ts
import { buildMemorySystemPromptAddition } from "openclaw/plugin-sdk/core";
import { resolveSessionAgentId } from "openclaw/plugin-sdk/memory-host-core";
export default function register(api) {
api.registerContextEngine("my-engine", (ctx) => ({
@@ -136,7 +137,14 @@ export default function register(api) {
return { ingested: true };
},
async assemble({ sessionId, messages, tokenBudget, availableTools, citationsMode }) {
async assemble({
sessionId,
sessionKey,
messages,
tokenBudget,
availableTools,
citationsMode,
}) {
// Return messages that fit the budget
return {
messages: buildContext(messages, tokenBudget),
@@ -144,6 +152,8 @@ export default function register(api) {
systemPromptAddition: buildMemorySystemPromptAddition({
availableTools: availableTools ?? new Set(),
citationsMode,
agentId: resolveSessionAgentId({ config: ctx.config, sessionKey }),
agentSessionKey: sessionKey,
}),
};
},
+42 -6
View File
@@ -1,12 +1,12 @@
---
summary: "Multi-agent routing: isolated agents, channel accounts, and bindings"
summary: "Multi-agent routing: agent boundaries, channel accounts, and bindings"
title: "Multi-agent routing"
sidebarTitle: "Multi-agent routing"
read_when: "You want multiple isolated agents (workspaces + auth) in one gateway process."
read_when: "You want multiple agents with separate workspaces, auth, and sessions in one Gateway process."
status: active
---
Run multiple _isolated_ agents in one Gateway process, each with its own workspace, state directory (`agentDir`), and session store, plus multiple channel accounts (e.g. two WhatsApp numbers). Inbound messages route to the right agent through **bindings**.
Run multiple agents in one Gateway process, each with its own workspace, state directory (`agentDir`), and session store, plus multiple channel accounts (e.g. two WhatsApp numbers). Inbound messages route to the right agent through **bindings**.
An **agent** is the full per-persona scope: workspace files, auth profiles, model registry, and session store. A **binding** maps a channel account (a Slack workspace, a WhatsApp number, etc.) to one of those agents.
@@ -34,6 +34,11 @@ Never reuse `agentDir` across agents — it causes auth/session state collisions
Skills load from each agent workspace plus shared roots such as `~/.openclaw/skills`, then filter by the effective agent skill allowlist. Use `agents.defaults.skills` for a shared baseline and `agents.list[].skills` for a per-agent replacement (explicit entries replace the default, they do not merge). See [Skills: per-agent vs shared](/tools/skills#per-agent-vs-shared-skills) and [Skills: agent allowlists](/tools/skills#agent-allowlists).
Plugin-owned storage follows that plugin's configuration; adding a second agent
does not automatically split every global plugin store. For example, configure
[Memory Wiki per-agent vaults](/concepts/multi-agent#per-agent-memory-wiki-vaults)
when personas must not share compiled wiki knowledge.
<Note>
**Workspace note:** each agent's workspace is the **default cwd**, not a hard sandbox. Relative paths resolve inside the workspace, but absolute paths can reach other host locations unless sandboxing is enabled. See [Sandboxing](/gateway/sandboxing).
</Note>
@@ -114,13 +119,44 @@ openclaw agents list --bindings
## Multiple agents, multiple personas
Each configured `agentId` is a fully isolated persona:
Each configured `agentId` is a distinct persona boundary for core agent state:
- Different accounts per channel (per `accountId`).
- Different personalities (per-agent `AGENTS.md`/`SOUL.md`).
- Separate auth and sessions, with no cross-talk unless explicitly enabled.
- Separate auth and sessions, with cross-agent access enabled only through explicit features or plugin configuration.
This lets multiple people share one Gateway while keeping their agent state isolated.
This lets multiple people share one Gateway while keeping core agent state separate.
## Per-agent Memory Wiki vaults
Memory Wiki uses one global vault by default. To keep a support agent's
compiled knowledge separate from a marketing agent's, set
`plugins.entries.memory-wiki.config.vault.scope` to `agent`:
```json5
{
plugins: {
entries: {
"memory-wiki": {
enabled: true,
config: {
vault: {
scope: "agent",
path: "~/.openclaw/wiki",
},
},
},
},
},
}
```
The configured path is the parent directory. OpenClaw appends the normalized
agent id, producing paths such as `~/.openclaw/wiki/support` and
`~/.openclaw/wiki/marketing`. Agent-scoped CLI and Gateway operations require
an explicit agent when multiple agents are configured. See
[Memory Wiki per-agent vaults](/plugins/memory-wiki#per-agent-vaults) for bridge
filtering, migration, and trust-boundary details.
## Cross-agent QMD memory search
+6
View File
@@ -2089,6 +2089,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- Headings:
- H1: openclaw wiki
- H2: Common commands
- H2: Agent selection
- H2: Commands
- H3: wiki status
- H3: wiki doctor
@@ -2640,6 +2641,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Agent helper
- H2: Quick start
- H2: Multiple agents, multiple personas
- H2: Per-agent Memory Wiki vaults
- H2: Cross-agent QMD memory search
- H2: One WhatsApp number, multiple people (DM split)
- H2: Routing rules
@@ -5664,6 +5666,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Agent tools
- H2: Prompt and context behavior
- H2: Configuration
- H3: Per-agent vaults
- H3: Example: QMD + bridge mode
- H2: CLI
- H2: Obsidian support
@@ -6937,6 +6940,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- Headings:
- H2: When to use a harness
- H2: What core still owns
- H3: Harness-owned auth bootstrap
- H2: Register a harness
- H2: Selection policy
- H2: Provider plus harness pairing
@@ -7382,6 +7386,8 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- Route: /providers/clawrouter
- Headings:
- H2: Getting started
- H2: Managed non-interactive deployment
- H2: Readiness and live proof
- H2: Model discovery
- H2: Protocol and provider plugins
- H2: Quotas and usage
+3
View File
@@ -582,6 +582,9 @@ request. Plugin dependencies are expected to be present before runtime load.
- Provider-specific Vydra coverage:
- `OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_VYDRA_VIDEO=1 pnpm test:live -- extensions/vydra/vydra.live.test.ts`
- That file runs `veo3` text-to-video plus a `kling` image-to-video lane that uses a remote image URL fixture by default (`OPENCLAW_LIVE_VYDRA_KLING_IMAGE_URL` to override).
- Provider-specific xAI Video 1.5 coverage:
- `OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_XAI_VIDEO_15=1 pnpm test:live -- extensions/xai/xai.live.test.ts -t "Grok Imagine Video 1.5"`
- The case generates a local PNG first frame, requests a one-second 1080P image-to-video clip, polls to completion, and verifies the downloaded video buffer.
- Current `videoToVideo` live coverage:
- `runway` only when the selected model resolves to `gen4_aleph`
- Current declared-but-skipped `videoToVideo` providers in the shared sweep:
+8 -2
View File
@@ -1063,6 +1063,7 @@ pipeline rather than just add memory search or hooks.
```ts
import { buildMemorySystemPromptAddition } from "openclaw/plugin-sdk/core";
import { resolveSessionAgentId } from "openclaw/plugin-sdk/memory-host-core";
export default function (api) {
api.registerContextEngine("lossless-claw", (ctx) => ({
@@ -1070,13 +1071,15 @@ export default function (api) {
async ingest() {
return { ingested: true };
},
async assemble({ messages, availableTools, citationsMode }) {
async assemble({ messages, sessionKey, availableTools, citationsMode }) {
return {
messages,
estimatedTokens: 0,
systemPromptAddition: buildMemorySystemPromptAddition({
availableTools: availableTools ?? new Set(),
citationsMode,
agentId: resolveSessionAgentId({ config: ctx.config, sessionKey }),
agentSessionKey: sessionKey,
}),
};
},
@@ -1108,6 +1111,7 @@ import {
buildMemorySystemPromptAddition,
delegateCompactionToRuntime,
} from "openclaw/plugin-sdk/core";
import { resolveSessionAgentId } from "openclaw/plugin-sdk/memory-host-core";
export default function (api) {
api.registerContextEngine("my-memory-engine", (ctx) => ({
@@ -1119,13 +1123,15 @@ export default function (api) {
async ingest() {
return { ingested: true };
},
async assemble({ messages, availableTools, citationsMode }) {
async assemble({ messages, sessionKey, availableTools, citationsMode }) {
return {
messages,
estimatedTokens: 0,
systemPromptAddition: buildMemorySystemPromptAddition({
availableTools: availableTools ?? new Set(),
citationsMode,
agentId: resolveSessionAgentId({ config: ctx.config, sessionKey }),
agentSessionKey: sessionKey,
}),
};
},
+98 -15
View File
@@ -3,6 +3,7 @@ summary: "memory-wiki: compiled knowledge vault with provenance, claims, dashboa
read_when:
- You want persistent knowledge beyond plain MEMORY.md notes
- You are configuring the bundled memory-wiki plugin
- You need separate wiki vaults for agents in one Gateway
- You want to understand wiki_search, wiki_get, or bridge mode
title: "Memory wiki"
---
@@ -41,6 +42,18 @@ then confirm the active memory plugin supports public artifacts.
- `bridge`: reads public memory artifacts and event logs from the active memory plugin through public plugin SDK seams. Use this to compile the memory plugin's exported artifacts without reaching into private plugin internals.
- `unsafe-local`: explicit same-machine escape hatch for local private paths. Intentionally experimental and non-portable; use only when you understand the trust boundary and specifically need local filesystem access bridge mode cannot provide.
Vault mode and vault scope are separate choices:
- `vaultMode` chooses where wiki inputs come from.
- `vault.scope` chooses whether all agents use one vault or each agent gets a child vault.
`vault.scope: "global"` is the default and preserves the existing single-vault
behavior. Use `vault.scope: "agent"` with `isolated` or `bridge` mode when
agents must not share wiki pages, compiled digests, search results, or writes.
Agent scope cannot be combined with `unsafe-local` mode because those configured
private paths are not agent-owned inputs. Configuration validation rejects this
combination.
Bridge mode can index, per `bridge.*` config toggle:
- exported memory artifacts (`indexMemoryRoot`)
@@ -244,7 +257,7 @@ includes compact `Claim:` and `Evidence:` lines when available.
| Tool | Purpose |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `wiki_status` | current vault mode, health, Obsidian CLI availability |
| `wiki_status` | current vault mode and scope, resolved agent, health, Obsidian CLI availability |
| `wiki_search` | search wiki pages and, when configured, the shared memory corpus; accepts `mode` for person lookup, question routing, source evidence, or raw claim drilldown |
| `wiki_get` | read a wiki page by id/path, falling back to the shared memory corpus when shared search is enabled and the lookup misses |
| `wiki_apply` | narrow synthesis/metadata mutations without freeform page surgery |
@@ -276,6 +289,7 @@ Put config under `plugins.entries.memory-wiki.config`:
config: {
vaultMode: "isolated",
vault: {
scope: "global",
path: "~/.openclaw/wiki/main",
renderMode: "obsidian",
},
@@ -323,20 +337,83 @@ Put config under `plugins.entries.memory-wiki.config`:
Key toggles:
| Key | Values / default | Notes |
| ------------------------------------------ | ---------------------------------------------- | -------------------------------------------------------- |
| `vaultMode` | `isolated` (default), `bridge`, `unsafe-local` | |
| `vault.path` | default `~/.openclaw/wiki/main` | |
| `vault.renderMode` | `native` (default), `obsidian` | |
| `bridge.readMemoryArtifacts` | default `true` | import active memory plugin public artifacts |
| `bridge.followMemoryEvents` | default `true` | include event logs in bridge mode |
| `unsafeLocal.allowPrivateMemoryCoreAccess` | default `false` | required to run `unsafe-local` imports |
| `unsafeLocal.paths` | default `[]` | explicit local paths to import in `unsafe-local` mode |
| `search.backend` | `shared` (default), `local` | |
| `search.corpus` | `wiki` (default), `memory`, `all` | |
| `context.includeCompiledDigestPrompt` | default `false` | append compact digest snapshot to memory prompt sections |
| `render.createBacklinks` | default `true` | generate deterministic related blocks |
| `render.createDashboards` | default `true` | generate dashboard pages |
| Key | Values / default | Notes |
| ------------------------------------------ | ---------------------------------------------- | ----------------------------------------------------------------------------- |
| `vaultMode` | `isolated` (default), `bridge`, `unsafe-local` | chooses input and integration behavior |
| `vault.scope` | `global` (default), `agent` | one shared vault or one child vault per agent |
| `vault.path` | global default `~/.openclaw/wiki/main` | exact vault globally; agent-scope parent defaults to `~/.openclaw/wiki` |
| `vault.renderMode` | `native` (default), `obsidian` | |
| `bridge.readMemoryArtifacts` | default `true` | import active memory plugin public artifacts |
| `bridge.followMemoryEvents` | default `true` | include event logs in bridge mode |
| `unsafeLocal.allowPrivateMemoryCoreAccess` | default `false` | required to run `unsafe-local` imports |
| `unsafeLocal.paths` | default `[]` | explicit local paths to import in `unsafe-local` mode |
| `search.backend` | `shared` (default), `local` | |
| `search.corpus` | `wiki` (default), `memory`, `all` | |
| `context.includeCompiledDigestPrompt` | default `false` | append the selected agent's compact digest snapshot to memory prompt sections |
| `render.createBacklinks` | default `true` | generate deterministic related blocks |
| `render.createDashboards` | default `true` | generate dashboard pages |
### Per-agent vaults
Set `vault.scope` to `agent` to give every configured agent a separate wiki.
In this scope, `vault.path` is a parent directory and OpenClaw appends the
normalized agent id:
```json5
{
agents: {
list: [{ id: "support" }, { id: "marketing" }],
},
plugins: {
entries: {
"memory-wiki": {
enabled: true,
config: {
vaultMode: "bridge",
vault: {
scope: "agent",
path: "~/.openclaw/wiki",
},
bridge: {
enabled: true,
readMemoryArtifacts: true,
},
},
},
},
},
}
```
This resolves to `~/.openclaw/wiki/support` and
`~/.openclaw/wiki/marketing`. If `vault.path` is omitted in agent scope, the
parent defaults to `~/.openclaw/wiki`. The default `main` agent therefore keeps
the existing `~/.openclaw/wiki/main` path.
Agent tools, compiled prompt digests, and the wiki supplement exposed through
`memory_search` / `memory_get` resolve the vault from the active agent context.
For CLI and Gateway calls in a setup with multiple configured agents, provide
the agent explicitly with `openclaw wiki --agent <agentId> ...` or the Gateway
request's `agentId`. A single configured agent remains the default when no id is
provided.
In bridge mode, agent-scoped imports accept a public memory artifact only when
its `agentIds` includes the selected agent. Artifacts owned by another agent,
without ownership metadata, or with an unknown owner are skipped. Global scope
keeps the existing shared-artifact behavior.
<Warning>
Changing `vault.scope` does not copy or split an existing vault. In agent scope,
an explicitly configured `vault.path` becomes a parent directory, so move or
import existing pages deliberately before switching production agents. Back up
the vault first.
Per-agent vaults are a same-process knowledge boundary, not an operating-system
security boundary. Plugins and unsandboxed tools with host filesystem access can
still read another agent's directory. Use [sandboxing](/gateway/sandboxing) or
[separate Gateway profiles](/gateway/multiple-gateways) when agents do not trust
each other.
</Warning>
### Example: QMD + bridge mode
@@ -411,6 +488,12 @@ probing, vault search, opening a page, invoking a command, and jumping to the
daily note. This is optional; the wiki still works in native mode without
Obsidian.
Agent-scoped vaults can still use Obsidian-friendly Markdown, but configuration
validation rejects `obsidian.useOfficialCli: true` with `vault.scope: "agent"`.
The current `obsidian.vaultName` setting is global and cannot select a distinct
Obsidian vault for each agent. Use the wiki tools and CLI operations instead,
or keep an Obsidian-operated wiki in global scope.
## Recommended workflow
<Steps>
+22 -4
View File
@@ -34,7 +34,7 @@ WebSocket model APIs, build a [provider plugin](/plugins/sdk-provider-plugins).
Before a harness is selected, OpenClaw has already resolved:
- provider and model
- runtime auth state
- runtime auth state, unless the harness declares that it owns auth bootstrap
- thinking level and context budget
- the OpenClaw transcript/session file
- workspace, sandbox, and tool policy
@@ -44,6 +44,20 @@ Before a harness is selected, OpenClaw has already resolved:
A harness runs a prepared attempt; it does not pick providers, replace channel
delivery, or silently switch models.
### Harness-owned auth bootstrap
By default, core resolves provider credentials before calling a harness. A
trusted harness that can authenticate through its own native runtime may set
`authBootstrap: "harness"` on its static `AgentHarness` registration. Core then
skips its generic provider credential bootstrap and missing-credential failure
for every attempt claimed by that harness.
Core still forwards a compatible, explicitly selected or ordered OpenClaw auth
profile and its scoped store when one exists. The harness must resolve that
profile or its native credentials before issuing model requests, keep secrets
scoped to the attempt, and surface actionable authentication failures. Do not
set this capability on a harness that only sometimes owns authentication.
The prepared attempt also includes `params.runtimePlan`, an OpenClaw-owned
policy bundle for runtime decisions that must stay shared across OpenClaw and
native harnesses:
@@ -98,6 +112,9 @@ export default definePluginEntry({
});
```
`authBootstrap` is intentionally absent from this generic example. Add
`authBootstrap: "harness"` only when the harness meets the contract above.
## Selection policy
OpenClaw chooses a harness after provider/model resolution:
@@ -154,9 +171,10 @@ for compatibility.
For operator setup, model prefix examples, and Codex-only configs, see
[Codex Harness](/plugins/codex-harness).
OpenClaw requires Codex app-server `0.142.0` or newer. The Codex plugin checks
the app-server initialize handshake and blocks older or unversioned servers,
so OpenClaw only runs against the protocol surface it has tested.
The Codex plugin enforces the minimum app-server version documented in
[Codex Harness](/plugins/codex-harness). It checks the initialize handshake and
blocks older or unversioned servers, so OpenClaw only runs against the protocol
surface it has tested.
### Tool-result middleware
+8
View File
@@ -187,6 +187,14 @@ guidance remain available to non-Codex prompt surfaces for compatibility.
| `api.registerNodeInvokePolicy(policy)` | Allowlist/approval policy for node-invoked commands |
| `api.registerSecurityAuditCollector(collector)` | Findings collector for `openclaw security audit` |
Memory prompt supplement builders receive optional `agentId`,
`agentSessionKey`, and `sandboxed` context. Memory corpus supplement `search`
and `get` calls receive optional `agentId` and `sandboxed` context. Plugins with
agent-owned storage should resolve that storage for each call instead of
capturing one global path during registration. If an agent id is required but
missing in a multi-agent operation, fail closed rather than choosing an
arbitrary agent.
Telegram interactive handlers can return `{ submitText }` to route text through
Telegram's normal inbound agent path after the handler succeeds. OpenClaw keeps
the callback button when inbound policy skips the text or processing fails, so
+23
View File
@@ -934,6 +934,7 @@ catalog, API-key auth, and dynamic model resolution.
id: "acme-ai",
label: "Acme Video",
defaultTimeoutMs: 600_000,
models: ["acme-video", "acme-image-video"],
capabilities: {
generate: { maxVideos: 1, maxDurationSeconds: 10, supportsResolution: true },
imageToVideo: {
@@ -945,6 +946,21 @@ catalog, API-key auth, and dynamic model resolution.
},
videoToVideo: { enabled: false },
},
catalogByModel: {
"acme-image-video": {
modes: ["imageToVideo"],
capabilities: {
imageToVideo: {
enabled: true,
maxVideos: 1,
maxInputImages: 1,
resolutions: ["480P", "720P", "1080P"],
supportsResolution: true,
},
videoToVideo: { enabled: false },
},
},
},
generateVideo: async (req) => ({ videos: [] }),
});
```
@@ -952,6 +968,13 @@ catalog, API-key auth, and dynamic model resolution.
`capabilities` is required on both provider types; `edit` and the
video transform blocks (`imageToVideo`, `videoToVideo`) always need an
explicit `enabled` flag.
Use `catalogByModel` when a listed model's static modes or capabilities
differ from the provider defaults. This metadata keeps
`video_generate action=list` and model catalogs accurate without
invoking provider code. Request-time capability lookup and enforcement
still belong in `resolveModelCapabilities` and `generateVideo`; reuse
the same capability constant for both paths when possible.
</Tab>
<Tab title="Web fetch and search">
```typescript
+103
View File
@@ -69,6 +69,107 @@ you only need an issued ClawRouter credential.
</Step>
</Steps>
## Managed non-interactive deployment
Keep the proxy key in the workload's secret injection and store only a
SecretRef in `openclaw.json`. The canonical managed fields are:
| Purpose | Config or environment field |
| ------------- | ------------------------------------------------------------------------ |
| Router origin | `models.providers.clawrouter.baseUrl` |
| Credential | `models.providers.clawrouter.apiKey` -> env SecretRef |
| Secret value | `CLAWROUTER_API_KEY` in the gateway process environment |
| Default model | `agents.defaults.model.primary` -> `clawrouter/<provider>/<model>` |
| Workload tag | `models.providers.clawrouter.headers.X-ClawRouter-Project-Id` (optional) |
For example, a deployment controller can own this JSON5 patch:
```json5
{
plugins: {
entries: { clawrouter: { enabled: true } },
},
models: {
providers: {
clawrouter: {
baseUrl: "https://clawrouter.internal.example",
apiKey: {
source: "env",
provider: "default",
id: "CLAWROUTER_API_KEY",
},
headers: {
"X-ClawRouter-Project-Id": "fakeco",
},
},
},
},
agents: {
defaults: {
model: { primary: "clawrouter/openai/gpt-5.5" },
},
},
}
```
If the deployment sets `plugins.allow`, preserve its existing entries and add
`clawrouter`. Validate and apply without an interactive wizard:
```bash
openclaw config patch --file ./clawrouter.patch.json5 --dry-run --json
openclaw config patch --file ./clawrouter.patch.json5
```
The dry run resolves the SecretRef but never prints its value. To rotate the
credential, update the external Secret that supplies `CLAWROUTER_API_KEY` and
restart the gateway workload so the new process environment is loaded. The
config file and model reference do not change.
## Readiness and live proof
These checks prove different boundaries; do not substitute one for another:
```bash
# ClawRouter process health only; no credential or upstream model is exercised.
curl -fsS https://clawrouter.internal.example/v1/health
# OpenClaw gateway startup readiness only; no model call is made.
curl -fsS http://127.0.0.1:18789/readyz
# Credential-scoped catalog discovery.
openclaw models list --all --provider clawrouter --json
# Minimal real inference probe through the configured ClawRouter provider.
openclaw models status --probe --probe-provider clawrouter --probe-max-tokens 8 --json
# Workload canary using an exact granted model ref.
openclaw agent --agent main \
--model clawrouter/openai/gpt-5.5 \
--message "Reply exactly: CLAWROUTER_CANARY_OK" \
--json
```
Use a model returned by the scoped catalog instead of copying the example
model blindly. A successful `/readyz` response means the gateway can serve
requests; it does not claim that ClawRouter, its credential, or an upstream
provider is ready. The model probe and agent canary are the inference proofs.
For live diagnosis, issue the canary and inspect the gateway's standard logs.
The existing metadata-only model transport diagnostics emit lines shaped like:
```text
[model-fetch] start provider=clawrouter api=openai-responses model=openai/gpt-5.5 method=POST url=https://clawrouter.internal.example/v1/responses
[model-fetch] response provider=clawrouter api=openai-responses model=openai/gpt-5.5 status=200
```
The plugin sends bounded `X-ClawRouter-Client`, `X-ClawRouter-Agent-Id`, and
`X-ClawRouter-Session-Id` headers when those identifiers are available. Static
deployment metadata such as `X-ClawRouter-Project-Id` can be set in the
provider `headers` map. Explicit configured headers win over automatic values.
The transport diagnostic records routing and response metadata; it does not log
credentials, request ids, prompts, or completions. ClawRouter's own audit event
provides the selected upstream provider and content-retention state.
## Model discovery
`GET /v1/catalog` returns `{ providers: [...] }`, where each provider entry
@@ -141,6 +242,8 @@ the same ClawRouter policy can change the remaining percentage.
- Catalog discovery is scoped to the configured proxy key and cached per credential scope (agent dir, workspace dir, auth profile id, and base URL).
- The proxy key is attached only at request dispatch; it is not stored in model metadata.
- Automatic attribution values are trimmed, control-character rejected, and bounded to 256 characters before dispatch.
- Model transport diagnostics contain metadata only and never include the proxy key or model content.
- Native Anthropic and Gemini model ids are rewritten to their upstream ids only at dispatch.
- Unsupported or ungranted catalog rows fail closed and are not selectable.
+3 -1
View File
@@ -452,7 +452,9 @@ still account-based. OpenClaw selects auth in this order:
`auth.order.openai`. Run `openclaw doctor --fix` to migrate older legacy
Codex auth profile ids and auth order.
2. The app-server's existing account, such as a local Codex CLI ChatGPT
sign-in.
sign-in. For the default isolated agent home, OpenClaw bridges that native
CLI account into the app-server through its login RPC; it does not share the
CLI's config, plugins, or thread store.
3. For local stdio app-server launches only, and only when the app-server
reports no account: `CODEX_API_KEY`, then `OPENAI_API_KEY`.
+43 -26
View File
@@ -134,7 +134,7 @@ below or under known limits.
| Server-side X search | `x_search` tool | Yes |
| Server-side code execution | `code_execution` tool | Yes |
| Images | `image_generate` | Yes |
| Videos | `video_generate` | Classic model; Video 1.5 is not exposed yet |
| Videos | `video_generate` | Classic full workflow; Video 1.5 image-to-video |
| Batch text-to-speech | `messages.tts.provider: "xai"` / `tts` | Yes |
| Streaming TTS | - | Not implemented by the xAI provider yet |
| Batch speech-to-text | `tools.media.audio` media understanding | Yes |
@@ -194,6 +194,15 @@ stale context metadata on active 4.20 rows. It does not pin active 4.20
## Features
<Warning>
`x_search` and `code_execution` run on xAI's servers. xAI bills $5 per 1,000
tool calls, plus the model's input and output tokens. With each tool's
`enabled` setting omitted, OpenClaw exposes it only for an active xAI model.
A known non-xAI model provider requires an explicit per-tool `enabled: true`;
a missing or unresolved provider fails closed. xAI auth is always required,
and `enabled: false` disables the tool for every provider.
</Warning>
<AccordionGroup>
<Accordion title="Web search">
The bundled `grok` web-search provider prefers xAI OAuth, then falls back
@@ -210,13 +219,17 @@ stale context metadata on active 4.20 rows. It does not pin active 4.20
The bundled `xai` plugin registers video generation through the shared
`video_generate` tool.
- Default video model: `xai/grok-imagine-video`
- Modes: text-to-video, image-to-video, reference-image generation, remote
video edit, and remote video extension
- Aspect ratios: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `3:2`, `2:3`
- Resolutions: `480P`, `720P`
- Default model: `xai/grok-imagine-video`
- Additional model: `xai/grok-imagine-video-1.5`
- Classic modes: text-to-video, image-to-video, reference-image generation,
remote video edit, and remote video extension
- Video 1.5 mode: image-to-video only, with exactly one first-frame image
- Aspect ratios: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `3:2`, `2:3`;
Video 1.5 inherits the source image ratio when omitted
- Resolutions: classic `480P`/`720P`; Video 1.5 supports `480P`, `720P`,
and `1080P`, and defaults to `480P`
- Duration: 1-15 seconds for generation/image-to-video, 1-10 seconds when
using `reference_image` roles, 2-10 seconds for extension
using classic `reference_image` roles, 2-10 seconds for classic extension
- Reference-image generation: set `imageRoles` to `reference_image` for
every supplied image; xAI accepts up to 7 such images
- Default operation timeout: 600 seconds unless `video_generate.timeoutMs`
@@ -228,6 +241,10 @@ stale context metadata on active 4.20 rows. It does not pin active 4.20
OpenClaw encodes those as data URLs for xAI.
</Warning>
Video 1.5 also recognizes xAI's `grok-imagine-video-1.5-preview` and
`grok-imagine-video-1.5-2026-05-30` identifiers. OpenClaw forwards the
selected identifier unchanged, but applies the same image-only validation.
To use xAI as the default video provider:
```json5
@@ -421,15 +438,15 @@ stale context metadata on active 4.20 rows. It does not pin active 4.20
Config path: `plugins.entries.xai.config.xSearch`
| Key | Type | Default | Description |
| ----------------- | ------- | ------------------------------ | ------------------------------------- |
| `enabled` | boolean | `true` (if key available) | Enable or disable x_search |
| `model` | string | `grok-4.3` | Model used for x_search requests |
| `baseUrl` | string | - | xAI Responses base URL override |
| `inlineCitations` | boolean | - | Include inline citations in results |
| `maxTurns` | number | - | Maximum conversation turns |
| `timeoutSeconds` | number | `30` | Request timeout in seconds |
| `cacheTtlMinutes` | number | `15` | Cache time-to-live in minutes |
| Key | Type | Default | Description |
| ----------------- | ------- | ------------------------- | ------------------------------------------------ |
| `enabled` | boolean | Automatic for xAI models | Disable, or opt in for a known non-xAI provider |
| `model` | string | `grok-4.3` | Model used for x_search requests |
| `baseUrl` | string | - | xAI Responses base URL override |
| `inlineCitations` | boolean | - | Include inline citations in results |
| `maxTurns` | number | - | Maximum conversation turns |
| `timeoutSeconds` | number | `30` | Request timeout in seconds |
| `cacheTtlMinutes` | number | `15` | Cache time-to-live in minutes |
```json5
{
@@ -458,12 +475,12 @@ stale context metadata on active 4.20 rows. It does not pin active 4.20
Config path: `plugins.entries.xai.config.codeExecution`
| Key | Type | Default | Description |
| ---------------- | ------- | -------------------------- | ---------------------------------------- |
| `enabled` | boolean | `true` (if key available) | Enable or disable code execution |
| `model` | string | `grok-4.3` | Model used for code execution requests |
| `maxTurns` | number | - | Maximum conversation turns |
| `timeoutSeconds` | number | `30` | Request timeout in seconds |
| Key | Type | Default | Description |
| ---------------- | ------- | ------------------------ | ------------------------------------------------ |
| `enabled` | boolean | Automatic for xAI models | Disable, or opt in for a known non-xAI provider |
| `model` | string | `grok-4.3` | Model used for code execution requests |
| `maxTurns` | number | - | Maximum conversation turns |
| `timeoutSeconds` | number | `30` | Request timeout in seconds |
<Note>
This is remote xAI sandbox execution, not local [`exec`](/tools/exec).
@@ -502,9 +519,6 @@ stale context metadata on active 4.20 rows. It does not pin active 4.20
- xAI Realtime voice is not registered as an OpenClaw provider yet. It
needs a different bidirectional voice session contract than batch STT
or streaming transcription.
- `grok-imagine-video-1.5` is not exposed yet. Unlike the classic video
model, it is image-to-video only and needs model-specific mode and 1080p
validation in the shared provider contract.
- xAI image `quality`, image `mask`, and extra native-only aspect ratios
are not exposed until the shared `image_generate` tool has
corresponding cross-provider controls.
@@ -546,6 +560,7 @@ The xAI media paths are covered by unit tests and opt-in live suites. Export
```bash
pnpm test extensions/xai
OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_TEST_QUIET=1 pnpm test:live -- extensions/xai/xai.live.test.ts
OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_XAI_VIDEO_15=1 pnpm test:live -- extensions/xai/xai.live.test.ts -t "Grok Imagine Video 1.5"
OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_TEST_QUIET=1 pnpm test:live -- extensions/xai/x-search.live.test.ts
OPENCLAW_LIVE_GATEWAY_MODELS="xai/grok-4.5,xai/grok-build-0.1,xai/grok-4.3,xai/grok-4.20-0309-reasoning,xai/grok-4.20-0309-non-reasoning" OPENCLAW_LIVE_GATEWAY_MAX_MODELS=0 OPENCLAW_LIVE_GATEWAY_SMOKE=0 pnpm test:live -- src/gateway/gateway-models.profiles.live.test.ts
OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_TEST_QUIET=1 OPENCLAW_LIVE_IMAGE_GENERATION_PROVIDERS=xai pnpm test:live -- test/image-generation.runtime.live.test.ts
@@ -555,7 +570,9 @@ The provider-specific live file synthesizes normal TTS, telephony-friendly PCM
TTS, transcribes audio through xAI batch STT, streams the same PCM through xAI
realtime STT, generates text-to-image output, and edits a reference image.
The shared image live file verifies the same xAI provider through OpenClaw's
runtime selection, fallback, normalization, and media attachment path.
runtime selection, fallback, normalization, and media attachment path. The
opt-in Video 1.5 case submits one generated first-frame image at 1080P and
verifies the completed video download.
## Related
+16 -5
View File
@@ -11,6 +11,11 @@ title: "Code execution"
(`https://api.x.ai/v1/responses`, same endpoint `x_search` uses). It is
registered by the bundled `xai` plugin under the `tools` contract.
<Warning>
`code_execution` runs on xAI's servers. xAI bills $5 per 1,000 tool calls,
plus the model's input and output tokens.
</Warning>
| Property | Value |
| ------------------ | --------------------------------------------------------------------------------- |
| Tool name | `code_execution` |
@@ -77,9 +82,15 @@ For local execution, use [`exec`](/tools/exec) instead.
</Step>
<Step title="Enable and tune code_execution">
`code_execution` is available whenever xAI credentials resolve. Set
`plugins.entries.xai.config.codeExecution.enabled` to `false` to disable
it, or use the same block to override the model, turn cap, or timeout:
With `enabled` omitted, `code_execution` is exposed only when the active
model's provider is `xai` and xAI credentials resolve. For an active model
with a known non-xAI provider, set
`plugins.entries.xai.config.codeExecution.enabled` to `true` to opt in to
cross-provider use. If the active model provider is missing or unresolved,
the tool stays hidden. Set `enabled` to `false` to disable it for every
provider. xAI credentials are always required.
Use the same block to override the model, turn cap, or timeout:
```json5
{
@@ -88,7 +99,7 @@ For local execution, use [`exec`](/tools/exec) instead.
xai: {
config: {
codeExecution: {
enabled: true,
enabled: true, // required for a known non-xAI model provider
model: "grok-4.3", // override the default xAI code-execution model
maxTurns: 2, // optional cap on internal tool turns
timeoutSeconds: 30, // request timeout (default: 30)
@@ -108,7 +119,7 @@ For local execution, use [`exec`](/tools/exec) instead.
```
`code_execution` appears in the agent's tool list once the xAI plugin
re-registers with `enabled: true`.
re-registers and the provider, enablement, and auth checks above pass.
</Step>
</Steps>
+8 -5
View File
@@ -89,12 +89,15 @@ The default approval socket follows the same root:
`$OPENCLAW_STATE_DIR/exec-approvals.sock`, or
`~/.openclaw/exec-approvals.sock` when the variable is unset.
Releases before 2026.6.11 always kept the file in `~/.openclaw`. If
Releases before 2026.6.6 always kept the file in `~/.openclaw`. If
`OPENCLAW_STATE_DIR` points somewhere else and an approvals file still exists
in the default directory, run `openclaw doctor --fix` once to import it into
the state directory (the original is archived with a `.migrated` suffix).
OpenClaw never imports it automatically: a gateway pointed at a temporary or
staging state directory must not capture the default installation's approvals.
in the default directory, run `openclaw doctor --fix` directly once to import
it into the state directory (the original is archived with a `.migrated`
suffix). Interactive doctor can also preview and confirm the import. Automated
update and Gateway watch repair runs never import across state directories: a
temporary or staging state directory must not capture the default
installation's approvals. The same boundary applies to legacy
`plugin-binding-approvals.json` imports into shared SQLite state.
Example schema:
+12 -5
View File
@@ -119,7 +119,7 @@ openclaw tasks cancel <lookup>
| Runway | `gen4.5` | ✓ | 1 image | 1 video | `RUNWAYML_API_SECRET` |
| Together | `Wan-AI/Wan2.2-T2V-A14B` | ✓ | `Wan-AI/Wan2.2-I2V-A14B` only | - | `TOGETHER_API_KEY` |
| Vydra | `veo3` | ✓ | 1 image (`kling`) | - | `VYDRA_API_KEY` |
| xAI | `grok-imagine-video` | ✓ | 1 first-frame image or up to 7 `reference_image`s | 1 video | `XAI_API_KEY` |
| xAI | `grok-imagine-video` | ✓ | Classic: 1 first frame or 7 references; 1.5: 1 frame | Classic: 1 video | `XAI_API_KEY` |
Some providers accept additional or alternate API key env vars. See
individual [provider pages](#related) for details.
@@ -147,7 +147,7 @@ the shared live sweep:
| Runway | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` runs only when the selected model is `runway/gen4_aleph` |
| Together | ✓ | ✓ | - | `generate`, `imageToVideo` |
| Vydra | ✓ | ✓ | - | `generate`; shared `imageToVideo` skipped because bundled `veo3` is text-only and bundled `kling` requires a remote image URL |
| xAI | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider currently needs a remote MP4 URL |
| xAI | ✓ | ✓ | ✓ | Classic supports all modes; Video 1.5 is image-to-video only; remote MP4 input keeps `videoToVideo` out of the shared sweep |
## Tool parameters
@@ -423,9 +423,16 @@ only the explicit `model`, `primary`, and `fallbacks` entries.
a remote image URL.
</Accordion>
<Accordion title="xAI">
Supports text-to-video, single first-frame image-to-video, up to 7
`reference_image` inputs through xAI `reference_images`, and remote
video edit/extend flows.
The default `grok-imagine-video` model supports text-to-video, single
first-frame image-to-video, up to 7 `reference_image` inputs through xAI
`reference_images`, and remote video edit/extend flows.
`grok-imagine-video-1.5` is image-to-video only: provide exactly one image.
It supports 1-15 seconds and `480P`, `720P`, or `1080P`, defaulting to
`480P`; omit `aspectRatio` to inherit the source image ratio. The preview
and dated 1.5 identifiers receive the same validation and are forwarded
unchanged.
</Accordion>
</AccordionGroup>
+13 -1
View File
@@ -418,6 +418,11 @@ optional structured filters. OpenClaw constructs the built-in xAI `x_search`
tool per request rather than keeping it permanently registered, so it is only
active for the turn that actually calls it.
<Warning>
`x_search` runs on xAI's servers. xAI bills $5 per 1,000 tool calls, plus the
model's input and output tokens.
</Warning>
<Note>
xAI documents `x_search` as supporting keyword search, semantic search, user
search, and thread fetch. For per-post engagement stats such as reposts,
@@ -429,6 +434,13 @@ active for the turn that actually calls it.
### x_search config
With `enabled` omitted, `x_search` is exposed only when the active model's
provider is `xai` and xAI credentials resolve. For an active model with a known
non-xAI provider, set `plugins.entries.xai.config.xSearch.enabled` to `true` to
opt in to cross-provider use. If the active model provider is missing or
unresolved, the tool stays hidden. Set `enabled` to `false` to disable it for
every provider. xAI credentials are always required.
```json5
{
plugins: {
@@ -436,7 +448,7 @@ active for the turn that actually calls it.
xai: {
config: {
xSearch: {
enabled: true,
enabled: true, // required for a known non-xAI model provider
model: "grok-4.3",
baseUrl: "https://api.x.ai/v1", // optional, overrides webSearch.baseUrl
inlineCitations: false,
@@ -6,12 +6,77 @@ import { parseBrowserErrorPayload, type BrowserNoDisplayErrorMetadata } from "./
/** Additive opt-in for structured browser route errors over node.invoke. */
export const BROWSER_PROXY_ERROR_ENVELOPE = "browser-v1" as const;
export const BROWSER_PROXY_MAX_FILE_BYTES = 10 * 1024 * 1024;
// 16 MiB expands to about 21.4 MiB in base64, leaving JSON/result headroom
// below the Gateway's 25 MiB WebSocket frame limit.
export const BROWSER_PROXY_MAX_TOTAL_FILE_BYTES = 16 * 1024 * 1024;
export const BROWSER_PROXY_MAX_FILES = 256;
/** Bound filesystem work even when one action emits many tiny downloads. */
export function assertBrowserProxyFileCountWithinLimit(fileCount: number): void {
if (fileCount > BROWSER_PROXY_MAX_FILES) {
throw new Error("browser proxy response exceeds 256 file limit");
}
}
/** Enforce the shared per-file and raw aggregate Browser proxy limits. */
export function assertBrowserProxyFileBytesWithinLimits(
fileBytes: number,
totalBytes: number,
): void {
if (fileBytes > BROWSER_PROXY_MAX_FILE_BYTES) {
throw new Error("browser proxy file exceeds 10 MiB limit");
}
if (totalBytes > BROWSER_PROXY_MAX_TOTAL_FILE_BYTES) {
throw new Error("browser proxy files exceed 16 MiB aggregate limit");
}
}
export type BrowserProxyFile = {
path: string;
base64: string;
mimeType?: string;
};
/** Visit the route-owned file paths that may cross the Browser node boundary. */
export function visitBrowserProxyFilePaths(
result: unknown,
visit: (filePath: string) => string | void,
): void {
if (!result || typeof result !== "object" || Array.isArray(result)) {
return;
}
const root = result as Record<string, unknown>;
const visitPath = (owner: Record<string, unknown>, key: "path" | "imagePath") => {
const filePath = owner[key];
if (typeof filePath !== "string" || !filePath.trim()) {
return;
}
const replacement = visit(filePath);
if (typeof replacement === "string") {
owner[key] = replacement;
}
};
visitPath(root, "path");
visitPath(root, "imagePath");
const download = root.download;
if (download && typeof download === "object" && !Array.isArray(download)) {
visitPath(download as Record<string, unknown>, "path");
}
// Stay shallow: evaluate results contain page-controlled objects whose
// path-like fields must never become node filesystem reads.
if (Array.isArray(root.downloads)) {
for (const entry of root.downloads) {
if (entry && typeof entry === "object" && !Array.isArray(entry)) {
visitPath(entry as Record<string, unknown>, "path");
}
}
}
}
export type BrowserProxyErrorBody =
| { error: string }
| ({ error: string } & BrowserNoDisplayErrorMetadata);
@@ -1,9 +1,13 @@
// Browser tests cover proxy files plugin behavior.
import fs from "node:fs/promises";
import path from "node:path";
import { MEDIA_MAX_BYTES } from "openclaw/plugin-sdk/media-runtime";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTempHomeEnv, type TempHomeEnv } from "../../test-support.js";
import {
BROWSER_PROXY_MAX_FILE_BYTES,
BROWSER_PROXY_MAX_FILES,
BROWSER_PROXY_MAX_TOTAL_FILE_BYTES,
} from "../browser-proxy-envelope.js";
import { applyBrowserProxyPaths, persistBrowserProxyFiles } from "./proxy-files.js";
describe("persistBrowserProxyFiles", () => {
@@ -35,35 +39,135 @@ describe("persistBrowserProxyFiles", () => {
await expect(fs.readFile(savedPath ?? "", "utf8")).resolves.toBe("hello from browser proxy");
});
it("rejects browser proxy files that exceed the shared media size limit", async () => {
const oversized = Buffer.alloc(MEDIA_MAX_BYTES + 1, 0x41);
it("persists a file at the proxy limit above the shared media default", async () => {
const sourcePath = "/tmp/above-default.bin";
const buffer = Buffer.alloc(BROWSER_PROXY_MAX_FILE_BYTES, 0x41);
const mapping = await persistBrowserProxyFiles([
{
path: sourcePath,
base64: buffer.toString("base64"),
mimeType: "application/octet-stream",
},
]);
await expect(
persistBrowserProxyFiles([
{
path: "/tmp/oversized.bin",
base64: oversized.toString("base64"),
mimeType: "application/octet-stream",
},
]),
).rejects.toThrow("Media exceeds 5MB limit");
await expect(fs.stat(mapping.get(sourcePath) ?? "")).resolves.toMatchObject({
size: buffer.byteLength,
});
});
it("rejects an oversized aggregate before persisting any files", async () => {
const first = Buffer.alloc(BROWSER_PROXY_MAX_FILE_BYTES, 0x41);
const second = Buffer.alloc(
BROWSER_PROXY_MAX_TOTAL_FILE_BYTES - BROWSER_PROXY_MAX_FILE_BYTES + 1,
0x42,
);
const error = await persistBrowserProxyFiles([
{
path: "/tmp/first.bin",
base64: first.toString("base64"),
mimeType: "application/octet-stream",
},
{
path: "/tmp/second.bin",
base64: second.toString("base64"),
mimeType: "application/octet-stream",
},
]).then(
() => null,
(err: unknown) => err,
);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe("browser proxy files exceed 16 MiB aggregate limit");
await expect(
fs.stat(path.join(tempHome.home, ".openclaw", "media", "browser")),
).rejects.toHaveProperty("code", "ENOENT");
});
it("rewrites nested download paths after node file persistence", () => {
it("rejects a file above the proxy per-file limit", async () => {
const oversized = Buffer.alloc(BROWSER_PROXY_MAX_FILE_BYTES + 1, 0x41);
const error = await persistBrowserProxyFiles([
{
path: "/tmp/oversized.bin",
base64: oversized.toString("base64"),
mimeType: "application/octet-stream",
},
]).then(
() => null,
(err: unknown) => err,
);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe("browser proxy file exceeds 10 MiB limit");
await expect(
fs.stat(path.join(tempHome.home, ".openclaw", "media", "browser")),
).rejects.toHaveProperty("code", "ENOENT");
});
it("rejects too many files before persisting any", async () => {
const files = Array.from({ length: BROWSER_PROXY_MAX_FILES + 1 }, (_, index) => ({
path: `/tmp/file-${index}.bin`,
base64: "",
mimeType: "application/octet-stream",
}));
await expect(persistBrowserProxyFiles(files)).rejects.toThrow(
"browser proxy response exceeds 256 file limit",
);
await expect(
fs.stat(path.join(tempHome.home, ".openclaw", "media", "browser")),
).rejects.toHaveProperty("code", "ENOENT");
});
it("rewrites explicit proxy file paths without traversing nested page data", () => {
const result = {
ok: true,
download: { path: "/tmp/openclaw/downloads/report.pdf" },
path: "/node/screenshot.png",
imagePath: "/node/snapshot.png",
download: { path: "/node/download.csv", suggestedFilename: "download.csv" },
downloads: [
{ path: "/node/first.pdf", suggestedFilename: "first.pdf" },
null,
{ path: 42 },
{ path: "/node/second.pdf", suggestedFilename: "second.pdf" },
{ path: "/node/first.pdf", suggestedFilename: "first-copy.pdf" },
],
result: {
path: "/node/page-controlled.txt",
downloads: [{ path: "/node/page-controlled-download.txt" }],
},
};
applyBrowserProxyPaths(
result,
new Map([["/tmp/openclaw/downloads/report.pdf", "/tmp/openclaw-media/report.pdf"]]),
new Map([
["/node/screenshot.png", "/gateway/screenshot.png"],
["/node/snapshot.png", "/gateway/snapshot.png"],
["/node/download.csv", "/gateway/download.csv"],
["/node/first.pdf", "/gateway/first.pdf"],
["/node/second.pdf", "/gateway/second.pdf"],
["/node/page-controlled.txt", "/gateway/should-not-rewrite.txt"],
["/node/page-controlled-download.txt", "/gateway/should-not-rewrite-download.txt"],
]),
);
expect(result.download.path).toBe("/tmp/openclaw-media/report.pdf");
expect(result).toEqual({
ok: true,
path: "/gateway/screenshot.png",
imagePath: "/gateway/snapshot.png",
download: { path: "/gateway/download.csv", suggestedFilename: "download.csv" },
downloads: [
{ path: "/gateway/first.pdf", suggestedFilename: "first.pdf" },
null,
{ path: 42 },
{ path: "/gateway/second.pdf", suggestedFilename: "second.pdf" },
{ path: "/gateway/first.pdf", suggestedFilename: "first-copy.pdf" },
],
result: {
path: "/node/page-controlled.txt",
downloads: [{ path: "/node/page-controlled-download.txt" }],
},
});
});
});
+25 -26
View File
@@ -4,45 +4,44 @@
* Persists files returned by node-hosted browser proxy calls and rewrites
* proxied result paths to local saved media paths.
*/
import {
assertBrowserProxyFileCountWithinLimit,
assertBrowserProxyFileBytesWithinLimits,
BROWSER_PROXY_MAX_FILE_BYTES,
type BrowserProxyFile,
visitBrowserProxyFilePaths,
} from "../browser-proxy-envelope.js";
import { saveMediaBuffer } from "../media/store.js";
type BrowserProxyFile = {
path: string;
base64: string;
mimeType?: string;
};
/** Persist proxy-returned files and return a remote-path to local-path map. */
export async function persistBrowserProxyFiles(files: BrowserProxyFile[] | undefined) {
if (!files || files.length === 0) {
return new Map<string, string>();
}
const mapping = new Map<string, string>();
assertBrowserProxyFileCountWithinLimit(files.length);
const decoded: Array<{ file: BrowserProxyFile; buffer: Buffer }> = [];
let totalBytes = 0;
for (const file of files) {
const buffer = Buffer.from(file.base64, "base64");
const saved = await saveMediaBuffer(buffer, file.mimeType, "browser");
totalBytes += buffer.byteLength;
assertBrowserProxyFileBytesWithinLimits(buffer.byteLength, totalBytes);
decoded.push({ file, buffer });
}
const mapping = new Map<string, string>();
for (const { file, buffer } of decoded) {
const saved = await saveMediaBuffer(
buffer,
file.mimeType,
"browser",
BROWSER_PROXY_MAX_FILE_BYTES,
);
mapping.set(file.path, saved.path);
}
return mapping;
}
/** Rewrite result.path when it points at a persisted proxy file. */
/** Rewrite every supported result path that points at a persisted proxy file. */
export function applyBrowserProxyPaths(result: unknown, mapping: Map<string, string>) {
if (!result || typeof result !== "object") {
return;
}
const obj = result as Record<string, unknown>;
if (typeof obj.path === "string" && mapping.has(obj.path)) {
obj.path = mapping.get(obj.path);
}
if (typeof obj.imagePath === "string" && mapping.has(obj.imagePath)) {
obj.imagePath = mapping.get(obj.imagePath);
}
const download = obj.download;
if (download && typeof download === "object") {
const d = download as Record<string, unknown>;
if (typeof d.path === "string" && mapping.has(d.path)) {
d.path = mapping.get(d.path);
}
}
visitBrowserProxyFilePaths(result, (filePath) => mapping.get(filePath));
}
@@ -1,6 +1,14 @@
// Browser tests cover invoke browser plugin behavior.
import fs from "node:fs/promises";
import os from "node:os";
import nodePath from "node:path";
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
BROWSER_PROXY_MAX_FILE_BYTES,
BROWSER_PROXY_MAX_FILES,
BROWSER_PROXY_MAX_TOTAL_FILE_BYTES,
} from "../browser-proxy-envelope.js";
const controlServiceMocks = vi.hoisted(() => ({
createBrowserControlContext: vi.fn(() => ({ control: true })),
@@ -191,6 +199,117 @@ describe("runBrowserProxyCommand", () => {
controlServiceMocks.startBrowserControlServiceFromConfig.mockResolvedValue(true);
});
it("serializes plural action downloads without reading nested page paths", async () => {
const tempDir = await fs.mkdtemp(nodePath.join(os.tmpdir(), "openclaw-browser-proxy-action-"));
const firstPath = nodePath.join(tempDir, "first.txt");
const secondPath = nodePath.join(tempDir, "second.txt");
const nestedPagePath = nodePath.join(tempDir, "page-controlled.txt");
const result = {
ok: true,
downloads: [
{ path: firstPath, suggestedFilename: "first.txt" },
null,
{ path: 42 },
{ path: secondPath, suggestedFilename: "second.txt" },
{ path: firstPath, suggestedFilename: "first-copy.txt" },
],
result: {
path: nestedPagePath,
downloads: [{ path: nestedPagePath }],
},
};
try {
await Promise.all([
fs.writeFile(firstPath, "first browser download", "utf8"),
fs.writeFile(secondPath, "second browser download", "utf8"),
fs.writeFile(nestedPagePath, "must stay on the node", "utf8"),
]);
dispatcherMocks.dispatch.mockResolvedValueOnce({ status: 200, body: result });
const payload = JSON.parse(
await runBrowserProxyCommand(JSON.stringify({ method: "POST", path: "/act" })),
) as {
result: unknown;
files?: Array<{ path: string; base64: string; mimeType?: string }>;
};
expect(payload.result).toEqual(result);
expect(
payload.files?.map((file) => ({
path: file.path,
contents: Buffer.from(file.base64, "base64").toString("utf8"),
mimeType: file.mimeType,
})),
).toEqual([
{ path: firstPath, contents: "first browser download", mimeType: "image/png" },
{ path: secondPath, contents: "second browser download", mimeType: "image/png" },
]);
expect(payload.files?.some((file) => file.path === nestedPagePath)).toBe(false);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("rejects an aggregate above the proxy transport budget", async () => {
const tempDir = await fs.mkdtemp(nodePath.join(os.tmpdir(), "openclaw-browser-proxy-limit-"));
const firstPath = nodePath.join(tempDir, "first.bin");
const secondPath = nodePath.join(tempDir, "second.bin");
try {
await Promise.all([fs.writeFile(firstPath, ""), fs.writeFile(secondPath, "")]);
await Promise.all([
fs.truncate(firstPath, BROWSER_PROXY_MAX_FILE_BYTES),
fs.truncate(
secondPath,
BROWSER_PROXY_MAX_TOTAL_FILE_BYTES - BROWSER_PROXY_MAX_FILE_BYTES + 1,
),
]);
dispatcherMocks.dispatch.mockResolvedValueOnce({
status: 200,
body: { downloads: [{ path: firstPath }, { path: secondPath }] },
});
const error = await runBrowserProxyCommand(
JSON.stringify({ method: "POST", path: "/act" }),
).then(
() => null,
(err: unknown) => err,
);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe(
`browser proxy file read failed for ${secondPath}: Error: browser proxy files exceed 16 MiB aggregate limit`,
);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("rejects too many unique files before reading them", async () => {
dispatcherMocks.dispatch.mockResolvedValueOnce({
status: 200,
body: {
downloads: Array.from({ length: BROWSER_PROXY_MAX_FILES + 1 }, (_, index) => ({
path: `/missing/browser-download-${index}.bin`,
})),
},
});
await expect(
runBrowserProxyCommand(JSON.stringify({ method: "POST", path: "/act" })),
).rejects.toThrow("browser proxy response exceeds 256 file limit");
});
it("rejects a result whose encoded node frame would exceed the transport limit", async () => {
dispatcherMocks.dispatch.mockResolvedValueOnce({
status: 200,
body: { result: "\\".repeat(7 * 1024 * 1024) },
});
await expect(
runBrowserProxyCommand(JSON.stringify({ method: "POST", path: "/act" })),
).rejects.toThrow("browser proxy payload exceeds 24 MiB encoded limit");
});
it("adds profile and browser status details on ws-backed timeouts", async () => {
vi.useFakeTimers();
dispatcherMocks.dispatch
@@ -6,10 +6,13 @@ import fsPromises from "node:fs/promises";
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
assertBrowserProxyFileCountWithinLimit,
assertBrowserProxyFileBytesWithinLimits,
BROWSER_PROXY_ERROR_ENVELOPE,
createBrowserProxyFailure,
type BrowserProxyEnvelope,
type BrowserProxyFile,
visitBrowserProxyFilePaths,
} from "../browser-proxy-envelope.js";
import { redactCdpUrl } from "../browser/cdp.helpers.js";
import { loadBrowserConfigForRuntimeRefresh } from "../browser/config-refresh-source.js";
@@ -37,9 +40,10 @@ type BrowserProxyParams = {
errorEnvelope?: unknown;
};
const BROWSER_PROXY_MAX_FILE_BYTES = 10 * 1024 * 1024;
const DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 20_000;
const BROWSER_PROXY_STATUS_TIMEOUT_MS = 750;
// Leave one MiB for the fixed node.invoke.result frame around payloadJSON.
const BROWSER_PROXY_MAX_ENCODED_PAYLOAD_BYTES = 24 * 1024 * 1024;
function normalizeProfileAllowlist(raw?: string[]): string[] {
return Array.isArray(raw) ? normalizeStringEntries(raw) : [];
@@ -93,40 +97,36 @@ function isProfileAllowed(params: { allowProfiles: string[]; profile?: string |
function collectBrowserProxyPaths(payload: unknown): string[] {
const paths = new Set<string>();
const obj =
typeof payload === "object" && payload !== null ? (payload as Record<string, unknown>) : null;
if (!obj) {
return [];
}
if (typeof obj.path === "string" && obj.path.trim()) {
paths.add(obj.path.trim());
}
if (typeof obj.imagePath === "string" && obj.imagePath.trim()) {
paths.add(obj.imagePath.trim());
}
const download = obj.download;
if (download && typeof download === "object") {
const dlPath = (download as Record<string, unknown>).path;
if (typeof dlPath === "string" && dlPath.trim()) {
paths.add(dlPath.trim());
}
}
visitBrowserProxyFilePaths(payload, (filePath) => {
paths.add(filePath.trim());
assertBrowserProxyFileCountWithinLimit(paths.size);
});
return [...paths];
}
async function readBrowserProxyFile(filePath: string): Promise<BrowserProxyFile | null> {
const stat = await fsPromises.stat(filePath).catch(() => null);
if (!stat || !stat.isFile()) {
return null;
async function readBrowserProxyFiles(filePaths: string[]): Promise<BrowserProxyFile[]> {
const files: BrowserProxyFile[] = [];
let totalBytes = 0;
for (const filePath of filePaths) {
try {
const stat = await fsPromises.stat(filePath).catch(() => null);
if (!stat || !stat.isFile()) {
throw new Error("file not found");
}
assertBrowserProxyFileBytesWithinLimits(stat.size, totalBytes + stat.size);
const buffer = await fsPromises.readFile(filePath);
assertBrowserProxyFileBytesWithinLimits(buffer.byteLength, totalBytes + buffer.byteLength);
totalBytes += buffer.byteLength;
const mimeType = await detectMime({ buffer, filePath });
files.push({ path: filePath, base64: buffer.toString("base64"), mimeType });
} catch (err) {
throw new Error(`browser proxy file read failed for ${filePath}: ${String(err)}`, {
cause: err,
});
}
}
if (stat.size > BROWSER_PROXY_MAX_FILE_BYTES) {
throw new Error(
`browser proxy file exceeds ${Math.round(BROWSER_PROXY_MAX_FILE_BYTES / (1024 * 1024))}MB`,
);
}
const buffer = await fsPromises.readFile(filePath);
const mimeType = await detectMime({ buffer, filePath });
return { path: filePath, base64: buffer.toString("base64"), mimeType };
return files;
}
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- CLI JSON params are typed by the invoked method.
@@ -339,29 +339,14 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis
});
}
let files: BrowserProxyFile[] | undefined;
const paths = collectBrowserProxyPaths(result);
if (paths.length > 0) {
const loaded = await Promise.all(
paths.map(async (p) => {
try {
const file = await readBrowserProxyFile(p);
if (!file) {
throw new Error("file not found");
}
return file;
} catch (err) {
throw new Error(`browser proxy file read failed for ${p}: ${String(err)}`, {
cause: err,
});
}
}),
);
if (loaded.length > 0) {
files = loaded;
}
}
const files = paths.length > 0 ? await readBrowserProxyFiles(paths) : undefined;
const payload: BrowserProxyEnvelope = files ? { result, files } : { result };
return JSON.stringify(payload);
const serialized = JSON.stringify(payload);
// Node results carry this JSON as a string inside a second JSON frame.
if (Buffer.byteLength(JSON.stringify(serialized)) > BROWSER_PROXY_MAX_ENCODED_PAYLOAD_BYTES) {
throw new Error("browser proxy payload exceeds 24 MiB encoded limit");
}
return serialized;
}
+70
View File
@@ -10,6 +10,7 @@ const providerAuthRuntimeMocks = vi.hoisted(() => ({
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => providerAuthRuntimeMocks);
import plugin from "./index.js";
import { wrapClawRouterProviderStream } from "./stream.js";
const LIVE_CATALOG = {
providers: [
@@ -101,12 +102,81 @@ describe("ClawRouter plugin", () => {
expect(calls[0]?.headers).toEqual({
"X-Request-ID": "request-1",
"X-ClawRouter-Client": "openclaw",
Authorization: "Bearer runtime-proxy-key",
});
expect(calls[0]?.id).toBe("claude-sonnet-4-6");
expect(calls[0]?.params).toBeUndefined();
});
it("attaches bounded attribution without overriding configured metadata", () => {
const calls: Array<Parameters<StreamFn>[0]> = [];
const baseStreamFn: StreamFn = (model) => {
calls.push(model);
return {} as ReturnType<StreamFn>;
};
const wrapped = wrapClawRouterProviderStream({
provider: "clawrouter",
modelId: "openai/gpt-5.5",
agentId: "main",
streamFn: baseStreamFn,
} as never);
void wrapped?.(
{
provider: "clawrouter",
api: "openai-responses",
id: "openai/gpt-5.5",
headers: {
"x-clawrouter-client": "managed-openclaw",
"X-ClawRouter-Project-Id": "fakeco",
},
} as never,
{} as never,
{
apiKey: "runtime-proxy-key",
sessionId: `session-${"x".repeat(300)}`,
} as never,
);
expect(calls[0]?.headers).toMatchObject({
"x-clawrouter-client": "managed-openclaw",
"X-ClawRouter-Agent-Id": "main",
"X-ClawRouter-Project-Id": "fakeco",
Authorization: "Bearer runtime-proxy-key",
});
expect(calls[0]?.headers?.["X-ClawRouter-Session-Id"]).toHaveLength(256);
});
it("omits unsafe attribution header values", () => {
const calls: Array<Parameters<StreamFn>[0]> = [];
const baseStreamFn: StreamFn = (model) => {
calls.push(model);
return {} as ReturnType<StreamFn>;
};
const wrapped = wrapClawRouterProviderStream({
provider: "clawrouter",
modelId: "openai/gpt-5.5",
agentId: "bad\nagent",
streamFn: baseStreamFn,
} as never);
void wrapped?.(
{
provider: "clawrouter",
api: "openai-responses",
id: "openai/gpt-5.5",
} as never,
{} as never,
{ apiKey: "runtime-proxy-key", sessionId: "bad\rsession" } as never,
);
expect(calls[0]?.headers).toEqual({
"X-ClawRouter-Client": "openclaw",
Authorization: "Bearer runtime-proxy-key",
});
});
it("resolves managed secret refs before scoped discovery", async () => {
providerAuthRuntimeMocks.resolveApiKeyForProvider.mockResolvedValue({
apiKey: "resolved-proxy-key",
+59 -10
View File
@@ -3,35 +3,84 @@ import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-ent
import { prepareClawRouterRequestModel } from "./provider-catalog.js";
const ENV_API_KEY_MARKER = "CLAWROUTER_API_KEY";
const ATTRIBUTION_VALUE_MAX_LENGTH = 256;
const CLIENT_HEADER = "X-ClawRouter-Client";
const AGENT_HEADER = "X-ClawRouter-Agent-Id";
const SESSION_HEADER = "X-ClawRouter-Session-Id";
function withBearerAuthorization(
function hasControlCharacter(value: string): boolean {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code <= 0x1f || code === 0x7f) {
return true;
}
}
return false;
}
function sanitizeAttributionValue(value: string | undefined): string | undefined {
const normalized = value?.trim();
if (!normalized || hasControlCharacter(normalized)) {
return undefined;
}
return normalized.slice(0, ATTRIBUTION_VALUE_MAX_LENGTH);
}
function findHeader(headers: Record<string, string>, target: string): string | undefined {
const normalizedTarget = target.toLowerCase();
for (const [name, value] of Object.entries(headers)) {
if (name.toLowerCase() === normalizedTarget) {
return value;
}
}
return undefined;
}
function setHeaderDefault(
headers: Record<string, string>,
name: string,
value: string | undefined,
): void {
if (value !== undefined && findHeader(headers, name) === undefined) {
headers[name] = value;
}
}
function withClawRouterHeaders(
headers: Record<string, string> | undefined,
apiKey: string,
params: { agentId?: string; apiKey?: string; sessionId?: string },
): Record<string, string> {
const next: Record<string, string> = {};
for (const [name, value] of Object.entries(headers ?? {})) {
if (name.toLowerCase() !== "authorization") {
if (name.toLowerCase() !== "authorization" || !params.apiKey) {
next[name] = value;
}
}
next.Authorization = `Bearer ${apiKey}`;
setHeaderDefault(next, CLIENT_HEADER, "openclaw");
setHeaderDefault(next, AGENT_HEADER, sanitizeAttributionValue(params.agentId));
setHeaderDefault(next, SESSION_HEADER, sanitizeAttributionValue(params.sessionId));
if (params.apiKey) {
next.Authorization = `Bearer ${params.apiKey}`;
}
return next;
}
function createClawRouterStreamWrapper(underlying: StreamFn | undefined): StreamFn | undefined {
function createClawRouterStreamWrapper(ctx: ProviderWrapStreamFnContext): StreamFn | undefined {
const underlying = ctx.streamFn;
if (!underlying) {
return undefined;
}
return (model, context, options) => {
const apiKey = options?.apiKey?.trim();
const preparedModel = prepareClawRouterRequestModel(model);
if (!apiKey || apiKey === ENV_API_KEY_MARKER) {
return underlying(preparedModel, context, options);
}
return underlying(
{
...preparedModel,
headers: withBearerAuthorization(preparedModel.headers, apiKey),
headers: withClawRouterHeaders(preparedModel.headers, {
agentId: ctx.agentId,
apiKey: apiKey && apiKey !== ENV_API_KEY_MARKER ? apiKey : undefined,
sessionId: options?.sessionId,
}),
},
context,
options,
@@ -42,5 +91,5 @@ function createClawRouterStreamWrapper(underlying: StreamFn | undefined): Stream
export function wrapClawRouterProviderStream(
ctx: ProviderWrapStreamFnContext,
): StreamFn | undefined {
return createClawRouterStreamWrapper(ctx.streamFn);
return createClawRouterStreamWrapper(ctx);
}
+1
View File
@@ -60,6 +60,7 @@ export function createCodexAppServerAgentHarness(options: {
deliveryDefaults: {
sourceVisibleReplies: "message_tool",
},
authBootstrap: "harness",
supports: (ctx) => {
const provider = ctx.provider.trim().toLowerCase();
if (providerIds.has(provider)) {
+8
View File
@@ -379,6 +379,14 @@ describe("codex plugin", () => {
);
});
it("owns auth bootstrap for forwarded profiles and native Codex sign-in", () => {
const harness = createCodexAppServerAgentHarness({
bindingStore: testCodexAppServerBindingStore,
});
expect(harness.authBootstrap).toBe("harness");
});
it("passes live Codex plugin config into public Codex app-server attempts", async () => {
const registerAgentHarness = vi.fn();
const liveConfig = {
@@ -6,6 +6,10 @@ import {
embeddedAgentLog,
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
clearMemoryPluginState,
registerMemoryCapability,
} from "openclaw/plugin-sdk/memory-host-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildCodexWorkspaceBootstrapContext,
@@ -20,6 +24,7 @@ import type { CodexAppServerContextEngineBinding } from "./session-binding.js";
afterEach(() => {
vi.restoreAllMocks();
clearMemoryPluginState();
});
describe("Codex app-server attempt context", () => {
@@ -143,6 +148,54 @@ describe("Codex app-server attempt context", () => {
expect(context.memoryToolRouted).toBe(false);
});
it("passes agent context to Codex memory collaboration guidance", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "codex-agent-memory-"));
let observedContext:
| { agentId?: string; agentSessionKey?: string; sandboxed?: boolean }
| undefined;
registerMemoryCapability("memory-core", {
promptBuilder: (context) => {
observedContext = context;
return [
"## Agent Memory",
`agent=${context.agentId} session=${context.agentSessionKey}`,
"",
];
},
});
try {
const context = await buildCodexWorkspaceBootstrapContext({
params: {
sessionId: "session-1",
sessionKey: "agent:marketing-agent:session-1",
config: {
agents: {
defaults: { workspace: workspaceDir },
list: [{ id: "marketing-agent", default: true, workspace: workspaceDir }],
},
},
} as EmbeddedRunAttemptParams,
resolvedWorkspace: workspaceDir,
effectiveWorkspace: workspaceDir,
sessionKey: "agent:marketing-agent:session-1",
sessionAgentId: "marketing-agent",
memoryToolNames: ["memory_search", "memory_get"],
});
expect(context.memoryToolRouted).toBe(true);
expect(observedContext).toMatchObject({
agentId: "marketing-agent",
agentSessionKey: "agent:marketing-agent:session-1",
});
expect(context.memoryCollaborationInstructions).toContain(
"agent=marketing-agent session=agent:marketing-agent:session-1",
);
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
it("remaps Codex bootstrap files under dot-prefixed workspace directories", () => {
expect(
remapCodexContextFilePath({
@@ -260,6 +260,8 @@ export async function buildCodexWorkspaceBootstrapContext(params: {
toolNames: params.memoryToolNames,
memoryToolRouted: memoryToolsAvailable,
citationsMode: params.params.config?.memory?.citations,
agentId: params.params.agentId ?? params.sessionAgentId,
agentSessionKey: params.sessionKey,
})
: undefined,
heartbeatCollaborationInstructions:
@@ -858,11 +860,15 @@ function renderCodexWorkspaceMemoryCollaborationInstructions(params: {
toolNames: readonly string[];
memoryToolRouted: boolean;
citationsMode?: Parameters<typeof buildMemorySystemPromptAddition>[0]["citationsMode"];
agentId?: string;
agentSessionKey?: string;
}): string | undefined {
const memoryRecallInstructions = params.memoryToolRouted
? renderCodexMemoryRecallInstructions({
toolNames: params.toolNames,
citationsMode: params.citationsMode,
agentId: params.agentId,
agentSessionKey: params.agentSessionKey,
})
: undefined;
const memoryReferenceInstructions = renderCodexWorkspaceMemoryReference({
@@ -876,11 +882,15 @@ function renderCodexWorkspaceMemoryCollaborationInstructions(params: {
function renderCodexMemoryRecallInstructions(params: {
toolNames: readonly string[];
citationsMode?: Parameters<typeof buildMemorySystemPromptAddition>[0]["citationsMode"];
agentId?: string;
agentSessionKey?: string;
}): string | undefined {
const availableTools = new Set(params.toolNames);
const memoryPrompt = buildMemorySystemPromptAddition({
availableTools,
citationsMode: params.citationsMode,
agentId: params.agentId,
agentSessionKey: params.agentSessionKey,
});
if (!memoryPrompt) {
// Memory recall policy belongs to the active memory plugin.
@@ -20,7 +20,11 @@ import {
} from "./memory-tool-manager.test-mocks.js";
import { testing as shortTermPromotionTesting } from "./short-term-promotion.js";
import { createMemoryCoreTestHarness } from "./test-helpers.js";
import { testing as memoryToolsTesting } from "./tools.js";
import {
createMemoryGetTool,
createMemorySearchTool,
testing as memoryToolsTesting,
} from "./tools.js";
import {
asOpenClawConfig,
createAutoCitationsMemorySearchTool,
@@ -393,6 +397,51 @@ describe("memory tools", () => {
expect(getMemorySearchManagerMockCalls()).toBe(0);
});
it.each(["wiki", "all"] as const)(
"forwards effective agent context to memory_search corpus=%s supplements",
async (corpus) => {
const search = vi.fn(async () => [
{
corpus: "wiki" as const,
path: "entities/alpha.md",
score: 4,
snippet: "Alpha wiki entry",
},
]);
registerMemoryCorpusSupplement("memory-wiki", {
search,
get: async () => null,
});
const config = asOpenClawConfig({
agents: { list: [{ id: "marketing-agent", default: true }] },
});
const tool = createMemorySearchTool({
config,
agentId: " Marketing Agent ",
agentSessionKey: "agent:marketing-agent:main",
sandboxed: true,
});
if (!tool) {
throw new Error("expected memory_search tool");
}
await tool.execute(`call_search_${corpus}`, {
query: "alpha",
maxResults: 3,
corpus,
});
expect(search).toHaveBeenCalledWith({
query: "alpha",
maxResults: 3,
agentId: "marketing-agent",
agentSessionKey: "agent:marketing-agent:main",
sandboxed: true,
corpus,
});
},
);
it("includes memory results in corpus=all even when wiki scores are numerically higher (#77337)", async () => {
// Wiki uses integer point scores (up to ~100+); memory uses cosine similarity (0-1).
// Raw-score sort would starve memory hits when maxResults <= number of wiki hits.
@@ -630,6 +679,57 @@ describe("memory tools", () => {
});
});
it.each(["wiki", "all"] as const)(
"forwards effective agent context to memory_get corpus=%s supplements",
async (corpus) => {
if (corpus === "all") {
setMemoryReadFileImpl(async () => {
throw new Error("memory path missing");
});
}
const get = vi.fn(async () => ({
corpus: "wiki" as const,
path: "entities/alpha.md",
content: "Alpha wiki entry",
fromLine: 2,
lineCount: 4,
}));
registerMemoryCorpusSupplement("memory-wiki", {
search: async () => [],
get,
});
const config = asOpenClawConfig({
agents: { list: [{ id: "marketing-agent", default: true }] },
});
const tool = createMemoryGetTool({
config,
agentId: " Marketing Agent ",
agentSessionKey: "agent:marketing-agent:main",
sandboxed: true,
});
if (!tool) {
throw new Error("expected memory_get tool");
}
await tool.execute(`call_get_${corpus}`, {
path: "entities/alpha.md",
from: 2,
lines: 4,
corpus,
});
expect(get).toHaveBeenCalledWith({
lookup: "entities/alpha.md",
fromLine: 2,
lineCount: 4,
agentId: "marketing-agent",
agentSessionKey: "agent:marketing-agent:main",
sandboxed: true,
corpus,
});
},
);
it("falls back to a wiki corpus supplement when memory_get corpus=all misses memory without throwing", async () => {
setMemoryReadFileImpl(async (params: MemoryReadParams) => ({
text: "",
@@ -19,6 +19,7 @@ type MemoryToolOptions = {
getConfig?: () => OpenClawConfig | undefined;
agentId?: string;
agentSessionKey?: string;
sandboxed?: boolean;
oneShotCliRun?: boolean;
};
@@ -154,7 +155,9 @@ export function buildMemorySearchUnavailableResult(
export async function searchMemoryCorpusSupplements(params: {
query: string;
maxResults?: number;
agentId?: string;
agentSessionKey?: string;
sandboxed?: boolean;
corpus?: "memory" | "wiki" | "all" | "sessions";
}): Promise<MemoryCorpusSearchResult[]> {
if (params.corpus === "memory" || params.corpus === "sessions") {
@@ -183,7 +186,9 @@ export async function getMemoryCorpusSupplementResult(params: {
lookup: string;
fromLine?: number;
lineCount?: number;
agentId?: string;
agentSessionKey?: string;
sandboxed?: boolean;
corpus?: "memory" | "wiki" | "all" | "sessions";
}) {
if (params.corpus === "memory" || params.corpus === "sessions") {
+23
View File
@@ -319,14 +319,18 @@ async function getSupplementMemoryReadResult(params: {
relPath: string;
from?: number;
lines?: number;
agentId?: string;
agentSessionKey?: string;
sandboxed?: boolean;
corpus?: "memory" | "wiki" | "all";
}) {
const supplement = await getMemoryCorpusSupplementResult({
lookup: params.relPath,
fromLine: params.from,
lineCount: params.lines,
agentId: params.agentId,
agentSessionKey: params.agentSessionKey,
sandboxed: params.sandboxed,
corpus: params.corpus,
});
if (!supplement) {
@@ -345,7 +349,9 @@ async function resolveMemoryReadFailureResult(params: {
relPath: string;
from?: number;
lines?: number;
agentId?: string;
agentSessionKey?: string;
sandboxed?: boolean;
}) {
if (params.requestedCorpus === "all") {
try {
@@ -353,7 +359,9 @@ async function resolveMemoryReadFailureResult(params: {
relPath: params.relPath,
from: params.from,
lines: params.lines,
agentId: params.agentId,
agentSessionKey: params.agentSessionKey,
sandboxed: params.sandboxed,
corpus: params.requestedCorpus,
});
if (supplement) {
@@ -378,7 +386,9 @@ async function executeMemoryReadResult(params: {
relPath: string;
from?: number;
lines?: number;
agentId?: string;
agentSessionKey?: string;
sandboxed?: boolean;
}) {
try {
const result = await params.read();
@@ -387,7 +397,9 @@ async function executeMemoryReadResult(params: {
relPath: params.relPath,
from: params.from,
lines: params.lines,
agentId: params.agentId,
agentSessionKey: params.agentSessionKey,
sandboxed: params.sandboxed,
corpus: params.requestedCorpus,
});
if (supplement) {
@@ -402,7 +414,9 @@ async function executeMemoryReadResult(params: {
relPath: params.relPath,
from: params.from,
lines: params.lines,
agentId: params.agentId,
agentSessionKey: params.agentSessionKey,
sandboxed: params.sandboxed,
});
}
}
@@ -677,7 +691,9 @@ export function createMemorySearchTool(options: {
await searchMemoryCorpusSupplements({
query,
maxResults,
agentId,
agentSessionKey: options.agentSessionKey,
sandboxed: options.sandboxed,
corpus: requestedCorpus,
}),
)
@@ -733,6 +749,7 @@ export function createMemoryGetTool(options: {
getConfig?: () => OpenClawConfig | undefined;
agentId?: string;
agentSessionKey?: string;
sandboxed?: boolean;
}) {
return createMemoryTool({
options,
@@ -759,7 +776,9 @@ export function createMemoryGetTool(options: {
relPath,
from: from ?? undefined,
lines: lines ?? undefined,
agentId,
agentSessionKey: options.agentSessionKey,
sandboxed: options.sandboxed,
corpus: requestedCorpus,
});
return jsonResult(
@@ -786,7 +805,9 @@ export function createMemoryGetTool(options: {
relPath,
from: from ?? undefined,
lines: lines ?? undefined,
agentId,
agentSessionKey: options.agentSessionKey,
sandboxed: options.sandboxed,
});
}
const memory = await getMemoryManagerContextWithPurpose({
@@ -808,7 +829,9 @@ export function createMemoryGetTool(options: {
relPath,
from: from ?? undefined,
lines: lines ?? undefined,
agentId,
agentSessionKey: options.agentSessionKey,
sandboxed: options.sandboxed,
});
},
});
+58
View File
@@ -14,6 +14,10 @@ When the active memory plugin exposes shared recall, agents can use `memory_sear
Default mode is `isolated`.
`vaultMode` controls the wiki's inputs. `vault.scope` separately controls
whether agents share one vault (`global`, the default) or resolve separate
vaults (`agent`).
## Config
Put config under `plugins.entries.memory-wiki.config`:
@@ -23,6 +27,7 @@ Put config under `plugins.entries.memory-wiki.config`:
vaultMode: "isolated",
vault: {
scope: "global", // or "agent"
path: "~/.openclaw/wiki/main",
renderMode: "obsidian", // or "native"
},
@@ -71,6 +76,51 @@ Put config under `plugins.entries.memory-wiki.config`:
}
```
### Per-agent vaults
In agent scope, `vault.path` is a parent directory. OpenClaw appends the
normalized agent id:
```json5
{
vaultMode: "bridge",
vault: {
scope: "agent",
path: "~/.openclaw/wiki",
},
bridge: {
enabled: true,
readMemoryArtifacts: true,
},
obsidian: {
useOfficialCli: false,
},
}
```
This resolves agents such as `support` and `marketing` to
`~/.openclaw/wiki/support` and `~/.openclaw/wiki/marketing`. With no explicit
path, the parent defaults to `~/.openclaw/wiki`; the default `main` agent
therefore keeps the existing `~/.openclaw/wiki/main` path. In global scope,
`vault.path` remains the exact shared vault path.
Wiki tools and compiled prompt/corpus supplements resolve the active runtime
agent on each call. In bridge mode, an agent vault imports only public memory
artifacts whose `agentIds` includes that agent; unowned and other-agent
artifacts are skipped. CLI and Gateway operations require an explicit agent in
multi-agent setups; use `openclaw wiki --agent <agentId> ...` or pass `agentId`
to the `wiki.*` RPC request. A single configured agent may remain implicit.
Configuration validation rejects agent scope with either
`vaultMode: "unsafe-local"` or `obsidian.useOfficialCli: true`. Obsidian-friendly
Markdown rendering still works with agent vaults when official CLI actions are
disabled.
Changing scope does not copy or split existing pages. Back up the vault and
move or import content deliberately. Per-agent paths are a same-process
knowledge boundary, not an operating-system security boundary; unsandboxed
plugins and tools can still access another agent's host files.
## Vault shape
The plugin initializes a vault like this:
@@ -130,6 +180,10 @@ openclaw wiki obsidian search "alpha"
openclaw wiki obsidian open syntheses/alpha-summary.md
openclaw wiki obsidian command workspace:quick-switcher
openclaw wiki obsidian daily
# Agent-scoped vault
openclaw wiki --agent support status
openclaw wiki --agent support search "refund policy"
```
## Agent tools
@@ -170,10 +224,14 @@ Write methods:
- `wiki.obsidian.command`
- `wiki.obsidian.daily`
For agent-scoped vaults, pass `agentId` to vault-backed RPC methods. Missing or
unknown ids fail in multi-agent setups.
## Notes
- `unsafe-local` is intentionally experimental and non-portable.
- Bridge mode reads the active memory plugin through public seams only.
- Agent scope is incompatible with `unsafe-local` and official Obsidian CLI actions.
- Wiki pages are compiled artifacts, not the ultimate source of truth. Keep provenance attached to raw sources, memory artifacts, and daily notes.
- The compiled agent digests in `.openclaw-wiki/cache/agent-digest.json` and `.openclaw-wiki/cache/claims.jsonl` are the stable machine-facing view of the wiki.
- Obsidian CLI support requires the official `obsidian` CLI to be installed and available on `PATH`.
+10 -1
View File
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
registerWikiCli: vi.fn(),
resolveMemoryWikiAgentConfig: vi.fn(),
resolveMemoryWikiConfig: vi.fn(),
}));
@@ -13,6 +14,7 @@ vi.mock("./src/cli.js", () => ({
}));
vi.mock("./src/config.js", () => ({
resolveMemoryWikiAgentConfig: mocks.resolveMemoryWikiAgentConfig,
resolveMemoryWikiConfig: mocks.resolveMemoryWikiConfig,
}));
@@ -73,6 +75,13 @@ describe("memory-wiki cli metadata entry", () => {
expect(mocks.resolveMemoryWikiConfig).toHaveBeenCalledWith(
appConfig.plugins.entries["memory-wiki"].config,
);
expect(mocks.registerWikiCli).toHaveBeenCalledWith(program, resolvedConfig, appConfig);
expect(mocks.registerWikiCli).toHaveBeenCalledWith(
program,
expect.objectContaining({
config: resolvedConfig,
getAppConfig: expect.any(Function),
resolveConfig: expect.any(Function),
}),
);
});
});
+13 -5
View File
@@ -8,12 +8,20 @@ export default definePluginEntry({
register(api) {
api.registerCli(
async ({ program, config: appConfig }) => {
const [{ registerWikiCli }, { resolveMemoryWikiConfig }] = await Promise.all([
import("./src/cli.js"),
import("./src/config.js"),
]);
const [{ registerWikiCli }, { resolveMemoryWikiAgentConfig, resolveMemoryWikiConfig }] =
await Promise.all([import("./src/cli.js"), import("./src/config.js")]);
const pluginConfig = appConfig.plugins?.entries?.["memory-wiki"]?.config;
registerWikiCli(program, resolveMemoryWikiConfig(pluginConfig), appConfig);
const config = resolveMemoryWikiConfig(pluginConfig);
registerWikiCli(program, {
config,
getAppConfig: () => appConfig,
resolveConfig: (agentId, currentAppConfig) =>
resolveMemoryWikiAgentConfig({
config,
appConfig: currentAppConfig ?? appConfig,
...(agentId ? { agentId } : {}),
}),
});
},
{
descriptors: [
@@ -31,15 +31,19 @@ function resolveLegacyImportRunRecordPath(vaultRoot: string, runId: string): str
return path.join(vaultRoot, ".openclaw-wiki", "import-runs", `${runId}.json`);
}
function migrationParams(params: { stateDir: string; vaultRoot: string }) {
function migrationParams(params: { stateDir: string; vaultRoot: string; agentIds?: string[] }) {
const env = { ...process.env, HOME: params.stateDir, OPENCLAW_STATE_DIR: params.stateDir };
return {
config: {
...(params.agentIds ? { agents: { list: params.agentIds.map((id) => ({ id })) } } : {}),
plugins: {
entries: {
"memory-wiki": {
config: {
vault: { path: params.vaultRoot },
vault: {
path: params.vaultRoot,
...(params.agentIds ? { scope: "agent" as const } : {}),
},
},
},
},
@@ -267,4 +271,48 @@ describe("memory-wiki doctor source sync migration", () => {
});
await expect(fs.stat(legacyPath)).rejects.toMatchObject({ code: "ENOENT" });
});
it("migrates legacy state from every configured agent vault", async () => {
const stateDir = await makeTempDir();
const vaultRoot = path.join(stateDir, "vaults");
const agentIds = ["support", "marketing"];
for (const agentId of agentIds) {
const legacyPath = resolveMemoryWikiSourceSyncStatePath(path.join(vaultRoot, agentId));
await fs.mkdir(path.dirname(legacyPath), { recursive: true });
await fs.writeFile(
legacyPath,
`${JSON.stringify({
version: 1,
entries: {
[agentId]: {
group: "bridge",
pagePath: `sources/${agentId}.md`,
sourcePath: `/tmp/${agentId}.md`,
sourceUpdatedAtMs: 100,
sourceSize: 200,
renderFingerprint: agentId,
},
},
})}\n`,
);
}
const params = migrationParams({ stateDir, vaultRoot, agentIds });
await expect(stateMigrations[0].detectLegacyState(params)).resolves.toEqual({
preview: [
expect.stringContaining(path.join(vaultRoot, "support")),
expect.stringContaining(path.join(vaultRoot, "marketing")),
],
});
await expect(stateMigrations[0].migrateLegacyState(params)).resolves.toMatchObject({
warnings: [],
});
const store = createMemoryWikiSourceSyncStateStore(params.context.openPluginStateKeyedStore);
for (const agentId of agentIds) {
await expect(
readMemoryWikiSourceSyncState(path.join(vaultRoot, agentId), store),
).resolves.toMatchObject({ entries: { [agentId]: { renderFingerprint: agentId } } });
}
});
});
+17 -2
View File
@@ -7,7 +7,12 @@ import {
legacyStateFileExists,
type PluginDoctorStateMigration,
} from "openclaw/plugin-sdk/runtime-doctor";
import { resolveMemoryWikiConfig, type MemoryWikiPluginConfig } from "./src/config.js";
import {
resolveMemoryWikiAgentConfig,
resolveMemoryWikiConfig,
resolveMemoryWikiConfiguredAgentIds,
type MemoryWikiPluginConfig,
} from "./src/config.js";
export { legacyConfigRules, normalizeCompatibilityConfig } from "./src/config-compat.js";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
@@ -50,7 +55,17 @@ function resolveConfiguredVaultRoots(params: {
const resolved = resolveMemoryWikiConfig(readConfiguredPluginConfig(params.config), {
homedir: homeDir,
});
return [resolved.vault.path];
if (resolved.vault.scope === "global") {
return [resolved.vault.path];
}
return resolveMemoryWikiConfiguredAgentIds(params.config).map(
(agentId) =>
resolveMemoryWikiAgentConfig({
config: resolved,
appConfig: params.config,
agentId,
}).vault.path,
);
}
async function archiveLegacyImportRunRecords(params: {
+74 -2
View File
@@ -1,9 +1,29 @@
// Memory Wiki tests cover index plugin behavior.
import { describe, expect, it } from "vitest";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "./api.js";
import plugin from "./index.js";
import { createMemoryWikiTestHarness } from "./src/test-helpers.js";
const { createPluginApi } = createMemoryWikiTestHarness();
const toolMocks = vi.hoisted(() => {
const createTool = (name: string) =>
vi.fn((config: unknown, _appConfig?: unknown, memoryContext?: unknown) => ({
name,
testConfig: config,
testMemoryContext: memoryContext,
}));
return {
createWikiApplyTool: createTool("wiki_apply"),
createWikiGetTool: createTool("wiki_get"),
createWikiLintTool: createTool("wiki_lint"),
createWikiSearchTool: createTool("wiki_search"),
createWikiStatusTool: createTool("wiki_status"),
};
});
vi.mock("./src/tool.js", () => toolMocks);
const { createPluginApi, createTempDir } = createMemoryWikiTestHarness();
describe("memory-wiki plugin", () => {
it("registers prompt supplement, gateway methods, tools, and wiki cli surface", () => {
@@ -49,6 +69,13 @@ describe("memory-wiki plugin", () => {
"wiki_search",
"wiki_get",
]);
expect(registerTool.mock.calls.map((call) => typeof call[0])).toEqual([
"function",
"function",
"function",
"function",
"function",
]);
expect(registerCli).toHaveBeenCalledTimes(1);
expect(registerCli.mock.calls[0]?.[1]).toStrictEqual({
descriptors: [
@@ -60,4 +87,49 @@ describe("memory-wiki plugin", () => {
],
});
});
it("resolves every tool factory from the invocation agent", async () => {
const rootDir = await createTempDir("memory-wiki-index-agents-");
const appConfig = {
agents: { list: [{ id: "support", default: true }, { id: "marketing" }] },
} as OpenClawConfig;
const { api, registerTool } = createPluginApi();
api.config = appConfig;
api.pluginConfig = {
vault: { scope: "agent", path: rootDir },
};
Object.assign(api.runtime, {
config: {
current: () => appConfig,
},
});
plugin.register(api);
for (const [factory, registration] of registerTool.mock.calls) {
expect(factory).toEqual(expect.any(Function));
expect(factory({})).toBeNull();
const supportTool = factory({ agentId: "support" });
const marketingTool = factory({ agentId: "marketing" });
expect(supportTool).toMatchObject({
name: registration.name,
testConfig: {
agentId: "support",
vault: { scope: "agent", path: path.join(rootDir, "support") },
},
});
expect(marketingTool).toMatchObject({
name: registration.name,
testConfig: {
agentId: "marketing",
vault: { scope: "agent", path: path.join(rootDir, "marketing") },
},
});
if (registration.name === "wiki_status") {
expect(supportTool).toMatchObject({ testMemoryContext: { agentId: "support" } });
expect(marketingTool).toMatchObject({ testMemoryContext: { agentId: "marketing" } });
}
expect(() => factory({ agentId: "finance" })).toThrow("Unknown memory-wiki agentId: finance");
}
});
});
+77 -19
View File
@@ -1,7 +1,13 @@
// Memory Wiki plugin entrypoint registers its OpenClaw integration.
import { definePluginEntry } from "./api.js";
import { definePluginEntry, type OpenClawConfig } from "./api.js";
import { registerWikiCli } from "./src/cli.js";
import { memoryWikiConfigSchema, resolveMemoryWikiConfig } from "./src/config.js";
import {
memoryWikiConfigSchema,
resolveMemoryWikiAgentConfig,
resolveMemoryWikiConfig,
resolveMemoryWikiConfiguredAgentIds,
type MemoryWikiConfigResolver,
} from "./src/config.js";
import { createWikiCorpusSupplement } from "./src/corpus-supplement.js";
import { registerMemoryWikiGatewayMethods } from "./src/gateway.js";
import {
@@ -28,6 +34,22 @@ export default definePluginEntry({
configSchema: memoryWikiConfigSchema,
register(api) {
const config = resolveMemoryWikiConfig(api.pluginConfig);
const getAppConfig = () =>
(api.runtime.config?.current?.() ?? api.config) as OpenClawConfig | undefined;
const resolveConfig: MemoryWikiConfigResolver = (agentId, appConfig = getAppConfig()) =>
resolveMemoryWikiAgentConfig({ config, appConfig, agentId });
const resolveToolContext = (agentId?: string) => {
const appConfig = getAppConfig();
if (
config.vault.scope === "agent" &&
!agentId &&
resolveMemoryWikiConfiguredAgentIds(appConfig).length > 1
) {
// Context-free tool discovery cannot safely choose one agent's vault.
return null;
}
return { appConfig, config: resolveConfig(agentId, appConfig) };
};
configureMemoryWikiSourceSyncStateStore(
createMemoryWikiSourceSyncStateStore(api.runtime.state.openKeyedStore),
);
@@ -35,35 +57,71 @@ export default definePluginEntry({
createMemoryWikiImportRunStateStore(api.runtime.state.openKeyedStore),
);
api.registerMemoryPromptSupplement(createWikiPromptSectionBuilder(config));
api.registerMemoryCorpusSupplement(
createWikiCorpusSupplement({ config, appConfig: api.config }),
);
registerMemoryWikiGatewayMethods({ api, config, appConfig: api.config });
api.registerTool(createWikiStatusTool(config, api.config), { name: "wiki_status" });
api.registerTool(createWikiLintTool(config, api.config), { name: "wiki_lint" });
api.registerTool(createWikiApplyTool(config, api.config), { name: "wiki_apply" });
api.registerMemoryPromptSupplement(createWikiPromptSectionBuilder({ config, resolveConfig }));
api.registerMemoryCorpusSupplement(createWikiCorpusSupplement({ resolveConfig, getAppConfig }));
registerMemoryWikiGatewayMethods({
api,
config,
appConfig: api.config,
getAppConfig,
resolveConfig,
});
api.registerTool(
(ctx) =>
createWikiSearchTool(config, api.config, {
agentId: ctx.agentId,
(ctx) => {
const resolved = resolveToolContext(ctx.agentId);
return resolved
? createWikiStatusTool(resolved.config, resolved.appConfig, {
agentId: resolved.config.agentId ?? ctx.agentId,
})
: null;
},
{ name: "wiki_status" },
);
api.registerTool(
(ctx) => {
const resolved = resolveToolContext(ctx.agentId);
return resolved ? createWikiLintTool(resolved.config, resolved.appConfig) : null;
},
{ name: "wiki_lint" },
);
api.registerTool(
(ctx) => {
const resolved = resolveToolContext(ctx.agentId);
return resolved ? createWikiApplyTool(resolved.config, resolved.appConfig) : null;
},
{ name: "wiki_apply" },
);
api.registerTool(
(ctx) => {
const resolved = resolveToolContext(ctx.agentId);
if (!resolved) {
return null;
}
return createWikiSearchTool(resolved.config, resolved.appConfig, {
agentId: resolved.config.agentId ?? ctx.agentId,
agentSessionKey: ctx.sessionKey,
sandboxed: ctx.sandboxed,
}),
});
},
{ name: "wiki_search" },
);
api.registerTool(
(ctx) =>
createWikiGetTool(config, api.config, {
agentId: ctx.agentId,
(ctx) => {
const resolved = resolveToolContext(ctx.agentId);
if (!resolved) {
return null;
}
return createWikiGetTool(resolved.config, resolved.appConfig, {
agentId: resolved.config.agentId ?? ctx.agentId,
agentSessionKey: ctx.sessionKey,
sandboxed: ctx.sandboxed,
}),
});
},
{ name: "wiki_get" },
);
api.registerCli(
({ program }) => {
registerWikiCli(program, config, api.config);
registerWikiCli(program, { config, resolveConfig, getAppConfig });
},
{
descriptors: [
+52 -1
View File
@@ -16,7 +16,11 @@
},
"vault.path": {
"label": "Vault Path",
"help": "Filesystem path for the wiki vault root."
"help": "Exact vault path in global scope, or the parent directory for per-agent vaults."
},
"vault.scope": {
"label": "Vault Scope",
"help": "Use one global vault or a separate child vault for each agent."
},
"vault.renderMode": {
"label": "Render Mode",
@@ -46,6 +50,49 @@
"configSchema": {
"type": "object",
"additionalProperties": false,
"allOf": [
{
"not": {
"required": ["vaultMode", "vault"],
"properties": {
"vaultMode": {
"const": "unsafe-local"
},
"vault": {
"required": ["scope"],
"properties": {
"scope": {
"const": "agent"
}
}
}
}
}
},
{
"not": {
"required": ["vault", "obsidian"],
"properties": {
"vault": {
"required": ["scope"],
"properties": {
"scope": {
"const": "agent"
}
}
},
"obsidian": {
"required": ["useOfficialCli"],
"properties": {
"useOfficialCli": {
"const": true
}
}
}
}
}
}
],
"properties": {
"vaultMode": {
"type": "string",
@@ -55,6 +102,10 @@
"type": "object",
"additionalProperties": false,
"properties": {
"scope": {
"type": "string",
"enum": ["global", "agent"]
},
"path": {
"type": "string"
},
@@ -0,0 +1,265 @@
// Memory Wiki tests cover agent-scoped vault isolation through the public tools.
import fs from "node:fs/promises";
import path from "node:path";
import {
clearMemoryPluginState,
registerMemoryCorpusSupplement,
} from "openclaw/plugin-sdk/memory-host-core";
import type { AnyAgentTool, OpenClawPluginToolFactory } from "openclaw/plugin-sdk/plugin-entry";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { describe, expect, it } from "vitest";
import memoryCorePlugin from "../../memory-core/index.js";
import type { OpenClawConfig } from "../api.js";
import {
resolveMemoryWikiAgentConfig,
resolveMemoryWikiConfig,
type ResolvedMemoryWikiConfig,
} from "./config.js";
import { createWikiCorpusSupplement } from "./corpus-supplement.js";
import { createMemoryWikiTestHarness } from "./test-helpers.js";
import { createWikiApplyTool, createWikiGetTool, createWikiSearchTool } from "./tool.js";
const { createTempDir } = createMemoryWikiTestHarness();
function asRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Expected tool details object");
}
return value as Record<string, unknown>;
}
function textContent(result: { content: Array<{ type: string; text?: string }> }): string {
return result.content.find((part) => part.type === "text")?.text ?? "";
}
function registerMemoryCoreToolFactories(
appConfig: OpenClawConfig,
): Map<string, OpenClawPluginToolFactory> {
const factories = new Map<string, OpenClawPluginToolFactory>();
memoryCorePlugin.register(
createTestPluginApi({
id: "memory-core",
config: appConfig,
registerTool(tool, options) {
if (typeof tool !== "function") {
return;
}
for (const name of options?.names ?? []) {
factories.set(name, tool);
}
},
}),
);
return factories;
}
function createMemoryCoreTool(params: {
factories: Map<string, OpenClawPluginToolFactory>;
name: "memory_search" | "memory_get";
appConfig: OpenClawConfig;
agentId: string;
}): AnyAgentTool {
const factory = params.factories.get(params.name);
if (!factory) {
throw new Error(`Expected memory-core to register ${params.name}`);
}
const tool = factory({
config: params.appConfig,
runtimeConfig: params.appConfig,
getRuntimeConfig: () => params.appConfig,
agentId: params.agentId,
sessionKey: `agent:${params.agentId}:main`,
});
if (!tool || Array.isArray(tool)) {
throw new Error(`Expected one ${params.name} tool`);
}
return tool;
}
describe("agent-scoped memory-wiki tools", () => {
it("keeps apply, search, and get behavior isolated by configured agent", async () => {
const vaultParent = await createTempDir("memory-wiki-agent-vaults-");
const appConfig = {
agents: {
list: [{ id: "support", default: true }, { id: "marketing" }],
},
} as OpenClawConfig;
const baseConfig = resolveMemoryWikiConfig({
vault: { scope: "agent", path: vaultParent },
search: { backend: "local", corpus: "wiki" },
});
const agents: Array<{
id: string;
title: string;
sentinel: string;
config: ResolvedMemoryWikiConfig;
pagePath?: string;
}> = [
{
id: "support",
title: "Support Private Synthesis",
sentinel: "SUPPORT_ONLY_7f3c21",
config: resolveMemoryWikiAgentConfig({
config: baseConfig,
appConfig,
agentId: "support",
}),
},
{
id: "marketing",
title: "Marketing Private Synthesis",
sentinel: "MARKETING_ONLY_8e4d62",
config: resolveMemoryWikiAgentConfig({
config: baseConfig,
appConfig,
agentId: "marketing",
}),
},
];
for (const agent of agents) {
const result = await createWikiApplyTool(agent.config, appConfig).execute(
`apply-${agent.id}`,
{
op: "create_synthesis",
title: agent.title,
body: `Private synthesis marker: ${agent.sentinel}`,
sourceIds: [`source.${agent.id}`],
},
);
const pagePath = asRecord(result.details).pagePath;
if (typeof pagePath !== "string") {
throw new Error("Expected wiki_apply to return pagePath");
}
agent.pagePath = pagePath;
}
expect(agents[0]?.config.vault.path).toBe(path.join(vaultParent, "support"));
expect(agents[1]?.config.vault.path).toBe(path.join(vaultParent, "marketing"));
expect(agents[0]?.config.vault.path).not.toBe(agents[1]?.config.vault.path);
for (const agent of agents) {
const foreignAgent = agents.find((candidate) => candidate.id !== agent.id);
if (!agent.pagePath || !foreignAgent?.pagePath) {
throw new Error("Expected both agent synthesis paths");
}
expect((await fs.stat(agent.config.vault.path)).isDirectory()).toBe(true);
await expect(
fs.readFile(path.join(agent.config.vault.path, agent.pagePath), "utf8"),
).resolves.toContain(agent.sentinel);
await expect(
fs.access(path.join(agent.config.vault.path, foreignAgent.pagePath)),
).rejects.toThrow();
const searchTool = createWikiSearchTool(agent.config, appConfig, {
agentId: agent.id,
});
const ownSearch = await searchTool.execute(`search-own-${agent.id}`, {
query: agent.sentinel,
});
expect(textContent(ownSearch)).toContain(agent.sentinel);
expect(asRecord(ownSearch.details).results).toEqual([
expect.objectContaining({ path: agent.pagePath }),
]);
const foreignSearch = await searchTool.execute(`search-foreign-${agent.id}`, {
query: foreignAgent.sentinel,
});
expect(textContent(foreignSearch)).toBe("No wiki or memory results.");
expect(asRecord(foreignSearch.details).results).toEqual([]);
const getTool = createWikiGetTool(agent.config, appConfig, { agentId: agent.id });
const ownGet = await getTool.execute(`get-own-${agent.id}`, { lookup: agent.pagePath });
expect(textContent(ownGet)).toContain(agent.sentinel);
expect(asRecord(ownGet.details).found).toBe(true);
const foreignGet = await getTool.execute(`get-foreign-${agent.id}`, {
lookup: foreignAgent.pagePath,
});
expect(textContent(foreignGet)).toBe(`Wiki page not found: ${foreignAgent.pagePath}`);
expect(asRecord(foreignGet.details).found).toBe(false);
}
clearMemoryPluginState();
try {
registerMemoryCorpusSupplement(
"memory-wiki",
createWikiCorpusSupplement({
resolveConfig: (agentId, currentAppConfig) =>
resolveMemoryWikiAgentConfig({
config: baseConfig,
appConfig: currentAppConfig,
agentId,
}),
getAppConfig: () => appConfig,
}),
);
const memoryCoreFactories = registerMemoryCoreToolFactories(appConfig);
for (const agent of agents) {
const foreignAgent = agents.find((candidate) => candidate.id !== agent.id);
if (!agent.pagePath || !foreignAgent?.pagePath) {
throw new Error("Expected both agent synthesis paths");
}
const memorySearch = createMemoryCoreTool({
factories: memoryCoreFactories,
name: "memory_search",
appConfig,
agentId: agent.id,
});
const ownMemorySearch = await memorySearch.execute(`memory-search-own-${agent.id}`, {
query: agent.sentinel,
corpus: "wiki",
});
expect(asRecord(ownMemorySearch.details).results).toEqual([
expect.objectContaining({
corpus: "wiki",
path: agent.pagePath,
snippet: expect.stringContaining(agent.sentinel),
}),
]);
const foreignMemorySearch = await memorySearch.execute(
`memory-search-foreign-${agent.id}`,
{
query: foreignAgent.sentinel,
corpus: "wiki",
},
);
expect(asRecord(foreignMemorySearch.details).results).toEqual([]);
const memoryGet = createMemoryCoreTool({
factories: memoryCoreFactories,
name: "memory_get",
appConfig,
agentId: agent.id,
});
const ownMemoryGet = await memoryGet.execute(`memory-get-own-${agent.id}`, {
path: agent.pagePath,
corpus: "wiki",
});
expect(asRecord(ownMemoryGet.details)).toMatchObject({
corpus: "wiki",
path: agent.pagePath,
text: expect.stringContaining(agent.sentinel),
});
const foreignMemoryGet = await memoryGet.execute(`memory-get-foreign-${agent.id}`, {
path: foreignAgent.pagePath,
corpus: "wiki",
});
expect(asRecord(foreignMemoryGet.details)).toMatchObject({
path: foreignAgent.pagePath,
text: "",
disabled: true,
error: "wiki corpus result not found",
});
}
} finally {
clearMemoryPluginState();
}
});
});
+114
View File
@@ -262,6 +262,120 @@ describe("syncMemoryWikiBridgeSources", () => {
expect(page).toContain("- Agents: unknown");
});
it("isolates agent-scoped bridge artifacts while preserving shared ownership", async () => {
const supportWorkspace = await createBridgeWorkspace("support-workspace");
const marketingWorkspace = await createBridgeWorkspace("marketing-workspace");
const sharedWorkspace = await createBridgeWorkspace("shared-workspace");
const unknownWorkspace = await createBridgeWorkspace("unknown-workspace");
const supportMemory = path.join(supportWorkspace, "MEMORY.md");
const marketingMemory = path.join(marketingWorkspace, "MEMORY.md");
const sharedMemory = path.join(sharedWorkspace, "MEMORY.md");
const unknownMemory = path.join(unknownWorkspace, "MEMORY.md");
await fs.writeFile(supportMemory, "# Support Sentinel\n", "utf8");
await fs.writeFile(marketingMemory, "# Marketing Sentinel\n", "utf8");
await fs.writeFile(sharedMemory, "# Shared Sentinel\n", "utf8");
await fs.writeFile(unknownMemory, "# Unknown Sentinel\n", "utf8");
registerBridgeArtifacts([
{
kind: "memory-root",
workspaceDir: supportWorkspace,
relativePath: "MEMORY.md",
absolutePath: supportMemory,
agentIds: [" SUPPORT "],
contentType: "markdown",
},
{
kind: "memory-root",
workspaceDir: marketingWorkspace,
relativePath: "MEMORY.md",
absolutePath: marketingMemory,
agentIds: ["marketing"],
contentType: "markdown",
},
{
kind: "memory-root",
workspaceDir: sharedWorkspace,
relativePath: "MEMORY.md",
absolutePath: sharedMemory,
agentIds: ["support", "MARKETING"],
contentType: "markdown",
},
{
kind: "memory-root",
workspaceDir: unknownWorkspace,
relativePath: "MEMORY.md",
absolutePath: unknownMemory,
contentType: "markdown",
} as Omit<MemoryPluginPublicArtifact, "agentIds"> as MemoryPluginPublicArtifact,
]);
const { rootDir: supportVault, config: unresolvedSupportConfig } = await createVault({
rootDir: nextCaseRoot("support-vault"),
config: {
vaultMode: "bridge",
vault: { scope: "agent" },
bridge: { enabled: true, indexMemoryRoot: true },
},
});
const { rootDir: marketingVault, config: unresolvedMarketingConfig } = await createVault({
rootDir: nextCaseRoot("marketing-vault"),
config: {
vaultMode: "bridge",
vault: { scope: "agent" },
bridge: { enabled: true, indexMemoryRoot: true },
},
});
const supportConfig = { ...unresolvedSupportConfig, agentId: "support" };
const marketingConfig = { ...unresolvedMarketingConfig, agentId: "marketing" };
const appConfig: OpenClawConfig = {
agents: {
list: [
{ id: "support", default: true, workspace: supportWorkspace },
{ id: "marketing", workspace: marketingWorkspace },
],
},
};
const supportResult = await syncMemoryWikiBridgeSources({ config: supportConfig, appConfig });
const marketingResult = await syncMemoryWikiBridgeSources({
config: marketingConfig,
appConfig,
});
expect(supportResult).toMatchObject({ artifactCount: 2, importedCount: 2, workspaces: 2 });
expect(marketingResult).toMatchObject({ artifactCount: 2, importedCount: 2, workspaces: 2 });
const supportPages = await Promise.all(
supportResult.pagePaths.map((pagePath) =>
fs.readFile(path.join(supportVault, pagePath), "utf8"),
),
);
const marketingPages = await Promise.all(
marketingResult.pagePaths.map((pagePath) =>
fs.readFile(path.join(marketingVault, pagePath), "utf8"),
),
);
expect(supportPages.join("\n")).toContain("Support Sentinel");
expect(supportPages.join("\n")).toContain("Shared Sentinel");
expect(supportPages.join("\n")).not.toContain("Marketing Sentinel");
expect(supportPages.join("\n")).not.toContain("Unknown Sentinel");
expect(marketingPages.join("\n")).toContain("Marketing Sentinel");
expect(marketingPages.join("\n")).toContain("Shared Sentinel");
expect(marketingPages.join("\n")).not.toContain("Support Sentinel");
expect(marketingPages.join("\n")).not.toContain("Unknown Sentinel");
});
it("rejects an unresolved agent-scoped bridge config", async () => {
const { config } = await createVault({
rootDir: nextCaseRoot("unresolved-agent-vault"),
config: { vault: { scope: "agent" } },
});
await expect(syncMemoryWikiBridgeSources({ config })).rejects.toThrow(
"Memory Wiki agent-scoped vault requires a resolved agent id",
);
});
it("returns a no-op result outside bridge mode", async () => {
const { config } = await createVault({ rootDir: nextCaseRoot("isolated") });
+47 -1
View File
@@ -7,6 +7,7 @@ import {
listActiveMemoryPublicArtifacts,
type MemoryPluginPublicArtifact,
} from "openclaw/plugin-sdk/memory-host-core";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import type { OpenClawConfig } from "../api.js";
import type { ResolvedMemoryWikiConfig } from "./config.js";
import { appendMemoryWikiLog } from "./log.js";
@@ -44,6 +45,45 @@ export type BridgeMemoryWikiResult = {
pagePaths: string[];
};
export function resolveMemoryWikiVaultAgentId(
config: Pick<ResolvedMemoryWikiConfig, "agentId" | "vault">,
): string | null {
if (config.vault.scope === "global") {
return null;
}
const agentId = config.agentId?.trim();
if (!agentId) {
throw new Error("Memory Wiki agent-scoped vault requires a resolved agent id");
}
return normalizeAgentId(agentId);
}
export function filterMemoryWikiBridgeArtifacts(params: {
config: Pick<ResolvedMemoryWikiConfig, "agentId" | "vault">;
artifacts: MemoryPluginPublicArtifact[];
callerAgentId?: string;
}): MemoryPluginPublicArtifact[] {
const vaultAgentId = resolveMemoryWikiVaultAgentId(params.config);
const callerAgentId = params.callerAgentId?.trim();
// Agent-scoped vault ownership is authoritative. Global vaults remain shared,
// but agent tools still scope diagnostic metadata to their calling agent.
const agentId = vaultAgentId ?? (callerAgentId ? normalizeAgentId(callerAgentId) : null);
if (!agentId) {
return params.artifacts;
}
// Ownership metadata is mandatory only in agent scope. Global scope keeps
// accepting legacy providers that omit agentIds.
return params.artifacts.filter((artifact) => {
const artifactAgentIds = Array.isArray(artifact.agentIds) ? artifact.agentIds : [];
return artifactAgentIds.some(
(artifactAgentId) =>
typeof artifactAgentId === "string" &&
artifactAgentId.trim().length > 0 &&
normalizeAgentId(artifactAgentId) === agentId,
);
});
}
function shouldImportArtifact(
artifact: MemoryPluginPublicArtifact,
bridgeConfig: ResolvedMemoryWikiConfig["bridge"],
@@ -219,6 +259,7 @@ export async function syncMemoryWikiBridgeSources(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
}): Promise<BridgeMemoryWikiResult> {
resolveMemoryWikiVaultAgentId(params.config);
await initializeMemoryWikiVault(params.config);
if (
params.config.vaultMode !== "bridge" ||
@@ -237,7 +278,12 @@ export async function syncMemoryWikiBridgeSources(params: {
};
}
const publicArtifacts = await listActiveMemoryPublicArtifacts({ cfg: params.appConfig });
// Filter before building active keys so each vault's pruning state tracks
// only artifacts that are visible to its resolved agent.
const publicArtifacts = filterMemoryWikiBridgeArtifacts({
config: params.config,
artifacts: await listActiveMemoryPublicArtifacts({ cfg: params.appConfig }),
});
const results: Array<{ pagePath: string; changed: boolean; created: boolean }> = [];
const activeKeys = new Set<string>();
const artifacts = await collectBridgeArtifacts(
+97 -8
View File
@@ -112,10 +112,13 @@ describe("memory-wiki cli", () => {
}
function createGatewayStatus(config: {
vault: { path: string };
agentId?: string;
vault: { path: string; scope?: MemoryWikiStatus["vaultScope"] };
bridge: MemoryWikiStatus["bridge"];
}): MemoryWikiStatus {
return {
vaultScope: config.vault.scope ?? "global",
agentId: config.agentId ?? null,
vaultMode: "bridge",
renderMode: "native",
vaultPath: config.vault.path,
@@ -154,7 +157,7 @@ describe("memory-wiki cli", () => {
const { rootDir, config } = await createCliVault();
const program = new Command();
program.name("test");
registerWikiCli(program, config);
registerWikiCli(program, { config });
await program.parseAsync(
[
@@ -180,6 +183,92 @@ describe("memory-wiki cli", () => {
);
});
it("resolves --agent for local commands and requires it with multiple agent vaults", async () => {
const { rootDir, config } = await createCliVault({
config: { vault: { scope: "agent" } },
});
const appConfig = {
agents: { list: [{ id: "support", default: true }, { id: "marketing" }] },
};
const program = new Command();
program.name("test");
program.exitOverride();
registerWikiCli(program, { config, getAppConfig: () => appConfig });
await program.parseAsync(["wiki", "--agent", "marketing", "init", "--json"], {
from: "user",
});
await expect(fs.stat(path.join(rootDir, "marketing", "index.md"))).resolves.toBeDefined();
const missingAgentProgram = new Command();
missingAgentProgram.name("test");
missingAgentProgram.exitOverride();
registerWikiCli(missingAgentProgram, { config, getAppConfig: () => appConfig });
await expect(
missingAgentProgram.parseAsync(["wiki", "status", "--json"], { from: "user" }),
).rejects.toThrow("agentId is required for memory-wiki when vault.scope=agent.");
});
it("forwards --agent through every bridge Gateway call", async () => {
const { config } = await createCliVault({
config: {
vaultMode: "bridge",
vault: { scope: "agent" },
bridge: { enabled: true, readMemoryArtifacts: true },
},
});
const appConfig = {
agents: { list: [{ id: "support", default: true }, { id: "marketing" }] },
};
const status = createGatewayStatus(config);
const report: MemoryWikiDoctorReport = {
healthy: true,
warningCount: 0,
status,
fixes: [],
};
callGatewayFromCliMock
.mockResolvedValueOnce(status)
.mockResolvedValueOnce(report)
.mockResolvedValueOnce({
importedCount: 0,
updatedCount: 0,
skippedCount: 0,
removedCount: 0,
artifactCount: 0,
workspaces: 0,
pagePaths: [],
indexesRefreshed: false,
indexUpdatedFiles: [],
indexRefreshReason: "no-import-changes",
});
const register = () => {
const program = new Command();
program.name("test");
registerWikiCli(program, { config, getAppConfig: () => appConfig });
return program;
};
await register().parseAsync(["wiki", "--agent", "marketing", "status", "--json"], {
from: "user",
});
await register().parseAsync(["wiki", "--agent", "marketing", "doctor", "--json"], {
from: "user",
});
await register().parseAsync(["wiki", "--agent", "marketing", "bridge", "import", "--json"], {
from: "user",
});
expect(callGatewayFromCliMock.mock.calls.map(([method, , params]) => [method, params])).toEqual(
[
["wiki.status", { agentId: "marketing" }],
["wiki.doctor", { agentId: "marketing" }],
["wiki.bridge.import", { agentId: "marketing" }],
],
);
});
it("registers OKF import and searches imported concepts", async () => {
const { rootDir, config } = await createCliVault();
const bundlePath = path.join(rootDir, "okf-bundle");
@@ -211,7 +300,7 @@ Orders join to [customers](/tables/customers.md).
const program = new Command();
program.name("test");
registerWikiCli(program, config);
registerWikiCli(program, { config });
await program.parseAsync(["wiki", "okf", "import", bundlePath, "--json"], { from: "user" });
@@ -248,7 +337,7 @@ Orders join to [customers](/tables/customers.md).
writeErr: () => {},
writeOut: () => {},
});
registerWikiCli(program, config);
registerWikiCli(program, { config });
await expect(
program.parseAsync(
@@ -290,7 +379,7 @@ Orders join to [customers](/tables/customers.md).
writeErr: () => {},
writeOut: () => {},
});
registerWikiCli(program, config);
registerWikiCli(program, { config });
await expect(
program.parseAsync(["wiki", "search", "alpha", "--max-results", "0x10"], {
@@ -312,7 +401,7 @@ Orders join to [customers](/tables/customers.md).
await fs.writeFile(targetPath, "# CLI Lines\n\nfirst\nsecond\n", "utf8");
const program = new Command();
program.name("test");
registerWikiCli(program, config);
registerWikiCli(program, { config });
await program.parseAsync(
["wiki", "get", "syntheses/cli-lines.md", "--from", "+01", "--lines", "02"],
@@ -349,7 +438,7 @@ cli note
const program = new Command();
program.name("test");
registerWikiCli(program, config);
registerWikiCli(program, { config });
await program.parseAsync(
[
@@ -389,7 +478,7 @@ cli note
});
const program = new Command();
program.name("test");
registerWikiCli(program, config);
registerWikiCli(program, { config });
await fs.rm(rootDir, { recursive: true, force: true });
await program.parseAsync(["wiki", "doctor", "--json"], { from: "user" });
+111 -35
View File
@@ -18,10 +18,10 @@ import {
} from "./chatgpt-import.js";
import { compileMemoryWikiVault } from "./compile.js";
import {
resolveMemoryWikiConfig,
resolveMemoryWikiAgentConfig,
WIKI_SEARCH_BACKENDS,
WIKI_SEARCH_CORPORA,
type MemoryWikiPluginConfig,
type MemoryWikiConfigResolver,
type ResolvedMemoryWikiConfig,
} from "./config.js";
import { ingestMemoryWikiSource } from "./ingest.js";
@@ -164,18 +164,15 @@ type WikiObsidianDailyCommandOptions = {
json?: boolean;
};
function isResolvedMemoryWikiConfig(
config: MemoryWikiPluginConfig | ResolvedMemoryWikiConfig | undefined,
): config is ResolvedMemoryWikiConfig {
return Boolean(
config &&
"vaultMode" in config &&
"vault" in config &&
"bridge" in config &&
"obsidian" in config &&
"unsafeLocal" in config,
);
}
type WikiCommandOptions = {
agent?: string;
};
export type MemoryWikiCliRegistration = {
config: ResolvedMemoryWikiConfig;
resolveConfig?: MemoryWikiConfigResolver;
getAppConfig?: () => OpenClawConfig | undefined;
};
function sanitizeGatewayStringForTerminal(value: string): string {
const truncated =
@@ -253,6 +250,9 @@ function isMemoryWikiStatus(value: unknown): value is MemoryWikiStatus {
const pageCounts = value.pageCounts;
const sourceCounts = value.sourceCounts;
return (
isBoundedGatewayString(value.vaultScope, GATEWAY_RESPONSE_MAX_CODE_CHARS) &&
(isBoundedGatewayString(value.agentId, GATEWAY_RESPONSE_MAX_CODE_CHARS) ||
value.agentId === null) &&
isBoundedGatewayString(value.vaultMode, GATEWAY_RESPONSE_MAX_CODE_CHARS) &&
isBoundedGatewayString(value.renderMode, GATEWAY_RESPONSE_MAX_CODE_CHARS) &&
isBoundedGatewayString(value.vaultPath) &&
@@ -328,15 +328,25 @@ function validateWikiGatewayResult(
throw new Error(`Invalid Gateway response for ${method}.`);
}
async function callWikiGateway(method: "wiki.status"): Promise<MemoryWikiStatus>;
async function callWikiGateway(method: "wiki.doctor"): Promise<MemoryWikiDoctorReport>;
async function callWikiGateway(method: "wiki.status", agentId?: string): Promise<MemoryWikiStatus>;
async function callWikiGateway(
method: "wiki.doctor",
agentId?: string,
): Promise<MemoryWikiDoctorReport>;
async function callWikiGateway(
method: "wiki.bridge.import",
agentId?: string,
): Promise<MemoryWikiImportedSourceSyncResult>;
async function callWikiGateway(method: "wiki.status" | "wiki.doctor" | "wiki.bridge.import") {
const result = await callGatewayFromCli(method, { timeout: WIKI_GATEWAY_TIMEOUT_MS }, undefined, {
progress: false,
});
async function callWikiGateway(
method: "wiki.status" | "wiki.doctor" | "wiki.bridge.import",
agentId?: string,
) {
const result = await callGatewayFromCli(
method,
{ timeout: WIKI_GATEWAY_TIMEOUT_MS },
agentId ? { agentId } : undefined,
{ progress: false },
);
return validateWikiGatewayResult(method, result);
}
@@ -476,12 +486,13 @@ function addWikiApplyMutationOptions<T extends Command>(command: T): T {
export async function runWikiStatus(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
agentId?: string;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
const routeThroughGateway = shouldRouteBridgeRuntimeThroughGateway(params.config);
const status = routeThroughGateway
? await callWikiGateway("wiki.status")
? await callWikiGateway("wiki.status", params.agentId)
: await (async () => {
await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig });
return await resolveMemoryWikiStatus(params.config, {
@@ -500,12 +511,13 @@ export async function runWikiStatus(params: {
export async function runWikiDoctor(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
agentId?: string;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
const routeThroughGateway = shouldRouteBridgeRuntimeThroughGateway(params.config);
const report = routeThroughGateway
? await callWikiGateway("wiki.doctor")
? await callWikiGateway("wiki.doctor", params.agentId)
: await (async () => {
await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig });
return buildMemoryWikiDoctorReport(
@@ -616,6 +628,7 @@ export async function runWikiOkfImport(params: {
async function runWikiSearch(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
agentId?: string;
query: string;
maxResults?: number;
searchBackend?: ResolvedMemoryWikiConfig["search"]["backend"];
@@ -631,6 +644,7 @@ async function runWikiSearch(params: {
const results = await searchMemoryWiki({
config: params.config,
appConfig: params.appConfig,
...(params.agentId ? { agentId: params.agentId } : {}),
query: params.query,
maxResults: params.maxResults,
searchBackend: params.searchBackend,
@@ -654,6 +668,7 @@ async function runWikiSearch(params: {
async function runWikiGet(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
agentId?: string;
lookup: string;
fromLine?: number;
lineCount?: number;
@@ -666,6 +681,7 @@ async function runWikiGet(params: {
const result = await getMemoryWikiPage({
config: params.config,
appConfig: params.appConfig,
...(params.agentId ? { agentId: params.agentId } : {}),
lookup: params.lookup,
fromLine: params.fromLine,
lineCount: params.lineCount,
@@ -763,13 +779,14 @@ async function runWikiApplyMetadata(params: {
export async function runWikiBridgeImport(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
agentId?: string;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
const render = (value: MemoryWikiImportedSourceSyncResult) =>
`Bridge import synced ${value.artifactCount} artifacts across ${value.workspaces} workspaces (${value.importedCount} new, ${value.updatedCount} updated, ${value.skippedCount} unchanged, ${value.removedCount} removed). Indexes ${value.indexesRefreshed ? `refreshed (${value.indexUpdatedFiles.length} files)` : `not refreshed (${value.indexRefreshReason})`}.`;
if (shouldRouteBridgeRuntimeThroughGateway(params.config)) {
const result = await callWikiGateway("wiki.bridge.import");
const result = await callWikiGateway("wiki.bridge.import", params.agentId);
writeOutput(formatGatewayJsonOrText(result, params.json, render), params.stdout);
return result;
}
@@ -791,6 +808,9 @@ async function runWikiUnsafeLocalImport(params: {
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
if (params.config.vault.scope === "agent") {
throw new Error("Unsafe-local import does not support memory-wiki vault.scope=agent.");
}
return runWikiCommandWithSummary({
json: params.json,
stdout: params.stdout,
@@ -820,12 +840,19 @@ async function runWikiObsidianStatus(params: {
});
}
function assertOfficialObsidianCliSupported(config: ResolvedMemoryWikiConfig) {
if (config.vault.scope === "agent") {
throw new Error("Official Obsidian CLI actions do not support memory-wiki vault.scope=agent.");
}
}
async function runWikiObsidianSearch(params: {
config: ResolvedMemoryWikiConfig;
query: string;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
assertOfficialObsidianCliSupported(params.config);
return runWikiCommandWithSummary({
json: params.json,
stdout: params.stdout,
@@ -840,6 +867,7 @@ async function runWikiObsidianOpenCli(params: {
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
assertOfficialObsidianCliSupported(params.config);
return runWikiCommandWithSummary({
json: params.json,
stdout: params.stdout,
@@ -854,6 +882,7 @@ async function runWikiObsidianCommandCli(params: {
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
assertOfficialObsidianCliSupported(params.config);
return runWikiCommandWithSummary({
json: params.json,
stdout: params.stdout,
@@ -867,6 +896,7 @@ async function runWikiObsidianDailyCli(params: {
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
assertOfficialObsidianCliSupported(params.config);
return runWikiCommandWithSummary({
json: params.json,
stdout: params.stdout,
@@ -928,22 +958,47 @@ export async function runWikiChatGptRollback(params: {
});
}
export function registerWikiCli(
program: Command,
pluginConfig?: MemoryWikiPluginConfig | ResolvedMemoryWikiConfig,
appConfig?: OpenClawConfig,
) {
const config = isResolvedMemoryWikiConfig(pluginConfig)
? pluginConfig
: resolveMemoryWikiConfig(pluginConfig);
const wiki = program.command("wiki").description("Inspect and initialize the memory wiki vault");
export function registerWikiCli(program: Command, registration: MemoryWikiCliRegistration) {
const resolveConfig: MemoryWikiConfigResolver =
registration.resolveConfig ??
((agentId, currentAppConfig) =>
resolveMemoryWikiAgentConfig({
config: registration.config,
appConfig: currentAppConfig,
...(agentId ? { agentId } : {}),
}));
let commandContext:
| { agentId?: string; appConfig?: OpenClawConfig; config: ResolvedMemoryWikiConfig }
| undefined;
const requireCommandContext = () => {
if (!commandContext) {
throw new Error("Memory Wiki CLI agent context was not resolved.");
}
return commandContext;
};
const wiki = program
.command("wiki")
.description("Inspect and initialize the memory wiki vault")
.option("--agent <id>", "Agent id for agent-scoped wiki vaults");
wiki.hook("preAction", () => {
const requestedAgentId = wiki.opts<WikiCommandOptions>().agent?.trim() || undefined;
const currentAppConfig = registration.getAppConfig?.();
const config = resolveConfig(requestedAgentId, currentAppConfig);
const agentId = config.agentId ?? requestedAgentId;
commandContext = {
config,
...(currentAppConfig ? { appConfig: currentAppConfig } : {}),
...(agentId ? { agentId } : {}),
};
});
wiki
.command("status")
.description("Show wiki vault status")
.option("--json", "Print JSON")
.action(async (opts: WikiStatusCommandOptions) => {
await runWikiStatus({ config, appConfig, json: opts.json });
const { agentId, appConfig, config } = requireCommandContext();
await runWikiStatus({ config, appConfig, agentId, json: opts.json });
});
wiki
@@ -951,7 +1006,8 @@ export function registerWikiCli(
.description("Audit wiki vault setup and report actionable fixes")
.option("--json", "Print JSON")
.action(async (opts: WikiDoctorCommandOptions) => {
await runWikiDoctor({ config, appConfig, json: opts.json });
const { agentId, appConfig, config } = requireCommandContext();
await runWikiDoctor({ config, appConfig, agentId, json: opts.json });
});
wiki
@@ -959,6 +1015,7 @@ export function registerWikiCli(
.description("Initialize the wiki vault layout")
.option("--json", "Print JSON")
.action(async (opts: WikiInitCommandOptions) => {
const { config } = requireCommandContext();
await runWikiInit({ config, json: opts.json });
});
@@ -967,6 +1024,7 @@ export function registerWikiCli(
.description("Refresh generated wiki indexes")
.option("--json", "Print JSON")
.action(async (opts: WikiCompileCommandOptions) => {
const { appConfig, config } = requireCommandContext();
await runWikiCompile({ config, appConfig, json: opts.json });
});
@@ -975,6 +1033,7 @@ export function registerWikiCli(
.description("Lint the wiki vault and write a report")
.option("--json", "Print JSON")
.action(async (opts: WikiLintCommandOptions) => {
const { appConfig, config } = requireCommandContext();
await runWikiLint({ config, appConfig, json: opts.json });
});
@@ -985,6 +1044,7 @@ export function registerWikiCli(
.option("--title <title>", "Override the source title")
.option("--json", "Print JSON")
.action(async (inputPath: string, opts: WikiIngestCommandOptions) => {
const { config } = requireCommandContext();
await runWikiIngest({ config, inputPath, title: opts.title, json: opts.json });
});
@@ -995,6 +1055,7 @@ export function registerWikiCli(
.argument("<path>", "OKF bundle directory")
.option("--json", "Print JSON")
.action(async (bundlePath: string, opts: WikiOkfImportCommandOptions) => {
const { config } = requireCommandContext();
await runWikiOkfImport({ config, bundlePath, json: opts.json });
});
@@ -1010,9 +1071,11 @@ export function registerWikiCli(
)
.option("--json", "Print JSON")
.action(async (query: string, opts: WikiSearchCommandOptions) => {
const { agentId, appConfig, config } = requireCommandContext();
await runWikiSearch({
config,
appConfig,
agentId,
query,
maxResults: opts.maxResults,
searchBackend: opts.backend,
@@ -1036,9 +1099,11 @@ export function registerWikiCli(
)
.option("--json", "Print JSON")
.action(async (lookup: string, opts: WikiGetCommandOptions) => {
const { agentId, appConfig, config } = requireCommandContext();
await runWikiGet({
config,
appConfig,
agentId,
lookup,
fromLine: opts.from,
lineCount: opts.lines,
@@ -1059,6 +1124,7 @@ export function registerWikiCli(
)
.option("--json", "Print JSON")
.action(async (title: string, opts: WikiApplySynthesisCommandOptions) => {
const { appConfig, config } = requireCommandContext();
await runWikiApplySynthesis({
config,
appConfig,
@@ -1082,6 +1148,7 @@ export function registerWikiCli(
.option("--clear-confidence", "Remove any stored confidence value")
.option("--json", "Print JSON")
.action(async (lookup: string, opts: WikiApplyMetadataCommandOptions) => {
const { appConfig, config } = requireCommandContext();
await runWikiApplyMetadata({
config,
appConfig,
@@ -1104,7 +1171,8 @@ export function registerWikiCli(
.description("Sync bridge-backed memory artifacts into wiki source pages")
.option("--json", "Print JSON")
.action(async (opts: WikiBridgeImportCommandOptions) => {
await runWikiBridgeImport({ config, appConfig, json: opts.json });
const { agentId, appConfig, config } = requireCommandContext();
await runWikiBridgeImport({ config, appConfig, agentId, json: opts.json });
});
const unsafeLocal = wiki
@@ -1115,6 +1183,7 @@ export function registerWikiCli(
.description("Sync unsafe-local configured paths into wiki source pages")
.option("--json", "Print JSON")
.action(async (opts: WikiUnsafeLocalImportCommandOptions) => {
const { appConfig, config } = requireCommandContext();
await runWikiUnsafeLocalImport({ config, appConfig, json: opts.json });
});
@@ -1128,6 +1197,7 @@ export function registerWikiCli(
.option("--dry-run", "Preview changes without writing", false)
.option("--json", "Print JSON")
.action(async (opts: WikiChatGptImportCommandOptions) => {
const { config } = requireCommandContext();
await runWikiChatGptImport({
config,
exportPath: opts.export!,
@@ -1141,6 +1211,7 @@ export function registerWikiCli(
.argument("<run-id>", "Import run id")
.option("--json", "Print JSON")
.action(async (runId: string, opts: WikiChatGptRollbackCommandOptions) => {
const { config } = requireCommandContext();
await runWikiChatGptRollback({
config,
runId,
@@ -1154,6 +1225,7 @@ export function registerWikiCli(
.description("Probe the Obsidian CLI")
.option("--json", "Print JSON")
.action(async (opts: WikiStatusCommandOptions) => {
const { config } = requireCommandContext();
await runWikiObsidianStatus({ config, json: opts.json });
});
obsidian
@@ -1162,6 +1234,7 @@ export function registerWikiCli(
.argument("<query>", "Search query")
.option("--json", "Print JSON")
.action(async (query: string, opts: WikiObsidianSearchCommandOptions) => {
const { config } = requireCommandContext();
await runWikiObsidianSearch({ config, query, json: opts.json });
});
obsidian
@@ -1170,6 +1243,7 @@ export function registerWikiCli(
.argument("<path>", "Vault-relative path")
.option("--json", "Print JSON")
.action(async (vaultPath: string, opts: WikiObsidianOpenCommandOptions) => {
const { config } = requireCommandContext();
await runWikiObsidianOpenCli({ config, vaultPath, json: opts.json });
});
obsidian
@@ -1178,6 +1252,7 @@ export function registerWikiCli(
.argument("<id>", "Obsidian command id")
.option("--json", "Print JSON")
.action(async (id: string, opts: WikiObsidianCommandCommandOptions) => {
const { config } = requireCommandContext();
await runWikiObsidianCommandCli({ config, id, json: opts.json });
});
obsidian
@@ -1185,6 +1260,7 @@ export function registerWikiCli(
.description("Open today's daily note in Obsidian")
.option("--json", "Print JSON")
.action(async (opts: WikiObsidianDailyCommandOptions) => {
const { config } = requireCommandContext();
await runWikiObsidianDailyCli({ config, json: opts.json });
});
}
+140
View File
@@ -6,12 +6,17 @@ import {
type JsonSchemaObject,
} from "openclaw/plugin-sdk/json-schema-runtime";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../api.js";
import {
DEFAULT_WIKI_RENDER_MODE,
DEFAULT_WIKI_SEARCH_BACKEND,
DEFAULT_WIKI_SEARCH_CORPUS,
DEFAULT_WIKI_VAULT_MODE,
DEFAULT_WIKI_VAULT_SCOPE,
memoryWikiConfigSchema,
resolveDefaultMemoryWikiVaultPath,
resolveDefaultMemoryWikiVaultRoot,
resolveMemoryWikiAgentConfig,
resolveMemoryWikiConfig,
} from "./config.js";
@@ -33,6 +38,7 @@ describe("resolveMemoryWikiConfig", () => {
const config = resolveMemoryWikiConfig(undefined, { homedir: "/Users/tester" });
expect(config.vaultMode).toBe(DEFAULT_WIKI_VAULT_MODE);
expect(config.vault.scope).toBe(DEFAULT_WIKI_VAULT_SCOPE);
expect(config.vault.renderMode).toBe(DEFAULT_WIKI_RENDER_MODE);
expect(config.vault.path).toBe(resolveDefaultMemoryWikiVaultPath("/Users/tester"));
expect(config.search.backend).toBe(DEFAULT_WIKI_SEARCH_BACKEND);
@@ -66,6 +72,118 @@ describe("resolveMemoryWikiConfig", () => {
expect(canonical.bridge.readMemoryArtifacts).toBe(false);
});
it("resolves normalized agent ids to distinct vault roots", () => {
const base = resolveMemoryWikiConfig(
{
vault: {
scope: "agent",
path: "~/vaults/wiki",
},
},
{ homedir: "/Users/tester" },
);
const appConfig = {
agents: {
list: [{ id: "Support Team", default: true }, { id: "Marketing" }],
},
} as OpenClawConfig;
const support = resolveMemoryWikiAgentConfig({
config: base,
appConfig,
agentId: " SUPPORT TEAM ",
});
const marketing = resolveMemoryWikiAgentConfig({
config: base,
appConfig,
agentId: "MARKETING",
});
expect(base.vault.path).toBe(path.join("/Users/tester", "vaults", "wiki"));
expect(support).toMatchObject({
agentId: "support-team",
vault: { scope: "agent", path: path.join(base.vault.path, "support-team") },
});
expect(marketing).toMatchObject({
agentId: "marketing",
vault: { scope: "agent", path: path.join(base.vault.path, "marketing") },
});
expect(support.vault.path).not.toBe(marketing.vault.path);
});
it("uses the wiki root before appending the single configured agent", () => {
const base = resolveMemoryWikiConfig(
{ vault: { scope: "agent" } },
{ homedir: "/Users/tester" },
);
const resolved = resolveMemoryWikiAgentConfig({
config: base,
appConfig: { agents: { list: [{ id: "support", default: true }] } },
});
expect(base.vault.path).toBe(resolveDefaultMemoryWikiVaultRoot("/Users/tester"));
expect(resolved.vault.path).toBe(
path.join(resolveDefaultMemoryWikiVaultRoot("/Users/tester"), "support"),
);
});
it("fails closed when a multi-agent scoped vault has no agent context", () => {
const config = resolveMemoryWikiConfig({ vault: { scope: "agent" } });
const appConfig = {
agents: { list: [{ id: "support", default: true }, { id: "marketing" }] },
} as OpenClawConfig;
expect(() => resolveMemoryWikiAgentConfig({ config, appConfig })).toThrow(
"agentId is required",
);
});
it("fails closed for unknown scoped agents", () => {
const config = resolveMemoryWikiConfig({ vault: { scope: "agent" } });
const appConfig = {
agents: { list: [{ id: "support", default: true }, { id: "marketing" }] },
} as OpenClawConfig;
expect(() => resolveMemoryWikiAgentConfig({ config, appConfig, agentId: "finance" })).toThrow(
"Unknown memory-wiki agentId: finance",
);
});
it("rejects unsafe-local access for agent-scoped vaults", () => {
const parsed = memoryWikiConfigSchema.safeParse?.({
vaultMode: "unsafe-local",
vault: { scope: "agent" },
});
expect(parsed?.success).toBe(false);
if (parsed?.success === false) {
expect(parsed.error?.issues).toContainEqual(
expect.objectContaining({
path: ["vaultMode"],
message: "vaultMode=unsafe-local cannot be combined with vault.scope=agent",
}),
);
}
});
it("rejects the global Obsidian CLI selector for agent-scoped vaults", () => {
const parsed = memoryWikiConfigSchema.safeParse?.({
vault: { scope: "agent" },
obsidian: { useOfficialCli: true },
});
expect(parsed?.success).toBe(false);
if (parsed?.success === false) {
expect(parsed.error?.issues).toContainEqual(
expect.objectContaining({
path: ["obsidian", "useOfficialCli"],
message: "obsidian.useOfficialCli cannot be enabled with vault.scope=agent",
}),
);
}
});
});
describe("memory-wiki manifest config schema", () => {
@@ -101,4 +219,26 @@ describe("memory-wiki manifest config schema", () => {
expect(validate(config)).toBe(true);
});
it("rejects unsafe-local access for agent-scoped vaults", () => {
const validate = compileManifestConfigSchema();
expect(
validate({
vaultMode: "unsafe-local",
vault: { scope: "agent" },
}),
).toBe(false);
});
it("rejects the global Obsidian CLI selector for agent-scoped vaults", () => {
const validate = compileManifestConfigSchema();
expect(
validate({
vault: { scope: "agent" },
obsidian: { useOfficialCli: true },
}),
).toBe(false);
});
});

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