mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
Merge remote-tracking branch 'origin/main' into jesse/non-clawhub-untrusted-installs
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1212
-580
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
+81
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: "Couldn’t 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 didn’t 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 didn’t 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 didn’t 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
@@ -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.*`
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
@@ -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" }],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } } });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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") });
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user