mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-16 19:18:54 +08:00
Compare commits
95 Commits
feeds-life
...
codex/tele
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35dedb8e40 | ||
|
|
21e3cfa5e9 | ||
|
|
c8e70708e9 | ||
|
|
0bb415bf66 | ||
|
|
00c58b6613 | ||
|
|
50dbd46aa6 | ||
|
|
baeedaa316 | ||
|
|
0f71a665ed | ||
|
|
bcd1fdb1db | ||
|
|
1b5ac45e60 | ||
|
|
3d3d291902 | ||
|
|
57bad4bdf8 | ||
|
|
923828ccd7 | ||
|
|
7305d9baca | ||
|
|
212fef8703 | ||
|
|
7a008c53f4 | ||
|
|
93fa065fb3 | ||
|
|
bdc3017c1a | ||
|
|
6cb631afc9 | ||
|
|
fec6a0407f | ||
|
|
43cef3a80c | ||
|
|
e32929e12c | ||
|
|
f018945eff | ||
|
|
8b0eac7927 | ||
|
|
efa9a6110b | ||
|
|
32af1c0697 | ||
|
|
b3128ba93d | ||
|
|
eb67ac5cbe | ||
|
|
ef6b7e3659 | ||
|
|
a355825060 | ||
|
|
2cc25aa909 | ||
|
|
2758140607 | ||
|
|
55d1324c7d | ||
|
|
dc573a38dc | ||
|
|
9dbe25e0f6 | ||
|
|
e3ca10438a | ||
|
|
68cbd1ae63 | ||
|
|
0ea08076c3 | ||
|
|
04d8a96b6c | ||
|
|
5c2487dc9a | ||
|
|
c40db057da | ||
|
|
df521a6459 | ||
|
|
a0b16f37e8 | ||
|
|
cc000e0ffe | ||
|
|
9092578d8d | ||
|
|
a23de348b2 | ||
|
|
7650397a22 | ||
|
|
5a2641fc41 | ||
|
|
377f6181a9 | ||
|
|
b896e22e10 | ||
|
|
1ffda5d3ca | ||
|
|
ec2788cf80 | ||
|
|
379de52b59 | ||
|
|
0944045b75 | ||
|
|
c932bf377b | ||
|
|
ddb07fc597 | ||
|
|
caab343461 | ||
|
|
8e55348ff9 | ||
|
|
8e56cf591d | ||
|
|
40f1190c8b | ||
|
|
8ded756284 | ||
|
|
f00de6b06a | ||
|
|
158d3db85a | ||
|
|
6cd6e3f39e | ||
|
|
4bb4252042 | ||
|
|
95da644f6d | ||
|
|
6451550cd7 | ||
|
|
10a4c7c10b | ||
|
|
fa9b8c8c6b | ||
|
|
f5a9456219 | ||
|
|
7208567382 | ||
|
|
b90f7d2fd0 | ||
|
|
1e2363b687 | ||
|
|
c082a3b740 | ||
|
|
53541b2141 | ||
|
|
a3070e3ddf | ||
|
|
865e4db1cd | ||
|
|
31a10ee388 | ||
|
|
a34ddce9a2 | ||
|
|
f6a3ac7e58 | ||
|
|
95772c8541 | ||
|
|
85a635368e | ||
|
|
0888debcd3 | ||
|
|
794bd89fa0 | ||
|
|
93b7e3d717 | ||
|
|
da92615816 | ||
|
|
767e8280ac | ||
|
|
c1219d161d | ||
|
|
636aab6891 | ||
|
|
8f9493c213 | ||
|
|
826ea2bf85 | ||
|
|
0bbac63d00 | ||
|
|
1fef20c96b | ||
|
|
77d6ad6f65 | ||
|
|
206ac169e6 |
5
.github/labeler.yml
vendored
5
.github/labeler.yml
vendored
@@ -318,11 +318,6 @@
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/policy/**"
|
||||
- "docs/cli/policy.md"
|
||||
"extensions: feeds":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/feeds/**"
|
||||
- "docs/plugins/reference/feeds.md"
|
||||
"extensions: open-prose":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -1365,6 +1365,8 @@ jobs:
|
||||
boundary_shard: 2/4,3/4,4/4
|
||||
- check_name: check-session-accessor-boundary
|
||||
group: session-accessor-boundary
|
||||
- check_name: check-session-transcript-reader-boundary
|
||||
group: session-transcript-reader-boundary
|
||||
- check_name: check-additional-extension-channels
|
||||
group: extension-channels
|
||||
- check_name: check-additional-extension-bundled
|
||||
@@ -1520,6 +1522,9 @@ jobs:
|
||||
run_check "lint:tmp:session-accessor-boundary" pnpm run lint:tmp:session-accessor-boundary
|
||||
fi
|
||||
;;
|
||||
session-transcript-reader-boundary)
|
||||
run_check "lint:tmp:session-transcript-reader-boundary" pnpm run lint:tmp:session-transcript-reader-boundary
|
||||
;;
|
||||
extension-channels)
|
||||
run_check "lint:extensions:channels" pnpm run lint:extensions:channels
|
||||
;;
|
||||
|
||||
20
.github/workflows/openclaw-performance.yml
vendored
20
.github/workflows/openclaw-performance.yml
vendored
@@ -56,6 +56,7 @@ concurrency:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
OCM_VERSION: v0.2.15
|
||||
OCM_LINUX_X64_SHA256: b849b8de5d77e97e0df9319703254ae95e29d7f26a7552ea79bf173ff110ea0a
|
||||
KOVA_REPOSITORY: openclaw/Kova
|
||||
PERFORMANCE_MODEL_ID: gpt-5.5
|
||||
|
||||
@@ -187,11 +188,20 @@ jobs:
|
||||
set -euo pipefail
|
||||
KOVA_SRC="${RUNNER_TEMP}/kova-src"
|
||||
echo "KOVA_SRC=$KOVA_SRC" >> "$GITHUB_ENV"
|
||||
mkdir -p "$HOME/.local/bin" "$(dirname "$KOVA_SRC")"
|
||||
curl -fsSL https://raw.githubusercontent.com/shakkernerd/ocm/main/install.sh \
|
||||
| bash -s -- --version "$OCM_VERSION" --prefix "$HOME/.local" --force
|
||||
git clone --filter=blob:none "https://github.com/${KOVA_REPOSITORY}.git" "$KOVA_SRC"
|
||||
git -C "$KOVA_SRC" checkout "$KOVA_REF"
|
||||
mkdir -p "$HOME/.local/bin" "$(dirname "$KOVA_SRC")" "${RUNNER_TEMP}/ocm-install"
|
||||
|
||||
ocm_archive="${RUNNER_TEMP}/ocm-${OCM_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
|
||||
curl -fsSL --proto '=https' --tlsv1.2 --retry 3 --retry-delay 1 --retry-connrefused \
|
||||
-o "$ocm_archive" \
|
||||
"https://github.com/shakkernerd/ocm/releases/download/${OCM_VERSION}/ocm-x86_64-unknown-linux-gnu.tar.gz"
|
||||
echo "${OCM_LINUX_X64_SHA256} ${ocm_archive}" | sha256sum -c -
|
||||
tar -xzf "$ocm_archive" -C "${RUNNER_TEMP}/ocm-install"
|
||||
install -m 0755 "${RUNNER_TEMP}/ocm-install/ocm" "$HOME/.local/bin/ocm"
|
||||
|
||||
git init -b main "$KOVA_SRC"
|
||||
git -C "$KOVA_SRC" remote add origin "https://github.com/${KOVA_REPOSITORY}.git"
|
||||
git -C "$KOVA_SRC" fetch --filter=blob:none --depth 1 origin "$KOVA_REF"
|
||||
git -C "$KOVA_SRC" checkout --detach FETCH_HEAD
|
||||
cat > "$HOME/.local/bin/kova" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
export KOVA_HOME="${KOVA_HOME}"
|
||||
|
||||
@@ -443,6 +443,7 @@ class NodeRuntime(
|
||||
updateStatus()
|
||||
micCapture.onGatewayConnectionChanged(true)
|
||||
scope.launch {
|
||||
subscribeOperatorSessionEvents()
|
||||
refreshHomeCanvasOverviewIfConnected()
|
||||
if (voiceReplySpeakerLazy.isInitialized()) {
|
||||
voiceReplySpeaker.refreshConfig()
|
||||
@@ -485,6 +486,14 @@ class NodeRuntime(
|
||||
},
|
||||
)
|
||||
|
||||
private suspend fun subscribeOperatorSessionEvents() {
|
||||
try {
|
||||
operatorSession.request("sessions.subscribe", null)
|
||||
} catch (err: Throwable) {
|
||||
Log.d("OpenClawRuntime", "sessions.subscribe failed: ${err.message ?: err::class.java.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
private val nodeSession =
|
||||
GatewaySession(
|
||||
scope = scope,
|
||||
|
||||
@@ -311,7 +311,6 @@ class ChatController(
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies gateway chat/agent stream events to local transcript and pending-run state. */
|
||||
fun handleGatewayEvent(
|
||||
event: String,
|
||||
payloadJson: String?,
|
||||
@@ -321,7 +320,6 @@ class ChatController(
|
||||
scope.launch { pollHealthIfNeeded(force = false) }
|
||||
}
|
||||
"health" -> {
|
||||
// If we receive a health snapshot, the gateway is reachable.
|
||||
_healthOk.value = true
|
||||
}
|
||||
"seqGap" -> {
|
||||
@@ -332,6 +330,17 @@ class ChatController(
|
||||
if (payloadJson.isNullOrBlank()) return
|
||||
handleChatEvent(payloadJson)
|
||||
}
|
||||
"sessions.changed" -> {
|
||||
if (payloadJson.isNullOrBlank()) {
|
||||
refreshSessionsForCurrentWindow()
|
||||
} else {
|
||||
handleSessionsChangedEvent(payloadJson)
|
||||
}
|
||||
}
|
||||
"session.message" -> {
|
||||
if (payloadJson.isNullOrBlank()) return
|
||||
handleSessionMessageEvent(payloadJson)
|
||||
}
|
||||
"agent" -> {
|
||||
if (payloadJson.isNullOrBlank()) return
|
||||
handleAgentEvent(payloadJson)
|
||||
@@ -353,6 +362,7 @@ class ChatController(
|
||||
)
|
||||
if (!isCurrentHistoryLoad(sessionKey, _sessionKey.value, generation, historyLoadGeneration.get())) return
|
||||
val history = parseHistory(historyJson, sessionKey = sessionKey, previousMessages = _messages.value)
|
||||
updateSessionFromHistory(history)
|
||||
prunePersistedOptimisticMessages(history.messages)
|
||||
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
|
||||
_sessionId.value = history.sessionId
|
||||
@@ -388,6 +398,10 @@ class ChatController(
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshSessionsForCurrentWindow() {
|
||||
scope.launch { fetchSessions(limit = _sessions.value.size.takeIf { it > 0 } ?: 100) }
|
||||
}
|
||||
|
||||
private suspend fun pollHealthIfNeeded(force: Boolean) {
|
||||
val now = System.currentTimeMillis()
|
||||
val last = lastHealthPollAtMs
|
||||
@@ -457,6 +471,7 @@ class ChatController(
|
||||
sessionKey = currentSessionKey,
|
||||
previousMessages = _messages.value,
|
||||
)
|
||||
updateSessionFromHistory(history)
|
||||
prunePersistedOptimisticMessages(history.messages)
|
||||
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
|
||||
_sessionId.value = history.sessionId
|
||||
@@ -472,6 +487,31 @@ class ChatController(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSessionsChangedEvent(payloadJson: String) {
|
||||
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
||||
if (payload["reason"].asStringOrNull() == "delete") {
|
||||
removeSessionEntry(payload["sessionKey"].asStringOrNull() ?: payload["key"].asStringOrNull())
|
||||
return
|
||||
}
|
||||
val entry = parseEventSessionEntry(payload)
|
||||
if (entry != null) {
|
||||
upsertSessionEntry(entry)
|
||||
} else {
|
||||
refreshSessionsForCurrentWindow()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSessionMessageEvent(payloadJson: String) {
|
||||
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
||||
val entry = parseEventSessionEntry(payload)
|
||||
if (entry != null) {
|
||||
upsertSessionEntry(entry)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseEventSessionEntry(payload: JsonObject): ChatSessionEntry? =
|
||||
payload["session"].asObjectOrNull()?.let(::parseSessionEntry) ?: parseSessionEntry(payload)
|
||||
|
||||
private fun handleAgentEvent(payloadJson: String) {
|
||||
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
||||
val sessionKey = payload["sessionKey"].asStringOrNull()?.trim()
|
||||
@@ -600,6 +640,7 @@ class ChatController(
|
||||
val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return ChatHistory(sessionKey, null, null, emptyList())
|
||||
val sid = root["sessionId"].asStringOrNull()
|
||||
val thinkingLevel = root["thinkingLevel"].asStringOrNull()
|
||||
val sessionInfo = root["sessionInfo"].asObjectOrNull()?.let { parseSessionEntry(it, fallbackKey = sessionKey) }
|
||||
val array = root["messages"].asArrayOrNull() ?: JsonArray(emptyList())
|
||||
|
||||
val messages =
|
||||
@@ -622,20 +663,69 @@ class ChatController(
|
||||
sessionId = sid,
|
||||
thinkingLevel = thinkingLevel,
|
||||
messages = reconcileMessageIds(previous = previousMessages, incoming = messages),
|
||||
sessionInfo = sessionInfo,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseSessions(jsonString: String): List<ChatSessionEntry> {
|
||||
val root = json.parseToJsonElement(jsonString).asObjectOrNull() ?: return emptyList()
|
||||
val sessions = root["sessions"].asArrayOrNull() ?: return emptyList()
|
||||
return sessions.mapNotNull { item ->
|
||||
val obj = item.asObjectOrNull() ?: return@mapNotNull null
|
||||
val key = obj["key"].asStringOrNull()?.trim().orEmpty()
|
||||
if (key.isEmpty()) return@mapNotNull null
|
||||
val updatedAt = obj["updatedAt"].asLongOrNull()
|
||||
val displayName = obj["displayName"].asStringOrNull()?.trim()
|
||||
ChatSessionEntry(key = key, updatedAtMs = updatedAt, displayName = displayName)
|
||||
}
|
||||
return sessions.mapNotNull { item -> parseSessionEntry(item.asObjectOrNull()) }
|
||||
}
|
||||
|
||||
private fun parseSessionEntry(
|
||||
obj: JsonObject?,
|
||||
fallbackKey: String? = null,
|
||||
): ChatSessionEntry? {
|
||||
if (obj == null) return null
|
||||
val key =
|
||||
obj["key"].asStringOrNull()?.trim().orEmpty()
|
||||
.ifEmpty { obj["sessionKey"].asStringOrNull()?.trim().orEmpty() }
|
||||
.ifEmpty { fallbackKey?.trim().orEmpty() }
|
||||
if (key.isEmpty()) return null
|
||||
return ChatSessionEntry(
|
||||
key = key,
|
||||
updatedAtMs = obj["updatedAt"].asLongOrNull(),
|
||||
displayName = obj["displayName"].asStringOrNull()?.trim(),
|
||||
totalTokens = obj["totalTokens"].asLongOrNull(),
|
||||
totalTokensFresh = obj["totalTokensFresh"].asBooleanOrNull(),
|
||||
contextTokens = obj["contextTokens"].asLongOrNull(),
|
||||
hasContextUsageMetadata =
|
||||
"totalTokens" in obj ||
|
||||
"totalTokensFresh" in obj ||
|
||||
"contextTokens" in obj,
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateSessionFromHistory(history: ChatHistory) {
|
||||
val info = history.sessionInfo ?: return
|
||||
upsertSessionEntry(info, preserveExistingContextUsageWithoutTotal = true)
|
||||
}
|
||||
|
||||
private fun upsertSessionEntry(
|
||||
entry: ChatSessionEntry,
|
||||
preserveExistingContextUsageWithoutTotal: Boolean = false,
|
||||
) {
|
||||
val current = _sessions.value
|
||||
val index = current.indexOfFirst { it.key == entry.key }
|
||||
_sessions.value =
|
||||
if (index >= 0) {
|
||||
current.toMutableList().also {
|
||||
it[index] =
|
||||
mergeChatSessionEntry(
|
||||
existing = it[index],
|
||||
next = entry,
|
||||
preserveExistingContextUsageWithoutTotal = preserveExistingContextUsageWithoutTotal,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
listOf(entry) + current
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeSessionEntry(sessionKey: String?) {
|
||||
val key = sessionKey?.trim()?.takeIf { it.isNotEmpty() } ?: return
|
||||
_sessions.value = _sessions.value.filterNot { it.key == key }
|
||||
}
|
||||
|
||||
private fun parseRunId(resJson: String): String? =
|
||||
@@ -857,3 +947,44 @@ private fun JsonElement?.asLongOrNull(): Long? =
|
||||
is JsonPrimitive -> content.toLongOrNull()
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun JsonElement?.asBooleanOrNull(): Boolean? =
|
||||
when (this) {
|
||||
is JsonPrimitive -> content.toBooleanStrictOrNull()
|
||||
else -> null
|
||||
}
|
||||
|
||||
internal fun mergeChatSessionEntry(
|
||||
existing: ChatSessionEntry,
|
||||
next: ChatSessionEntry,
|
||||
preserveExistingContextUsageWithoutTotal: Boolean = false,
|
||||
): ChatSessionEntry {
|
||||
val preserveExistingContextUsage = preserveExistingContextUsageWithoutTotal && next.totalTokens == null
|
||||
return existing.copy(
|
||||
updatedAtMs = next.updatedAtMs ?: existing.updatedAtMs,
|
||||
displayName = next.displayName ?: existing.displayName,
|
||||
totalTokens =
|
||||
when {
|
||||
preserveExistingContextUsage -> existing.totalTokens
|
||||
next.hasContextUsageMetadata -> next.totalTokens
|
||||
else -> null
|
||||
},
|
||||
totalTokensFresh =
|
||||
when {
|
||||
preserveExistingContextUsage -> existing.totalTokensFresh
|
||||
next.hasContextUsageMetadata -> next.totalTokensFresh
|
||||
else -> null
|
||||
},
|
||||
contextTokens =
|
||||
when {
|
||||
preserveExistingContextUsage -> next.contextTokens ?: existing.contextTokens
|
||||
next.hasContextUsageMetadata -> next.contextTokens
|
||||
else -> null
|
||||
},
|
||||
hasContextUsageMetadata =
|
||||
when {
|
||||
preserveExistingContextUsage -> existing.hasContextUsageMetadata || next.contextTokens != null
|
||||
else -> next.hasContextUsageMetadata
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,10 @@ data class ChatSessionEntry(
|
||||
val key: String,
|
||||
val updatedAtMs: Long?,
|
||||
val displayName: String? = null,
|
||||
val totalTokens: Long? = null,
|
||||
val totalTokensFresh: Boolean? = null,
|
||||
val contextTokens: Long? = null,
|
||||
val hasContextUsageMetadata: Boolean = totalTokens != null || totalTokensFresh != null || contextTokens != null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -50,6 +54,7 @@ data class ChatHistory(
|
||||
val sessionId: String?,
|
||||
val thinkingLevel: String?,
|
||||
val messages: List<ChatMessage>,
|
||||
val sessionInfo: ChatSessionEntry? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -74,6 +74,7 @@ import kotlinx.coroutines.withContext
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** Full chat surface that wires MainViewModel state to messages, attachments, voice, and composer actions. */
|
||||
@Composable
|
||||
@@ -95,6 +96,7 @@ fun ChatScreen(
|
||||
val sessions by viewModel.chatSessions.collectAsState()
|
||||
val chatDraft by viewModel.chatDraft.collectAsState()
|
||||
val pendingAssistantAutoSend by viewModel.pendingAssistantAutoSend.collectAsState()
|
||||
val contextUsage = resolveChatContextUsage(sessionKey = sessionKey, mainSessionKey = mainSessionKey, sessions = sessions)
|
||||
val context = LocalContext.current
|
||||
val resolver = context.contentResolver
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -196,6 +198,7 @@ fun ChatScreen(
|
||||
onValueChange = { input = it },
|
||||
attachments = attachments,
|
||||
thinkingLevel = thinkingLevel,
|
||||
contextUsage = contextUsage,
|
||||
healthOk = healthOk,
|
||||
pendingRunCount = pendingRunCount,
|
||||
onThinkingLevelChange = viewModel::setChatThinkingLevel,
|
||||
@@ -685,6 +688,7 @@ private fun ChatComposer(
|
||||
onValueChange: (String) -> Unit,
|
||||
attachments: List<PendingImageAttachment>,
|
||||
thinkingLevel: String,
|
||||
contextUsage: ChatContextUsage,
|
||||
healthOk: Boolean,
|
||||
pendingRunCount: Int,
|
||||
onThinkingLevelChange: (String) -> Unit,
|
||||
@@ -699,7 +703,11 @@ private fun ChatComposer(
|
||||
AttachmentStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment)
|
||||
}
|
||||
|
||||
ChatContextMeter(thinkingLevel = thinkingLevel, onClick = { onThinkingLevelChange(nextThinkingValue(thinkingLevel)) })
|
||||
ChatContextMeter(
|
||||
thinkingLevel = thinkingLevel,
|
||||
contextUsage = contextUsage,
|
||||
onClick = { onThinkingLevelChange(nextThinkingValue(thinkingLevel)) },
|
||||
)
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
ChatInputPill(value = value, onValueChange = onValueChange, onPickImages = onPickImages, onVoice = onVoice, modifier = Modifier.weight(1f))
|
||||
@@ -735,8 +743,10 @@ private fun ChatComposer(
|
||||
@Composable
|
||||
private fun ChatContextMeter(
|
||||
thinkingLevel: String,
|
||||
contextUsage: ChatContextUsage,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val contextFraction = contextMeterWidth(contextUsage) ?: 0f
|
||||
Row(
|
||||
modifier = Modifier.width(178.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -755,7 +765,13 @@ private fun ChatContextMeter(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(12.dp), tint = ClawTheme.colors.textSubtle)
|
||||
Text(text = "Context ${contextPercent(thinkingLevel)}%", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted)
|
||||
Text(
|
||||
text = contextMeterLabel(contextUsage, thinkingLevel),
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
|
||||
color = ClawTheme.colors.textMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
@@ -768,7 +784,7 @@ private fun ChatContextMeter(
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(thinkingMeterWidth(thinkingLevel))
|
||||
.fillMaxWidth(contextFraction)
|
||||
.height(3.dp)
|
||||
.background(ClawTheme.colors.primary, RoundedCornerShape(999.dp)),
|
||||
)
|
||||
@@ -902,6 +918,32 @@ private fun isActiveSessionChoice(
|
||||
return choiceKey == current
|
||||
}
|
||||
|
||||
internal data class ChatContextUsage(
|
||||
val totalTokens: Long?,
|
||||
val totalTokensFresh: Boolean?,
|
||||
val contextTokens: Long?,
|
||||
)
|
||||
|
||||
internal fun resolveChatContextUsage(
|
||||
sessionKey: String,
|
||||
mainSessionKey: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
): ChatContextUsage {
|
||||
val entry =
|
||||
sessions.firstOrNull {
|
||||
isActiveSessionChoice(
|
||||
choiceKey = it.key,
|
||||
sessionKey = sessionKey,
|
||||
mainSessionKey = mainSessionKey,
|
||||
)
|
||||
}
|
||||
return ChatContextUsage(
|
||||
totalTokens = entry?.totalTokens,
|
||||
totalTokensFresh = entry?.totalTokensFresh,
|
||||
contextTokens = entry?.contextTokens,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SendButton(
|
||||
enabled: Boolean,
|
||||
@@ -958,16 +1000,28 @@ private fun nextThinkingValue(value: String): String =
|
||||
else -> "off"
|
||||
}
|
||||
|
||||
/** Maps thinking presets to the visual context meter fill fraction. */
|
||||
private fun thinkingMeterWidth(value: String): Float =
|
||||
when (value.lowercase(Locale.US)) {
|
||||
"low" -> 0.34f
|
||||
"medium" -> 0.58f
|
||||
"high" -> 0.82f
|
||||
else -> 0.18f
|
||||
}
|
||||
internal fun contextMeterWidth(usage: ChatContextUsage): Float? {
|
||||
if (usage.totalTokensFresh == false) return null
|
||||
val total = usage.totalTokens?.takeIf { it >= 0L } ?: return null
|
||||
val context = usage.contextTokens?.takeIf { it > 0L } ?: return null
|
||||
return (total.toDouble() / context.toDouble()).coerceIn(0.0, 1.0).toFloat()
|
||||
}
|
||||
|
||||
private fun contextPercent(value: String): Int = (thinkingMeterWidth(value) * 100).toInt()
|
||||
internal fun contextMeterLabel(
|
||||
usage: ChatContextUsage,
|
||||
thinkingLevel: String,
|
||||
): String {
|
||||
val contextLabel = contextMeterWidth(usage)?.let { "Context ${(it * 100).roundToInt()}%" } ?: "Context --"
|
||||
return "$contextLabel · ${contextMeterThinkingLabel(thinkingLevel)}"
|
||||
}
|
||||
|
||||
internal fun contextMeterThinkingLabel(value: String): String =
|
||||
when (value.lowercase(Locale.US)) {
|
||||
"low" -> "low"
|
||||
"medium" -> "medium"
|
||||
"high" -> "high"
|
||||
else -> "off"
|
||||
}
|
||||
|
||||
private fun formatChatTimestamp(timestampMs: Long): String = DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault()).format(Date(timestampMs))
|
||||
|
||||
|
||||
@@ -59,4 +59,96 @@ class ChatControllerSessionPolicyTest {
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionMergeClearsUsageWhenNewSnapshotOmitsUsageMetadata() {
|
||||
val existing =
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:phone",
|
||||
updatedAtMs = 1L,
|
||||
displayName = "Phone",
|
||||
totalTokens = 41_000L,
|
||||
totalTokensFresh = true,
|
||||
contextTokens = 100_000L,
|
||||
)
|
||||
val next =
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:phone",
|
||||
updatedAtMs = 2L,
|
||||
displayName = "Phone renamed",
|
||||
hasContextUsageMetadata = false,
|
||||
)
|
||||
|
||||
val merged = mergeChatSessionEntry(existing, next)
|
||||
|
||||
assertEquals("agent:main:phone", merged.key)
|
||||
assertEquals(2L, merged.updatedAtMs)
|
||||
assertEquals("Phone renamed", merged.displayName)
|
||||
assertEquals(null, merged.totalTokens)
|
||||
assertEquals(null, merged.totalTokensFresh)
|
||||
assertEquals(null, merged.contextTokens)
|
||||
assertFalse(merged.hasContextUsageMetadata)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionMergePreservesUsageWhenHistorySnapshotOmitsTotalTokens() {
|
||||
val existing =
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:phone",
|
||||
updatedAtMs = 1L,
|
||||
displayName = "Phone",
|
||||
totalTokens = 41_000L,
|
||||
totalTokensFresh = true,
|
||||
contextTokens = 100_000L,
|
||||
)
|
||||
val next =
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:phone",
|
||||
updatedAtMs = 2L,
|
||||
displayName = "Phone renamed",
|
||||
totalTokensFresh = false,
|
||||
contextTokens = 120_000L,
|
||||
)
|
||||
|
||||
val merged =
|
||||
mergeChatSessionEntry(
|
||||
existing = existing,
|
||||
next = next,
|
||||
preserveExistingContextUsageWithoutTotal = true,
|
||||
)
|
||||
|
||||
assertEquals(2L, merged.updatedAtMs)
|
||||
assertEquals("Phone renamed", merged.displayName)
|
||||
assertEquals(41_000L, merged.totalTokens)
|
||||
assertEquals(true, merged.totalTokensFresh)
|
||||
assertEquals(120_000L, merged.contextTokens)
|
||||
assertTrue(merged.hasContextUsageMetadata)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionMergeAppliesExplicitStaleUsageMetadata() {
|
||||
val existing =
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:phone",
|
||||
updatedAtMs = 1L,
|
||||
totalTokens = 41_000L,
|
||||
totalTokensFresh = true,
|
||||
contextTokens = 100_000L,
|
||||
)
|
||||
val next =
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:phone",
|
||||
updatedAtMs = 2L,
|
||||
totalTokens = 82_000L,
|
||||
totalTokensFresh = false,
|
||||
contextTokens = 100_000L,
|
||||
)
|
||||
|
||||
val merged = mergeChatSessionEntry(existing, next)
|
||||
|
||||
assertEquals(82_000L, merged.totalTokens)
|
||||
assertEquals(false, merged.totalTokensFresh)
|
||||
assertEquals(100_000L, merged.contextTokens)
|
||||
assertTrue(merged.hasContextUsageMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package ai.openclaw.app.ui.chat
|
||||
|
||||
import ai.openclaw.app.chat.ChatSessionEntry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class ChatContextMeterTest {
|
||||
@Test
|
||||
fun contextMeterUsesActiveSessionTokenBudget() {
|
||||
val sessions =
|
||||
listOf(
|
||||
ChatSessionEntry(key = "main", updatedAtMs = 1L, displayName = "Main", totalTokens = 8_000L, totalTokensFresh = true, contextTokens = 10_000L),
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:mobile:test-device",
|
||||
updatedAtMs = 2L,
|
||||
displayName = "Phone",
|
||||
totalTokens = 1_250L,
|
||||
totalTokensFresh = true,
|
||||
contextTokens = 5_000L,
|
||||
),
|
||||
)
|
||||
|
||||
val usage =
|
||||
resolveChatContextUsage(
|
||||
sessionKey = "agent:main:mobile:test-device",
|
||||
mainSessionKey = "main",
|
||||
sessions = sessions,
|
||||
)
|
||||
|
||||
assertEquals(ChatContextUsage(totalTokens = 1_250L, totalTokensFresh = true, contextTokens = 5_000L), usage)
|
||||
assertEquals(0.25f, contextMeterWidth(usage))
|
||||
assertEquals("Context 25% · high", contextMeterLabel(usage, "high"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contextMeterResolvesCanonicalMainAlias() {
|
||||
val sessions =
|
||||
listOf(
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:node-phone",
|
||||
updatedAtMs = 1L,
|
||||
displayName = "Main",
|
||||
totalTokens = 41_000L,
|
||||
totalTokensFresh = true,
|
||||
contextTokens = 100_000L,
|
||||
),
|
||||
)
|
||||
|
||||
val usage =
|
||||
resolveChatContextUsage(
|
||||
sessionKey = "main",
|
||||
mainSessionKey = "agent:main:node-phone",
|
||||
sessions = sessions,
|
||||
)
|
||||
|
||||
assertEquals(ChatContextUsage(totalTokens = 41_000L, totalTokensFresh = true, contextTokens = 100_000L), usage)
|
||||
assertEquals("Context 41% · off", contextMeterLabel(usage, "off"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contextMeterDoesNotInventPercentWhenBudgetIsMissing() {
|
||||
val usage = ChatContextUsage(totalTokens = 8_200L, totalTokensFresh = true, contextTokens = null)
|
||||
|
||||
assertNull(contextMeterWidth(usage))
|
||||
assertEquals("Context -- · medium", contextMeterLabel(usage, "medium"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contextMeterClampsOverfullSessions() {
|
||||
val usage = ChatContextUsage(totalTokens = 150_000L, totalTokensFresh = true, contextTokens = 100_000L)
|
||||
|
||||
assertEquals(1.0f, contextMeterWidth(usage))
|
||||
assertEquals("Context 100% · low", contextMeterLabel(usage, "low"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contextMeterDoesNotDisplayStaleTokenUsage() {
|
||||
val usage = ChatContextUsage(totalTokens = 82_000L, totalTokensFresh = false, contextTokens = 100_000L)
|
||||
|
||||
assertNull(contextMeterWidth(usage))
|
||||
assertEquals("Context -- · high", contextMeterLabel(usage, "high"))
|
||||
}
|
||||
}
|
||||
@@ -58,11 +58,11 @@ Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.5.12 - 2026-05-12
|
||||
|
||||
Maintenance update for the current OpenClaw beta release.
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.5.10 - 2026-05-10
|
||||
|
||||
Maintenance update for the current OpenClaw beta release.
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
- Gateway connections now recover after a trusted Gateway certificate changes by refreshing the stored certificate pin during reconnect.
|
||||
|
||||
@@ -128,7 +128,7 @@ Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.4.19 - 2026-04-19
|
||||
|
||||
Maintenance update for the current OpenClaw beta release.
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.4.18 - 2026-04-18
|
||||
|
||||
@@ -136,11 +136,11 @@ Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.4.15 - 2026-04-15
|
||||
|
||||
Maintenance update for the current OpenClaw beta release.
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.4.14 - 2026-04-14
|
||||
|
||||
Maintenance update for the current OpenClaw beta release.
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.4.12 - 2026-04-12
|
||||
|
||||
|
||||
53
apps/ios/Config/AppStoreSigning.json
Normal file
53
apps/ios/Config/AppStoreSigning.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"teamId": "FWJYW4S8P8",
|
||||
"signingRepo": "git@github.com:openclaw/ios-signing.git",
|
||||
"certificateType": "IOS_DISTRIBUTION",
|
||||
"profileType": "IOS_APP_STORE",
|
||||
"targets": [
|
||||
{
|
||||
"target": "OpenClaw",
|
||||
"displayName": "OpenClaw",
|
||||
"bundleId": "ai.openclawfoundation.app",
|
||||
"platform": "IOS",
|
||||
"profileKey": "OPENCLAW_APP_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app",
|
||||
"capabilities": ["PUSH_NOTIFICATIONS"]
|
||||
},
|
||||
{
|
||||
"target": "OpenClawShareExtension",
|
||||
"displayName": "OpenClaw Share",
|
||||
"bundleId": "ai.openclawfoundation.app.share",
|
||||
"platform": "IOS",
|
||||
"profileKey": "OPENCLAW_SHARE_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app.share",
|
||||
"capabilities": []
|
||||
},
|
||||
{
|
||||
"target": "OpenClawActivityWidget",
|
||||
"displayName": "OpenClaw Activity Widget",
|
||||
"bundleId": "ai.openclawfoundation.app.activitywidget",
|
||||
"platform": "IOS",
|
||||
"profileKey": "OPENCLAW_ACTIVITY_WIDGET_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app.activitywidget",
|
||||
"capabilities": []
|
||||
},
|
||||
{
|
||||
"target": "OpenClawWatchApp",
|
||||
"displayName": "OpenClaw Watch App",
|
||||
"bundleId": "ai.openclawfoundation.app.watchkitapp",
|
||||
"platform": "IOS",
|
||||
"profileKey": "OPENCLAW_WATCH_APP_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app.watchkitapp",
|
||||
"capabilities": []
|
||||
},
|
||||
{
|
||||
"target": "OpenClawWatchExtension",
|
||||
"displayName": "OpenClaw Watch Extension",
|
||||
"bundleId": "ai.openclawfoundation.app.watchkitapp.extension",
|
||||
"platform": "IOS",
|
||||
"profileKey": "OPENCLAW_WATCH_EXTENSION_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app.watchkitapp.extension",
|
||||
"capabilities": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
// Shared iOS signing defaults for local development + CI.
|
||||
#include "Version.xcconfig"
|
||||
|
||||
OPENCLAW_IOS_DEFAULT_TEAM = Y5PE65HELJ
|
||||
OPENCLAW_IOS_DEFAULT_TEAM = FWJYW4S8P8
|
||||
OPENCLAW_IOS_SELECTED_TEAM = $(OPENCLAW_IOS_DEFAULT_TEAM)
|
||||
OPENCLAW_DEVELOPMENT_TEAM = $(OPENCLAW_IOS_SELECTED_TEAM)
|
||||
OPENCLAW_CODE_SIGN_STYLE = Automatic
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.client
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.client.activitywidget
|
||||
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
|
||||
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
|
||||
OPENCLAW_WATCH_APP_PROFILE =
|
||||
OPENCLAW_WATCH_EXTENSION_PROFILE =
|
||||
|
||||
@@ -18,7 +20,7 @@ OPENCLAW_WATCH_EXTENSION_PROFILE =
|
||||
#include? "../LocalSigning.xcconfig"
|
||||
|
||||
CODE_SIGN_STYLE = $(OPENCLAW_CODE_SIGN_STYLE)
|
||||
CODE_SIGN_IDENTITY = Apple Development
|
||||
CODE_SIGN_IDENTITY = $(OPENCLAW_CODE_SIGN_IDENTITY)
|
||||
DEVELOPMENT_TEAM = $(OPENCLAW_DEVELOPMENT_TEAM)
|
||||
|
||||
// Let Xcode manage provisioning for the selected local team unless a local override pins one.
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
// This file is only an example and should stay committed.
|
||||
|
||||
OPENCLAW_CODE_SIGN_STYLE = Automatic
|
||||
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
|
||||
OPENCLAW_DEVELOPMENT_TEAM = YOUR_TEAM_ID
|
||||
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.client
|
||||
OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.client.share
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.client.activitywidget
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
|
||||
OPENCLAW_SHARE_BUNDLE_ID = ai.openclawfoundation.app.share
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
|
||||
|
||||
// Leave empty with automatic signing.
|
||||
OPENCLAW_APP_PROFILE =
|
||||
OPENCLAW_SHARE_PROFILE =
|
||||
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
|
||||
OPENCLAW_WATCH_APP_PROFILE =
|
||||
OPENCLAW_WATCH_EXTENSION_PROFILE =
|
||||
|
||||
@@ -4,8 +4,8 @@ This iOS app is super-alpha and internal-use only. The first public App Store re
|
||||
|
||||
## Distribution Status
|
||||
|
||||
- Public distribution: not available.
|
||||
- Internal beta distribution: local archive + TestFlight upload via Fastlane.
|
||||
- Public distribution: App Store Connect app created; production signing is configured through the App Store release Fastlane path.
|
||||
- Internal TestFlight distribution: uses the same App Store distribution archive uploaded to App Store Connect.
|
||||
- Local/manual deploy from source via Xcode remains the default development path.
|
||||
|
||||
## Super-Alpha Disclaimer
|
||||
@@ -47,7 +47,7 @@ Shortcut command (same flow + open project):
|
||||
pnpm ios:open
|
||||
```
|
||||
|
||||
## Local Beta Release Flow
|
||||
## App Store Release Flow
|
||||
|
||||
Prereqs:
|
||||
|
||||
@@ -55,51 +55,82 @@ Prereqs:
|
||||
- `pnpm`
|
||||
- `xcodegen`
|
||||
- `fastlane`
|
||||
- Apple account signed into Xcode for automatic signing/provisioning
|
||||
- App Store Connect API key set up in Keychain via `scripts/ios-asc-keychain-setup.sh` when auto-resolving a beta build number or uploading to TestFlight
|
||||
- Apple account signed into Xcode for the canonical OpenClaw team (`FWJYW4S8P8`)
|
||||
- `asc` CLI authenticated for the canonical OpenClaw team
|
||||
- Release-owner access to the encrypted signing repo password (`ASC_MATCH_PASSWORD`)
|
||||
- App Store Connect app already created for `ai.openclawfoundation.app`
|
||||
- App Store Connect API key set up in Keychain via `scripts/ios-asc-keychain-setup.sh` when auto-resolving a build number or uploading to App Store Connect
|
||||
|
||||
Release behavior:
|
||||
|
||||
- Local development keeps using unique per-developer bundle IDs from `scripts/ios-configure-signing.sh`.
|
||||
- Beta release uses canonical `ai.openclaw.client*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/BetaRelease.xcconfig`.
|
||||
- Beta release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, and `OpenClawPushAPNsEnvironment=production`.
|
||||
- The beta flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
|
||||
- Local development uses the canonical `ai.openclawfoundation.app*` bundle IDs when the OpenClaw team is available, and unique `ai.openclawfoundation.app.test.*` bundle IDs only for non-canonical fallback teams.
|
||||
- App Store release uses canonical `ai.openclawfoundation.app*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/AppStoreRelease.xcconfig`.
|
||||
- App Store release uses manual `Apple Distribution` signing with profile names pinned in `apps/ios/Config/AppStoreSigning.json`.
|
||||
- `asc` owns one-time Developer Portal setup and encrypted signing sync. Fastlane owns release handling after those assets exist.
|
||||
- App Store release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, `OpenClawPushAPNsEnvironment=production`, and a production `aps-environment` entitlement.
|
||||
- `pnpm ios:release:upload` generates App Store screenshots and uploads release notes before archiving and uploading the IPA.
|
||||
- `pnpm ios:release` remains a compatibility alias for `pnpm ios:release:upload`; prefer the explicit upload command in new release docs and automation.
|
||||
- App Review submission is manual in App Store Connect. The release lane uploads a build and metadata, but does not submit for review.
|
||||
- The release flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
|
||||
- `apps/ios/version.json` is the pinned iOS release version source.
|
||||
- `apps/ios/CHANGELOG.md` is the iOS-only changelog and release-note source.
|
||||
- The pinned iOS version must use CalVer like `2026.4.10`.
|
||||
- That pinned value becomes:
|
||||
- `CFBundleShortVersionString = 2026.4.10`
|
||||
- `CFBundleVersion = next TestFlight build number for 2026.4.10`
|
||||
- `CFBundleVersion = next App Store Connect build number for 2026.4.10`
|
||||
- Changing the root gateway version does not change the iOS app version until you explicitly pin from the gateway.
|
||||
- See `apps/ios/VERSIONING.md` for the full workflow.
|
||||
|
||||
Relay behavior for beta builds:
|
||||
Relay behavior for App Store builds:
|
||||
|
||||
- Beta builds default to `https://ios-push-relay.openclaw.ai`.
|
||||
- Release builds default to `https://ios-push-relay.openclaw.ai`.
|
||||
- Optional custom relay override: `OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com`
|
||||
This must be a plain `https://host[:port][/path]` base URL without whitespace, query params, fragments, or xcconfig metacharacters.
|
||||
|
||||
Signing setup commands:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:signing:plan
|
||||
pnpm ios:release:signing:check
|
||||
pnpm ios:release:signing:setup
|
||||
ASC_MATCH_PASSWORD=... pnpm ios:release:signing:sync:push
|
||||
ASC_MATCH_PASSWORD=... pnpm ios:release:signing:sync:pull
|
||||
```
|
||||
|
||||
Release-owner secrets:
|
||||
|
||||
- App Store Connect API auth uses Keychain for private key material plus non-secret `apps/ios/fastlane/.env` variables.
|
||||
- The encrypted signing repo password lives outside this repo in the release-owner vault and is exposed locally as `ASC_MATCH_PASSWORD`.
|
||||
- Apple Distribution private keys, certificates, provisioning profiles, and decrypted signing sync output stay under `apps/ios/build/` or Keychain and are gitignored.
|
||||
- Rotating release signing means revoking/replacing the Developer Portal certificate or profile with `asc`, then pushing a fresh encrypted sync state.
|
||||
|
||||
Prepare the generated release xcconfig/project without archiving:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:prepare -- --build-number 7
|
||||
```
|
||||
|
||||
Archive without upload:
|
||||
|
||||
```bash
|
||||
pnpm ios:beta:archive
|
||||
pnpm ios:release:archive
|
||||
```
|
||||
|
||||
Archive and upload to TestFlight:
|
||||
Archive and upload to App Store Connect:
|
||||
|
||||
```bash
|
||||
pnpm ios:beta
|
||||
pnpm ios:release:upload
|
||||
```
|
||||
|
||||
If you need to force a specific build number:
|
||||
|
||||
```bash
|
||||
pnpm ios:beta -- --build-number 7
|
||||
pnpm ios:release:upload -- --build-number 7
|
||||
```
|
||||
|
||||
### Maintainer Quick Release Checklist
|
||||
|
||||
Use this when a clone is missing local iOS release setup and you want the shortest path to a TestFlight upload.
|
||||
Use this when a clone is missing local iOS release setup and you want the shortest path to an App Store Connect upload.
|
||||
|
||||
1. Confirm Fastlane auth is set up:
|
||||
|
||||
@@ -119,38 +150,50 @@ scripts/ios-asc-keychain-setup.sh \
|
||||
|
||||
This should create `apps/ios/fastlane/.env` with the non-secret ASC variables while the private key stays in Keychain.
|
||||
|
||||
3. Optional: set a custom official/TestFlight relay URL for the build. If unset, the beta flow uses `https://ios-push-relay.openclaw.ai`.
|
||||
3. Confirm the App Store Connect app and Apple Developer identifiers/capabilities exist for:
|
||||
- `ai.openclawfoundation.app`
|
||||
- `ai.openclawfoundation.app.share`
|
||||
- `ai.openclawfoundation.app.activitywidget`
|
||||
- `ai.openclawfoundation.app.watchkitapp`
|
||||
- `ai.openclawfoundation.app.watchkitapp.extension`
|
||||
|
||||
Use `pnpm ios:release:signing:setup` for the initial portal setup, then `ASC_MATCH_PASSWORD=... pnpm ios:release:signing:sync:push` to publish encrypted signing assets to the shared private repo.
|
||||
|
||||
4. Optional: set a custom official relay URL for the build. If unset, the release flow uses `https://ios-push-relay.openclaw.ai`.
|
||||
|
||||
```bash
|
||||
export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
|
||||
```
|
||||
|
||||
4. If you are starting a brand-new production release train, pin iOS to the current gateway version first:
|
||||
5. If you are starting a brand-new production release train, pin iOS to the current gateway version first:
|
||||
|
||||
```bash
|
||||
pnpm ios:version:pin -- --from-gateway
|
||||
```
|
||||
|
||||
5. Upload the beta:
|
||||
6. Upload the build:
|
||||
|
||||
```bash
|
||||
pnpm ios:beta
|
||||
pnpm ios:release:upload
|
||||
```
|
||||
|
||||
6. Expected behavior:
|
||||
7. Expected behavior:
|
||||
- Fastlane reads `apps/ios/version.json`
|
||||
- verifies synced iOS versioning artifacts
|
||||
- resolves the next TestFlight build number for that short version
|
||||
- generates `apps/ios/build/BetaRelease.xcconfig`
|
||||
- resolves the next App Store Connect build number for that short version
|
||||
- generates deterministic App Store screenshots
|
||||
- uploads release notes and screenshots to the editable App Store version
|
||||
- generates `apps/ios/build/AppStoreRelease.xcconfig`
|
||||
- archives `OpenClaw`
|
||||
- uploads the IPA to TestFlight
|
||||
- uploads the IPA to App Store Connect for TestFlight/App Review use
|
||||
- leaves App Review submission for a maintainer to complete manually
|
||||
|
||||
7. Expected outputs after a successful run:
|
||||
- `apps/ios/build/beta/OpenClaw-<version>.ipa`
|
||||
- `apps/ios/build/beta/OpenClaw-<version>.app.dSYM.zip`
|
||||
- Fastlane log line like `Uploaded iOS beta: version=<version> short=<short> build=<build>`
|
||||
8. Expected outputs after a successful run:
|
||||
- `apps/ios/build/app-store/OpenClaw-<version>.ipa`
|
||||
- `apps/ios/build/app-store/OpenClaw-<version>.app.dSYM.zip`
|
||||
- Fastlane log line like `Uploaded iOS App Store build: version=<version> short=<short> build=<build>`
|
||||
|
||||
8. If this is a fresh clone on a maintainer machine that already works elsewhere, it is OK to copy the non-secret `apps/ios/fastlane/.env` from another trusted local clone on the same Mac. The Keychain-backed private key remains machine-local and is not stored in the repo.
|
||||
9. If this is a fresh clone on a maintainer machine that already works elsewhere, it is OK to copy the non-secret `apps/ios/fastlane/.env` from another trusted local clone on the same Mac. The Keychain-backed private key remains machine-local and is not stored in the repo.
|
||||
|
||||
## iOS Versioning Workflow
|
||||
|
||||
@@ -176,7 +219,7 @@ Recommended flow:
|
||||
1. Keep `apps/ios/version.json` pinned to the current train version.
|
||||
2. Update `apps/ios/CHANGELOG.md`, usually under `## Unreleased` while iterating.
|
||||
3. Run `pnpm ios:version:sync` after changelog changes.
|
||||
4. Upload more TestFlight builds with `pnpm ios:beta`.
|
||||
4. Upload more TestFlight builds with `pnpm ios:release:upload`.
|
||||
5. Let Fastlane bump only the numeric build number.
|
||||
|
||||
### Starting the next production release train
|
||||
@@ -189,7 +232,7 @@ pnpm ios:version:pin -- --from-gateway
|
||||
|
||||
2. Update `apps/ios/CHANGELOG.md` for the new release as needed.
|
||||
3. Run `pnpm ios:version:sync`.
|
||||
4. Submit the first TestFlight build for that newly pinned version.
|
||||
4. Submit the first App Store Connect build for that newly pinned version.
|
||||
5. Keep iterating on that same version until the release candidate is ready.
|
||||
|
||||
See `apps/ios/VERSIONING.md` for the detailed spec.
|
||||
@@ -197,9 +240,9 @@ See `apps/ios/VERSIONING.md` for the detailed spec.
|
||||
## APNs Expectations For Local/Manual Builds
|
||||
|
||||
- The app calls `registerForRemoteNotifications()` at launch.
|
||||
- `apps/ios/Sources/OpenClaw.entitlements` sets `aps-environment` to `development`.
|
||||
- `apps/ios/Sources/OpenClaw.entitlements` derives `aps-environment` from the active build configuration/signing override.
|
||||
- APNs token registration to gateway happens only after gateway connection (`push.apns.register`).
|
||||
- Local/manual builds default to `OpenClawPushTransport=direct` and `OpenClawPushDistribution=local`.
|
||||
- Local/manual builds default to `OpenClawPushTransport=direct`, `OpenClawPushDistribution=local`, and a development `aps-environment` entitlement.
|
||||
- Your selected team/profile must support Push Notifications for the app bundle ID you are signing.
|
||||
- If push capability or provisioning is wrong, APNs registration fails at runtime (check Xcode logs for `APNs registration failed`).
|
||||
- The gateway host also needs direct APNs auth configured separately with `OPENCLAW_APNS_TEAM_ID`, `OPENCLAW_APNS_KEY_ID`, and either `OPENCLAW_APNS_PRIVATE_KEY_P8` or `OPENCLAW_APNS_PRIVATE_KEY_PATH`.
|
||||
@@ -319,7 +362,7 @@ Automatic wake/reconnect hardening:
|
||||
5. If network path is unclear:
|
||||
- switch to manual host/port + TLS in Gateway Advanced settings
|
||||
6. In Xcode console, filter for subsystem/category signals:
|
||||
- `ai.openclaw.ios`
|
||||
- `ai.openclawfoundation.app`
|
||||
- `GatewayDiag`
|
||||
- `APNs registration failed`
|
||||
7. Validate background expectations:
|
||||
|
||||
@@ -17,7 +17,7 @@ final class ShareViewController: UIViewController {
|
||||
var attachments: [ShareAttachment]
|
||||
}
|
||||
|
||||
private let logger = Logger(subsystem: "ai.openclaw.ios", category: "ShareExtension")
|
||||
private let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "ShareExtension")
|
||||
private var statusLabel: UILabel?
|
||||
private let draftTextView = UITextView()
|
||||
private let sendButton = UIButton(type: .system)
|
||||
|
||||
@@ -5,16 +5,21 @@
|
||||
#include "Config/Version.xcconfig"
|
||||
|
||||
OPENCLAW_CODE_SIGN_STYLE = Manual
|
||||
OPENCLAW_DEVELOPMENT_TEAM = Y5PE65HELJ
|
||||
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
|
||||
OPENCLAW_DEVELOPMENT_TEAM = FWJYW4S8P8
|
||||
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.client
|
||||
OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.client.share
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.client.activitywidget
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
|
||||
OPENCLAW_SHARE_BUNDLE_ID = ai.openclawfoundation.app.share
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
|
||||
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT = development
|
||||
|
||||
OPENCLAW_APP_PROFILE = ai.openclaw.client Development
|
||||
OPENCLAW_SHARE_PROFILE = ai.openclaw.client.share Development
|
||||
OPENCLAW_APP_PROFILE = ai.openclawfoundation.app Development
|
||||
OPENCLAW_SHARE_PROFILE = ai.openclawfoundation.app.share Development
|
||||
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
|
||||
OPENCLAW_WATCH_APP_PROFILE =
|
||||
OPENCLAW_WATCH_EXTENSION_PROFILE =
|
||||
|
||||
// Keep local includes after defaults: xcconfig is evaluated top-to-bottom,
|
||||
// so later assignments in local files override the defaults above.
|
||||
|
||||
@@ -14,7 +14,50 @@ enum AppleReviewDemoMode {
|
||||
}
|
||||
|
||||
static var agents: [AgentSummary] {
|
||||
[
|
||||
LocalChatFixture.appleReviewDemo.agents
|
||||
}
|
||||
}
|
||||
|
||||
enum ScreenshotFixtureMode {
|
||||
static let gatewayName = "OpenClaw Gateway"
|
||||
static let gatewayAddress = "Mac Studio on local network"
|
||||
static let gatewayID = "screenshot-fixture-gateway"
|
||||
|
||||
static var agents: [AgentSummary] {
|
||||
LocalChatFixture.appScreenshots.agents
|
||||
}
|
||||
}
|
||||
|
||||
struct LocalChatFixture {
|
||||
let sessionKey: String
|
||||
let sessionIDPrefix: String
|
||||
let displayName: String
|
||||
let subject: String
|
||||
let workspace: String
|
||||
let modelProvider: String
|
||||
let modelID: String
|
||||
let modelName: String
|
||||
let responsePrefix: String
|
||||
let seedMessages: [String]
|
||||
let agents: [AgentSummary]
|
||||
|
||||
static let appleReviewDemo = LocalChatFixture(
|
||||
sessionKey: "main",
|
||||
sessionIDPrefix: "apple-review-demo",
|
||||
displayName: "Apple Review Demo",
|
||||
subject: "Gateway review flow",
|
||||
workspace: "Apple Review Demo",
|
||||
modelProvider: "demo",
|
||||
modelID: "local-demo",
|
||||
modelName: "Apple Review Demo",
|
||||
responsePrefix: "Demo mode is active.",
|
||||
seedMessages: [
|
||||
"""
|
||||
Apple Review demo mode is active. This local chat transport lets reviewers inspect the iOS app \
|
||||
without a private Gateway.
|
||||
""",
|
||||
],
|
||||
agents: [
|
||||
AgentSummary(
|
||||
id: "main",
|
||||
name: "Main",
|
||||
@@ -25,12 +68,70 @@ enum AppleReviewDemoMode {
|
||||
thinkinglevels: nil,
|
||||
thinkingoptions: ["auto", "low", "medium"],
|
||||
thinkingdefault: "auto"),
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
static let appScreenshots = LocalChatFixture(
|
||||
sessionKey: "main",
|
||||
sessionIDPrefix: "screenshot-fixture",
|
||||
displayName: "Molty",
|
||||
subject: "Mobile command center",
|
||||
workspace: "OpenClaw",
|
||||
modelProvider: "openai",
|
||||
modelID: "gpt-5.5",
|
||||
modelName: "GPT-5.5",
|
||||
responsePrefix: "OpenClaw is connected to your gateway.",
|
||||
seedMessages: [
|
||||
"""
|
||||
OpenClaw is connected to your gateway. I can coordinate agents, inspect project context, and prepare \
|
||||
actions from your phone.
|
||||
""",
|
||||
"""
|
||||
The Molty agent is ready. Recent context, voice controls, and gateway settings are available \
|
||||
across the app.
|
||||
""",
|
||||
],
|
||||
agents: [
|
||||
AgentSummary(
|
||||
id: "main",
|
||||
name: "Molty",
|
||||
identity: ["emoji": AnyCodable("M")],
|
||||
workspace: "OpenClaw",
|
||||
model: ["provider": AnyCodable("openai"), "model": AnyCodable("gpt-5.5")],
|
||||
agentruntime: ["kind": AnyCodable("gateway")],
|
||||
thinkinglevels: nil,
|
||||
thinkingoptions: ["auto", "low", "medium", "high"],
|
||||
thinkingdefault: "auto"),
|
||||
AgentSummary(
|
||||
id: "research",
|
||||
name: "Research",
|
||||
identity: ["emoji": AnyCodable("RS")],
|
||||
workspace: "OpenClaw",
|
||||
model: ["provider": AnyCodable("openai"), "model": AnyCodable("gpt-5.5")],
|
||||
agentruntime: ["kind": AnyCodable("gateway")],
|
||||
thinkinglevels: nil,
|
||||
thinkingoptions: ["auto", "low", "medium", "high"],
|
||||
thinkingdefault: "medium"),
|
||||
AgentSummary(
|
||||
id: "automation",
|
||||
name: "Automation",
|
||||
identity: ["emoji": AnyCodable("AU")],
|
||||
workspace: "OpenClaw",
|
||||
model: ["provider": AnyCodable("openai"), "model": AnyCodable("gpt-5.5")],
|
||||
agentruntime: ["kind": AnyCodable("gateway")],
|
||||
thinkinglevels: nil,
|
||||
thinkingoptions: ["auto", "low", "medium", "high"],
|
||||
thinkingdefault: "auto"),
|
||||
])
|
||||
}
|
||||
|
||||
struct AppleReviewDemoChatTransport: OpenClawChatTransport {
|
||||
private let store = AppleReviewDemoChatStore()
|
||||
struct LocalFixtureChatTransport: OpenClawChatTransport {
|
||||
private let fixture: LocalChatFixture
|
||||
private let store: LocalFixtureChatStore
|
||||
|
||||
init(fixture: LocalChatFixture) {
|
||||
self.fixture = fixture
|
||||
self.store = LocalFixtureChatStore(fixture: fixture)
|
||||
}
|
||||
|
||||
func createSession(
|
||||
key: String,
|
||||
@@ -47,9 +148,9 @@ struct AppleReviewDemoChatTransport: OpenClawChatTransport {
|
||||
func listModels() async throws -> [OpenClawChatModelChoice] {
|
||||
[
|
||||
OpenClawChatModelChoice(
|
||||
modelID: "local-demo",
|
||||
name: "Apple Review Demo",
|
||||
provider: "demo",
|
||||
modelID: self.fixture.modelID,
|
||||
name: self.fixture.modelName,
|
||||
provider: self.fixture.modelProvider,
|
||||
contextWindow: 128_000),
|
||||
]
|
||||
}
|
||||
@@ -101,26 +202,102 @@ struct AppleReviewDemoChatTransport: OpenClawChatTransport {
|
||||
func compactSession(sessionKey _: String) async throws {}
|
||||
}
|
||||
|
||||
private actor AppleReviewDemoChatStore {
|
||||
private let sessionKey = "main"
|
||||
struct AppleReviewDemoChatTransport: OpenClawChatTransport {
|
||||
private let transport = LocalFixtureChatTransport(fixture: .appleReviewDemo)
|
||||
|
||||
func createSession(
|
||||
key: String,
|
||||
label: String?,
|
||||
parentSessionKey: String?) async throws -> OpenClawChatCreateSessionResponse
|
||||
{
|
||||
try await self.transport.createSession(key: key, label: label, parentSessionKey: parentSessionKey)
|
||||
}
|
||||
|
||||
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
|
||||
try await self.transport.requestHistory(sessionKey: sessionKey)
|
||||
}
|
||||
|
||||
func listModels() async throws -> [OpenClawChatModelChoice] {
|
||||
try await self.transport.listModels()
|
||||
}
|
||||
|
||||
func sendMessage(
|
||||
sessionKey: String,
|
||||
message: String,
|
||||
thinking: String,
|
||||
idempotencyKey: String,
|
||||
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||
{
|
||||
try await self.transport.sendMessage(
|
||||
sessionKey: sessionKey,
|
||||
message: message,
|
||||
thinking: thinking,
|
||||
idempotencyKey: idempotencyKey,
|
||||
attachments: attachments)
|
||||
}
|
||||
|
||||
func abortRun(sessionKey: String, runId: String) async throws {
|
||||
try await self.transport.abortRun(sessionKey: sessionKey, runId: runId)
|
||||
}
|
||||
|
||||
func listSessions(limit: Int?) async throws -> OpenClawChatSessionsListResponse {
|
||||
try await self.transport.listSessions(limit: limit)
|
||||
}
|
||||
|
||||
func setSessionModel(sessionKey: String, model: String?) async throws {
|
||||
try await self.transport.setSessionModel(sessionKey: sessionKey, model: model)
|
||||
}
|
||||
|
||||
func setSessionThinking(sessionKey: String, thinkingLevel: String) async throws {
|
||||
try await self.transport.setSessionThinking(sessionKey: sessionKey, thinkingLevel: thinkingLevel)
|
||||
}
|
||||
|
||||
func requestHealth(timeoutMs: Int) async throws -> Bool {
|
||||
try await self.transport.requestHealth(timeoutMs: timeoutMs)
|
||||
}
|
||||
|
||||
func waitForRunCompletion(runId: String, timeoutMs: Int) async -> Bool {
|
||||
await self.transport.waitForRunCompletion(runId: runId, timeoutMs: timeoutMs)
|
||||
}
|
||||
|
||||
func events() -> AsyncStream<OpenClawChatTransportEvent> {
|
||||
self.transport.events()
|
||||
}
|
||||
|
||||
func setActiveSessionKey(_ sessionKey: String) async throws {
|
||||
try await self.transport.setActiveSessionKey(sessionKey)
|
||||
}
|
||||
|
||||
func resetSession(sessionKey: String) async throws {
|
||||
try await self.transport.resetSession(sessionKey: sessionKey)
|
||||
}
|
||||
|
||||
func compactSession(sessionKey: String) async throws {
|
||||
try await self.transport.compactSession(sessionKey: sessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
private actor LocalFixtureChatStore {
|
||||
private let fixture: LocalChatFixture
|
||||
private var messages: [OpenClawChatMessage]
|
||||
|
||||
init() {
|
||||
self.messages = AppleReviewDemoChatStore.seedMessages()
|
||||
init(fixture: LocalChatFixture) {
|
||||
self.fixture = fixture
|
||||
self.messages = Self.seedMessages(fixture: fixture)
|
||||
}
|
||||
|
||||
func createSession(key: String) throws -> OpenClawChatCreateSessionResponse {
|
||||
try Self.decode(
|
||||
CreateSessionPayload(ok: true, key: key, sessionId: "apple-review-demo-\(key)"),
|
||||
CreateSessionPayload(ok: true, key: key, sessionId: "\(self.fixture.sessionIDPrefix)-\(key)"),
|
||||
as: OpenClawChatCreateSessionResponse.self)
|
||||
}
|
||||
|
||||
func history(sessionKey: String) throws -> OpenClawChatHistoryPayload {
|
||||
let normalizedSessionKey = Self.normalizedSessionKey(sessionKey)
|
||||
let normalizedSessionKey = Self.normalizedSessionKey(sessionKey, fallback: self.fixture.sessionKey)
|
||||
return try Self.decode(
|
||||
HistoryPayload(
|
||||
sessionKey: normalizedSessionKey,
|
||||
sessionId: "apple-review-demo-\(normalizedSessionKey)",
|
||||
sessionId: "\(self.fixture.sessionIDPrefix)-\(normalizedSessionKey)",
|
||||
messages: self.messages,
|
||||
thinkingLevel: "auto"),
|
||||
as: OpenClawChatHistoryPayload.self)
|
||||
@@ -135,9 +312,8 @@ private actor AppleReviewDemoChatStore {
|
||||
Self.message(
|
||||
role: "assistant",
|
||||
text: """
|
||||
Demo mode is active. I can show the review flow locally for \(subject), including chat, agent \
|
||||
selection, settings, and Gateway-connected UI states. Live automation requires pairing a real \
|
||||
OpenClaw Gateway.
|
||||
\(self.fixture.responsePrefix) I can help with \(subject), summarize current project context, \
|
||||
prepare agent actions, and keep the mobile workflow connected to the gateway.
|
||||
""",
|
||||
timestamp: now + 1))
|
||||
return try Self.decode(
|
||||
@@ -147,15 +323,15 @@ private actor AppleReviewDemoChatStore {
|
||||
|
||||
func sessions() throws -> OpenClawChatSessionsListResponse {
|
||||
let entry = OpenClawChatSessionEntry(
|
||||
key: self.sessionKey,
|
||||
key: self.fixture.sessionKey,
|
||||
kind: "chat",
|
||||
displayName: "Apple Review Demo",
|
||||
displayName: self.fixture.displayName,
|
||||
surface: "ios",
|
||||
subject: "Gateway review flow",
|
||||
subject: self.fixture.subject,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: Date().timeIntervalSince1970 * 1000,
|
||||
sessionId: "apple-review-demo-main",
|
||||
sessionId: "\(self.fixture.sessionIDPrefix)-\(self.fixture.sessionKey)",
|
||||
systemSent: true,
|
||||
abortedLastRun: false,
|
||||
thinkingLevel: "auto",
|
||||
@@ -163,52 +339,51 @@ private actor AppleReviewDemoChatStore {
|
||||
inputTokens: nil,
|
||||
outputTokens: nil,
|
||||
totalTokens: nil,
|
||||
modelProvider: "demo",
|
||||
model: "local-demo",
|
||||
modelProvider: self.fixture.modelProvider,
|
||||
model: self.fixture.modelID,
|
||||
contextTokens: 128_000,
|
||||
thinkingLevels: [
|
||||
OpenClawChatThinkingLevelOption(id: "auto", label: "Auto"),
|
||||
OpenClawChatThinkingLevelOption(id: "low", label: "Low"),
|
||||
OpenClawChatThinkingLevelOption(id: "medium", label: "Medium"),
|
||||
],
|
||||
thinkingOptions: ["auto", "low", "medium"],
|
||||
thinkingLevels: Self.thinkingLevels,
|
||||
thinkingOptions: Self.thinkingOptions,
|
||||
thinkingDefault: "auto")
|
||||
return OpenClawChatSessionsListResponse(
|
||||
ts: Date().timeIntervalSince1970 * 1000,
|
||||
path: nil,
|
||||
count: 1,
|
||||
defaults: OpenClawChatSessionsDefaults(
|
||||
modelProvider: "demo",
|
||||
model: "local-demo",
|
||||
modelProvider: self.fixture.modelProvider,
|
||||
model: self.fixture.modelID,
|
||||
contextTokens: 128_000,
|
||||
thinkingLevels: [
|
||||
OpenClawChatThinkingLevelOption(id: "auto", label: "Auto"),
|
||||
OpenClawChatThinkingLevelOption(id: "low", label: "Low"),
|
||||
OpenClawChatThinkingLevelOption(id: "medium", label: "Medium"),
|
||||
],
|
||||
thinkingOptions: ["auto", "low", "medium"],
|
||||
thinkingLevels: Self.thinkingLevels,
|
||||
thinkingOptions: Self.thinkingOptions,
|
||||
thinkingDefault: "auto",
|
||||
mainSessionKey: self.sessionKey),
|
||||
mainSessionKey: self.fixture.sessionKey),
|
||||
sessions: [entry])
|
||||
}
|
||||
|
||||
func reset() {
|
||||
self.messages = Self.seedMessages()
|
||||
self.messages = Self.seedMessages(fixture: self.fixture)
|
||||
}
|
||||
|
||||
private static func seedMessages() -> [OpenClawChatMessage] {
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
return [
|
||||
self.message(
|
||||
role: "assistant",
|
||||
text: """
|
||||
Apple Review demo mode is active. This local chat transport lets reviewers inspect the iOS app \
|
||||
without a private Gateway.
|
||||
""",
|
||||
timestamp: now),
|
||||
private static var thinkingOptions: [String] {
|
||||
["auto", "low", "medium", "high"]
|
||||
}
|
||||
|
||||
private static var thinkingLevels: [OpenClawChatThinkingLevelOption] {
|
||||
[
|
||||
OpenClawChatThinkingLevelOption(id: "auto", label: "Auto"),
|
||||
OpenClawChatThinkingLevelOption(id: "low", label: "Low"),
|
||||
OpenClawChatThinkingLevelOption(id: "medium", label: "Medium"),
|
||||
OpenClawChatThinkingLevelOption(id: "high", label: "High"),
|
||||
]
|
||||
}
|
||||
|
||||
private static func seedMessages(fixture: LocalChatFixture) -> [OpenClawChatMessage] {
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
return fixture.seedMessages.enumerated().map { index, text in
|
||||
self.message(role: "assistant", text: text, timestamp: now + Double(index))
|
||||
}
|
||||
}
|
||||
|
||||
private static func message(role: String, text: String, timestamp: Double) -> OpenClawChatMessage {
|
||||
OpenClawChatMessage(
|
||||
role: role,
|
||||
@@ -223,9 +398,9 @@ private actor AppleReviewDemoChatStore {
|
||||
timestamp: timestamp)
|
||||
}
|
||||
|
||||
private static func normalizedSessionKey(_ value: String) -> String {
|
||||
private static func normalizedSessionKey(_ value: String, fallback: String) -> String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? "main" : trimmed
|
||||
return trimmed.isEmpty ? fallback : trimmed
|
||||
}
|
||||
|
||||
private static func decode<T: Decodable>(_ value: some Encodable, as type: T.Type) throws -> T {
|
||||
|
||||
@@ -5,7 +5,7 @@ import OpenClawProtocol
|
||||
import OSLog
|
||||
|
||||
struct IOSGatewayChatTransport: OpenClawChatTransport {
|
||||
static let logger = Logger(subsystem: "ai.openclaw", category: "ios.chat.transport")
|
||||
static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "ios.chat.transport")
|
||||
static let defaultChatSendTimeoutMs = 30000
|
||||
private let gateway: GatewayNodeSession
|
||||
|
||||
|
||||
@@ -303,7 +303,7 @@ extension AgentProTab {
|
||||
}
|
||||
.padding(.vertical, 14)
|
||||
.padding(.horizontal, 13)
|
||||
.frame(minHeight: AgentLayout.rowMinHeight, alignment: .center)
|
||||
.frame(maxWidth: .infinity, minHeight: AgentLayout.rowMinHeight, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
self.appModel.setSelectedAgentId(agent.id)
|
||||
@@ -557,7 +557,7 @@ extension AgentProTab {
|
||||
}
|
||||
|
||||
var liveGatewayConnected: Bool {
|
||||
!self.appModel.isAppleReviewDemoModeEnabled &&
|
||||
!self.appModel.isLocalGatewayFixtureEnabled &&
|
||||
self.gatewayConnected &&
|
||||
self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ struct ChatProTab: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var viewModel: OpenClawChatViewModel?
|
||||
@State private var viewModelUsesAppleReviewDemoTransport = false
|
||||
@State private var viewModelTransportModeID = ""
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let headerTitle: String?
|
||||
let headerSubtitle: String?
|
||||
@@ -64,6 +64,7 @@ struct ChatProTab: View {
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.safeAreaPadding(.top, 8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.navigationBarHidden(true)
|
||||
@@ -78,6 +79,10 @@ struct ChatProTab: View {
|
||||
self.syncChatViewModel()
|
||||
self.viewModel?.refresh()
|
||||
}
|
||||
.onChange(of: self.appModel.isScreenshotFixtureModeEnabled) { _, _ in
|
||||
self.syncChatViewModel()
|
||||
self.viewModel?.refresh()
|
||||
}
|
||||
.onChange(of: self.appModel.isOperatorGatewayConnected) { _, connected in
|
||||
guard connected else { return }
|
||||
self.syncChatViewModel()
|
||||
@@ -103,7 +108,6 @@ struct ChatProTab: View {
|
||||
self.connectionPillButton
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
@@ -135,14 +139,12 @@ struct ChatProTab: View {
|
||||
|
||||
private func syncChatViewModel() {
|
||||
let sessionKey = self.appModel.chatSessionKey
|
||||
let usesDemoTransport = self.appModel.isAppleReviewDemoModeEnabled
|
||||
let transportModeID = self.appModel.chatTransportModeID
|
||||
guard let viewModel else {
|
||||
self.viewModelUsesAppleReviewDemoTransport = usesDemoTransport
|
||||
self.viewModelTransportModeID = transportModeID
|
||||
self.viewModel = OpenClawChatViewModel(
|
||||
sessionKey: sessionKey,
|
||||
transport: usesDemoTransport
|
||||
? AppleReviewDemoChatTransport()
|
||||
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession),
|
||||
transport: self.appModel.makeChatTransport(),
|
||||
onSessionChanged: { sessionKey in
|
||||
self.appModel.focusChatSession(sessionKey)
|
||||
},
|
||||
@@ -151,13 +153,11 @@ struct ChatProTab: View {
|
||||
})
|
||||
return
|
||||
}
|
||||
if self.viewModelUsesAppleReviewDemoTransport != usesDemoTransport {
|
||||
self.viewModelUsesAppleReviewDemoTransport = usesDemoTransport
|
||||
if self.viewModelTransportModeID != transportModeID {
|
||||
self.viewModelTransportModeID = transportModeID
|
||||
self.viewModel = OpenClawChatViewModel(
|
||||
sessionKey: sessionKey,
|
||||
transport: usesDemoTransport
|
||||
? AppleReviewDemoChatTransport()
|
||||
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession),
|
||||
transport: self.appModel.makeChatTransport(),
|
||||
onSessionChanged: { sessionKey in
|
||||
self.appModel.focusChatSession(sessionKey)
|
||||
},
|
||||
@@ -226,7 +226,7 @@ struct ChatProTab: View {
|
||||
guard self.gatewayDisplayState == .connected else {
|
||||
return false
|
||||
}
|
||||
return self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
|
||||
return self.appModel.isLocalChatFixtureEnabled || self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
private var gatewayDisplayState: GatewayDisplayState {
|
||||
|
||||
@@ -370,12 +370,11 @@ struct CommandCenterTab: View {
|
||||
}
|
||||
|
||||
private var sessionListAvailable: Bool {
|
||||
self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
|
||||
self.appModel.isLocalChatFixtureEnabled || self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
private var sessionListMode: String {
|
||||
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
|
||||
return self.appModel.isOperatorGatewayConnected ? "operator" : "offline"
|
||||
self.appModel.chatTransportModeID
|
||||
}
|
||||
|
||||
private var sessionItems: [WorkItem] {
|
||||
@@ -414,9 +413,7 @@ struct CommandCenterTab: View {
|
||||
}
|
||||
|
||||
do {
|
||||
let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled
|
||||
? AppleReviewDemoChatTransport()
|
||||
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession)
|
||||
let transport = self.appModel.makeChatTransport()
|
||||
let response = try await transport.listSessions(limit: Self.recentSessionsFetchLimit)
|
||||
self.defaultChatSessionEntry = response.sessions.first {
|
||||
$0.key == self.appModel.defaultChatSessionKey
|
||||
@@ -765,9 +762,7 @@ struct CommandSessionsScreen: View {
|
||||
defer { self.isLoading = false }
|
||||
|
||||
do {
|
||||
let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled
|
||||
? AppleReviewDemoChatTransport()
|
||||
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession)
|
||||
let transport = self.appModel.makeChatTransport()
|
||||
let response = try await transport.listSessions(limit: CommandCenterTab.recentSessionsFetchLimit)
|
||||
self.sessions = response.sessions
|
||||
} catch {
|
||||
@@ -779,11 +774,10 @@ struct CommandSessionsScreen: View {
|
||||
|
||||
extension NodeAppModel {
|
||||
fileprivate var isCommandSessionListAvailable: Bool {
|
||||
self.isAppleReviewDemoModeEnabled || self.isOperatorGatewayConnected
|
||||
self.isLocalChatFixtureEnabled || self.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
fileprivate var commandSessionListMode: String {
|
||||
if self.isAppleReviewDemoModeEnabled { return "demo" }
|
||||
return self.isOperatorGatewayConnected ? "operator" : "offline"
|
||||
self.chatTransportModeID
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,12 +180,11 @@ struct IPadActivityScreen: View {
|
||||
}
|
||||
|
||||
private var sessionsAvailable: Bool {
|
||||
self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
|
||||
self.appModel.isLocalChatFixtureEnabled || self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
private var sessionsMode: String {
|
||||
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
|
||||
return self.appModel.isOperatorGatewayConnected ? "operator" : "offline"
|
||||
self.appModel.chatTransportModeID
|
||||
}
|
||||
|
||||
private var sessionRows: [CommandCenterTab.WorkItem] {
|
||||
@@ -215,9 +214,7 @@ struct IPadActivityScreen: View {
|
||||
defer { self.isLoading = false }
|
||||
|
||||
do {
|
||||
let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled
|
||||
? AppleReviewDemoChatTransport()
|
||||
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession)
|
||||
let transport = self.appModel.makeChatTransport()
|
||||
let response = try await transport.listSessions(limit: CommandCenterTab.recentSessionsFetchLimit)
|
||||
self.sessions = response.sessions
|
||||
} catch {
|
||||
|
||||
@@ -6,7 +6,7 @@ final class NetworkStatusService: @unchecked Sendable {
|
||||
func currentStatus(timeoutMs: Int = 1500) async -> OpenClawNetworkStatusPayload {
|
||||
await withCheckedContinuation { cont in
|
||||
let monitor = NWPathMonitor()
|
||||
let queue = DispatchQueue(label: "ai.openclaw.ios.network-status")
|
||||
let queue = DispatchQueue(label: "ai.openclawfoundation.app.network-status")
|
||||
let state = NetworkStatusState()
|
||||
|
||||
monitor.pathUpdateHandler = { path in
|
||||
|
||||
@@ -125,6 +125,7 @@ final class GatewayConnectionController {
|
||||
private(set) var pendingTrustPrompt: TrustPrompt?
|
||||
|
||||
private let discovery = GatewayDiscoveryModel()
|
||||
private let discoveryEnabled: Bool
|
||||
private weak var appModel: NodeAppModel?
|
||||
private var didAutoConnect = false
|
||||
private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:]
|
||||
@@ -137,6 +138,7 @@ final class GatewayConnectionController {
|
||||
}
|
||||
|
||||
init(appModel: NodeAppModel, startDiscovery: Bool = true) {
|
||||
self.discoveryEnabled = startDiscovery
|
||||
self.appModel = appModel
|
||||
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
@@ -146,7 +148,7 @@ final class GatewayConnectionController {
|
||||
self.updateFromDiscovery()
|
||||
self.observeDiscovery()
|
||||
|
||||
if startDiscovery {
|
||||
if self.discoveryEnabled {
|
||||
self.discovery.start()
|
||||
}
|
||||
}
|
||||
@@ -156,6 +158,11 @@ final class GatewayConnectionController {
|
||||
}
|
||||
|
||||
func setScenePhase(_ phase: ScenePhase) {
|
||||
guard self.discoveryEnabled else {
|
||||
self.discovery.stop()
|
||||
return
|
||||
}
|
||||
|
||||
switch phase {
|
||||
case .background:
|
||||
self.discovery.stop()
|
||||
@@ -169,6 +176,12 @@ final class GatewayConnectionController {
|
||||
}
|
||||
|
||||
func restartDiscovery() {
|
||||
guard self.discoveryEnabled else {
|
||||
self.discovery.stop()
|
||||
self.updateFromDiscovery()
|
||||
return
|
||||
}
|
||||
|
||||
self.discovery.stop()
|
||||
self.didAutoConnect = false
|
||||
self.discovery.start()
|
||||
|
||||
@@ -59,7 +59,7 @@ final class GatewayDiscoveryModel {
|
||||
let browser = GatewayDiscoveryBrowserSupport.makeBrowser(
|
||||
serviceType: OpenClawBonjour.gatewayServiceType,
|
||||
domain: domain,
|
||||
queueLabelPrefix: "ai.openclaw.ios.gateway-discovery",
|
||||
queueLabelPrefix: "ai.openclawfoundation.app.gateway-discovery",
|
||||
onState: { [weak self] state in
|
||||
guard let self else { return }
|
||||
self.statesByDomain[domain] = state
|
||||
|
||||
@@ -2,9 +2,9 @@ import Foundation
|
||||
import os
|
||||
|
||||
enum GatewaySettingsStore {
|
||||
private static let gatewayService = "ai.openclaw.gateway"
|
||||
private static let nodeService = "ai.openclaw.node"
|
||||
private static let talkService = "ai.openclaw.talk"
|
||||
private static let gatewayService = "ai.openclawfoundation.app.gateway"
|
||||
private static let nodeService = "ai.openclawfoundation.app.node"
|
||||
private static let talkService = "ai.openclawfoundation.app.talk"
|
||||
|
||||
private static let instanceIdDefaultsKey = "node.instanceId"
|
||||
private static let preferredGatewayStableIDDefaultsKey = "gateway.preferredStableID"
|
||||
@@ -443,8 +443,8 @@ enum GatewaySettingsStore {
|
||||
}
|
||||
|
||||
enum GatewayDiagnostics {
|
||||
private static let logger = Logger(subsystem: "ai.openclaw.ios", category: "GatewayDiag")
|
||||
private static let queue = DispatchQueue(label: "ai.openclaw.gateway.diagnostics")
|
||||
private static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "GatewayDiag")
|
||||
private static let queue = DispatchQueue(label: "ai.openclawfoundation.app.gateway.diagnostics")
|
||||
private static let maxLogBytes: Int64 = 512 * 1024
|
||||
private static let keepLogBytes: Int64 = 256 * 1024
|
||||
private static let logSizeCheckEveryWrites = 50
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<dict>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>ai.openclaw.ios.bgrefresh</string>
|
||||
<string>$(OPENCLAW_APP_BUNDLE_ID).bgrefresh</string>
|
||||
</array>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
@@ -28,7 +28,7 @@
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>ai.openclaw.ios</string>
|
||||
<string>ai.openclawfoundation.app</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>openclaw</string>
|
||||
|
||||
@@ -7,7 +7,7 @@ import os
|
||||
final class LiveActivityManager {
|
||||
static let shared = LiveActivityManager()
|
||||
|
||||
private let logger = Logger(subsystem: "ai.openclaw.ios", category: "LiveActivity")
|
||||
private let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "LiveActivity")
|
||||
private let connectingStaleSeconds: TimeInterval = 120
|
||||
private let hydrationStaleSeconds: TimeInterval = 300
|
||||
private var currentActivity: Activity<OpenClawActivityAttributes>?
|
||||
|
||||
@@ -88,14 +88,14 @@ final class NodeAppModel {
|
||||
var pendingApprovalIDs: [String]?
|
||||
}
|
||||
|
||||
private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink")
|
||||
private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake")
|
||||
private let pendingActionLogger = Logger(subsystem: "ai.openclaw.ios", category: "PendingAction")
|
||||
private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake")
|
||||
private let watchReplyLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchReply")
|
||||
private let watchExecApprovalLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchExecApproval")
|
||||
private let deepLinkLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "DeepLink")
|
||||
private let pushWakeLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "PushWake")
|
||||
private let pendingActionLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "PendingAction")
|
||||
private let locationWakeLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "LocationWake")
|
||||
private let watchReplyLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "WatchReply")
|
||||
private let watchExecApprovalLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "WatchExecApproval")
|
||||
private let execApprovalNotificationLogger = Logger(
|
||||
subsystem: "ai.openclaw.ios",
|
||||
subsystem: "ai.openclawfoundation.app",
|
||||
category: "ExecApprovalNotification")
|
||||
enum CameraHUDKind {
|
||||
case photo
|
||||
@@ -112,6 +112,7 @@ final class NodeAppModel {
|
||||
var nodeStatusText: String = "Offline"
|
||||
var operatorStatusText: String = "Offline"
|
||||
private(set) var isAppleReviewDemoModeEnabled: Bool = false
|
||||
private(set) var isScreenshotFixtureModeEnabled: Bool = false
|
||||
var isOperatorGatewayConnected: Bool {
|
||||
self.operatorConnected
|
||||
}
|
||||
@@ -206,6 +207,36 @@ final class NodeAppModel {
|
||||
self.operatorGateway
|
||||
}
|
||||
|
||||
var localChatFixture: LocalChatFixture? {
|
||||
if self.isScreenshotFixtureModeEnabled { return .appScreenshots }
|
||||
if self.isAppleReviewDemoModeEnabled { return .appleReviewDemo }
|
||||
return nil
|
||||
}
|
||||
|
||||
var isLocalChatFixtureEnabled: Bool {
|
||||
self.localChatFixture != nil
|
||||
}
|
||||
|
||||
var isLocalGatewayFixtureEnabled: Bool {
|
||||
self.isAppleReviewDemoModeEnabled || self.isScreenshotFixtureModeEnabled
|
||||
}
|
||||
|
||||
var chatTransportModeID: String {
|
||||
if self.isScreenshotFixtureModeEnabled { return "screenshots" }
|
||||
if self.isAppleReviewDemoModeEnabled { return "apple-review-demo" }
|
||||
return self.isOperatorGatewayConnected ? "operator" : "offline"
|
||||
}
|
||||
|
||||
func makeChatTransport() -> any OpenClawChatTransport {
|
||||
if self.isScreenshotFixtureModeEnabled {
|
||||
return LocalFixtureChatTransport(fixture: .appScreenshots)
|
||||
}
|
||||
if self.isAppleReviewDemoModeEnabled {
|
||||
return AppleReviewDemoChatTransport()
|
||||
}
|
||||
return IOSGatewayChatTransport(gateway: self.operatorSession)
|
||||
}
|
||||
|
||||
private(set) var activeGatewayConnectConfig: GatewayConnectConfig?
|
||||
|
||||
private static let watchExecApprovalBridgeStateKey = "watch.execApproval.bridge.state.v1"
|
||||
@@ -547,7 +578,7 @@ final class NodeAppModel {
|
||||
await self.operatorGateway.disconnect()
|
||||
await self.nodeGateway.disconnect()
|
||||
await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
self.setOperatorConnected(false)
|
||||
self.gatewayConnected = false
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
@@ -914,7 +945,7 @@ final class NodeAppModel {
|
||||
await self.operatorGateway.disconnect()
|
||||
await self.nodeGateway.disconnect()
|
||||
await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
self.setOperatorConnected(false)
|
||||
self.gatewayConnected = false
|
||||
self.gatewayStatusText = "Reconnecting…"
|
||||
@@ -1963,6 +1994,7 @@ extension NodeAppModel {
|
||||
/// Preferred entry-point: apply a single config object and start both sessions.
|
||||
func applyGatewayConnectConfig(_ cfg: GatewayConnectConfig, forceReconnect: Bool = false) {
|
||||
self.isAppleReviewDemoModeEnabled = false
|
||||
self.isScreenshotFixtureModeEnabled = false
|
||||
self.connectToGateway(
|
||||
url: cfg.url,
|
||||
// Preserve the caller-provided stableID (may be empty) and let connectToGateway
|
||||
@@ -1997,7 +2029,7 @@ extension NodeAppModel {
|
||||
|
||||
private func restartGatewaySessionsAfterForegroundStaleConnection() async {
|
||||
await self.resetGatewaySessionsForForcedReconnect()
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
self.setOperatorConnected(false)
|
||||
self.gatewayConnected = false
|
||||
self.gatewayStatusText = "Reconnecting…"
|
||||
@@ -2008,6 +2040,7 @@ extension NodeAppModel {
|
||||
|
||||
func disconnectGateway() {
|
||||
self.isAppleReviewDemoModeEnabled = false
|
||||
self.isScreenshotFixtureModeEnabled = false
|
||||
self.gatewayAutoReconnectEnabled = false
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
@@ -2043,6 +2076,7 @@ extension NodeAppModel {
|
||||
extension NodeAppModel {
|
||||
private func prepareForGatewayConnect(stableID: String) {
|
||||
self.isAppleReviewDemoModeEnabled = false
|
||||
self.isScreenshotFixtureModeEnabled = false
|
||||
self.gatewayAutoReconnectEnabled = true
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
@@ -2085,7 +2119,7 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
private func applyGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
self.lastGatewayProblem = problem
|
||||
self.gatewayStatusText = problem.statusText
|
||||
self.gatewayServerName = nil
|
||||
@@ -2111,7 +2145,7 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
private func applyOperatorGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
self.operatorGatewayProblem = problem
|
||||
self.lastGatewayProblem = problem
|
||||
self.gatewayStatusText = problem.statusText
|
||||
@@ -2344,7 +2378,7 @@ extension NodeAppModel {
|
||||
onConnected: { [weak self] in
|
||||
guard let self else { return }
|
||||
let shouldUseConnection = await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return false }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return false }
|
||||
self.setOperatorConnected(true)
|
||||
self.clearOperatorGatewayConnectionProblemIfCurrent()
|
||||
self.forceOperatorTalkPermissionUpgradeRequest = false
|
||||
@@ -2366,7 +2400,7 @@ extension NodeAppModel {
|
||||
onDisconnected: { [weak self] reason in
|
||||
guard let self else { return }
|
||||
await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
self.setOperatorConnected(false)
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
LiveActivityManager.shared.endActivity(reason: "operator_disconnected")
|
||||
@@ -2391,7 +2425,7 @@ extension NodeAppModel {
|
||||
GatewayDiagnostics.log("operator gateway connect error: \(error.localizedDescription)")
|
||||
let problem: GatewayConnectionProblem? = await MainActor.run {
|
||||
let nextProblem = GatewayConnectionProblemMapper.map(error: error)
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return nil }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return nil }
|
||||
if let nextProblem {
|
||||
if nextProblem.needsPairingApproval || nextProblem.pauseReconnect {
|
||||
self.applyOperatorGatewayConnectionProblem(nextProblem)
|
||||
@@ -2457,7 +2491,7 @@ extension NodeAppModel {
|
||||
continue
|
||||
}
|
||||
await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
self.gatewayStatusText = (attempt == 0) ? "Connecting…" : "Reconnecting…"
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
@@ -2485,7 +2519,7 @@ extension NodeAppModel {
|
||||
onConnected: { [weak self] in
|
||||
guard let self else { return }
|
||||
let shouldUseConnection = await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return false }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return false }
|
||||
self.clearGatewayConnectionProblem()
|
||||
self.gatewayStatusText = "Connected"
|
||||
self.gatewayServerName = url.host ?? "gateway"
|
||||
@@ -2544,7 +2578,7 @@ extension NodeAppModel {
|
||||
onDisconnected: { [weak self] reason in
|
||||
guard let self else { return }
|
||||
await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
if self.shouldKeepGatewayProblemStatus(forDisconnectReason: reason),
|
||||
let lastGatewayProblem = self.lastGatewayProblem
|
||||
{
|
||||
@@ -2594,7 +2628,7 @@ extension NodeAppModel {
|
||||
let nextProblem = GatewayConnectionProblemMapper.map(
|
||||
error: error,
|
||||
preserving: self.lastGatewayProblem)
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return nil }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return nil }
|
||||
if let nextProblem {
|
||||
self.applyGatewayConnectionProblem(nextProblem)
|
||||
} else {
|
||||
@@ -2635,7 +2669,7 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
self.lastGatewayProblem = nil
|
||||
self.gatewayStatusText = "Offline"
|
||||
LiveActivityManager.shared.endActivity(reason: "gateway_loop_stopped")
|
||||
@@ -2785,6 +2819,7 @@ extension NodeAppModel {
|
||||
extension NodeAppModel {
|
||||
func enterAppleReviewDemoMode() {
|
||||
self.isAppleReviewDemoModeEnabled = true
|
||||
self.isScreenshotFixtureModeEnabled = false
|
||||
self.gatewayAutoReconnectEnabled = false
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
@@ -2825,6 +2860,47 @@ extension NodeAppModel {
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
self.homeCanvasRevision &+= 1
|
||||
}
|
||||
|
||||
func enterScreenshotFixtureMode() {
|
||||
self.isAppleReviewDemoModeEnabled = false
|
||||
self.isScreenshotFixtureModeEnabled = true
|
||||
self.gatewayAutoReconnectEnabled = false
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
self.lastGatewayProblem = nil
|
||||
self.operatorGatewayProblem = nil
|
||||
self.nodeGatewayTask?.cancel()
|
||||
self.nodeGatewayTask = nil
|
||||
self.operatorGatewayTask?.cancel()
|
||||
self.operatorGatewayTask = nil
|
||||
self.voiceWakeSyncTask?.cancel()
|
||||
self.voiceWakeSyncTask = nil
|
||||
self.gatewayHealthMonitor.stop()
|
||||
LiveActivityManager.shared.endActivity(reason: "screenshot_fixture")
|
||||
|
||||
Task {
|
||||
await self.operatorGateway.disconnect()
|
||||
await self.nodeGateway.disconnect()
|
||||
}
|
||||
|
||||
self.gatewayStatusText = "Connected"
|
||||
self.nodeStatusText = "Connected"
|
||||
self.gatewayServerName = ScreenshotFixtureMode.gatewayName
|
||||
self.gatewayRemoteAddress = ScreenshotFixtureMode.gatewayAddress
|
||||
self.connectedGatewayID = ScreenshotFixtureMode.gatewayID
|
||||
self.activeGatewayConnectConfig = nil
|
||||
self.gatewayConnected = true
|
||||
self.setOperatorConnected(true)
|
||||
self.hasOperatorAdminScope = true
|
||||
self.mainSessionBaseKey = "main"
|
||||
self.selectedAgentId = nil
|
||||
self.gatewayDefaultAgentId = "main"
|
||||
self.gatewayAgents = ScreenshotFixtureMode.agents
|
||||
self.focusedChatSessionKey = nil
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
self.talkMode.enterScreenshotFixtureMode()
|
||||
self.homeCanvasRevision &+= 1
|
||||
}
|
||||
}
|
||||
|
||||
extension NodeAppModel {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<string>$(OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
|
||||
@@ -22,9 +22,22 @@ enum OpenClawAppModelRegistry {
|
||||
|
||||
@MainActor
|
||||
final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate {
|
||||
private let logger = Logger(subsystem: "ai.openclaw.ios", category: "Push")
|
||||
private let backgroundWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "BackgroundWake")
|
||||
private static let wakeRefreshTaskIdentifier = "ai.openclaw.ios.bgrefresh"
|
||||
private let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "Push")
|
||||
private let backgroundWakeLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "BackgroundWake")
|
||||
private static var wakeRefreshTaskIdentifier: String {
|
||||
"\(appBundleIdentifier).bgrefresh"
|
||||
}
|
||||
|
||||
private static var appBundleIdentifier: String {
|
||||
guard let bundleId = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!bundleId.isEmpty
|
||||
else {
|
||||
return "ai.openclawfoundation.app"
|
||||
}
|
||||
|
||||
return bundleId
|
||||
}
|
||||
|
||||
private var backgroundWakeTask: Task<Bool, Never>?
|
||||
private var pendingAPNsDeviceToken: Data?
|
||||
private var pendingWatchPromptActions: [PendingWatchPromptAction] = []
|
||||
@@ -92,6 +105,10 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
func _test_resolvedAppModel() -> NodeAppModel? {
|
||||
self.resolvedAppModel()
|
||||
}
|
||||
|
||||
func _test_wakeRefreshTaskIdentifier() -> String {
|
||||
Self.wakeRefreshTaskIdentifier
|
||||
}
|
||||
#endif
|
||||
|
||||
func application(
|
||||
@@ -611,9 +628,21 @@ struct OpenClawApp: App {
|
||||
Self.installUncaughtExceptionLogger()
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
let appModel = NodeAppModel()
|
||||
#if DEBUG
|
||||
if Self.screenshotModeEnabled {
|
||||
UIView.setAnimationsEnabled(false)
|
||||
UserDefaults.standard.set(true, forKey: "gateway.onboardingComplete")
|
||||
UserDefaults.standard.set(true, forKey: "gateway.hasConnectedOnce")
|
||||
UserDefaults.standard.set(true, forKey: "onboarding.quickSetupDismissed")
|
||||
appModel.enterScreenshotFixtureMode()
|
||||
}
|
||||
#endif
|
||||
OpenClawAppModelRegistry.appModel = appModel
|
||||
_appModel = State(initialValue: appModel)
|
||||
_gatewayController = State(initialValue: GatewayConnectionController(appModel: appModel))
|
||||
_gatewayController = State(
|
||||
initialValue: GatewayConnectionController(
|
||||
appModel: appModel,
|
||||
startDiscovery: !Self.screenshotModeEnabled))
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
@@ -649,6 +678,14 @@ struct OpenClawApp: App {
|
||||
?? .system
|
||||
}
|
||||
|
||||
private static var screenshotModeEnabled: Bool {
|
||||
#if DEBUG
|
||||
ProcessInfo.processInfo.arguments.contains("--openclaw-screenshot-mode")
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyAppearancePreference() {
|
||||
let style = self.appearancePreference.userInterfaceStyle
|
||||
|
||||
@@ -13,7 +13,7 @@ private struct StoredPushRelayRegistrationState: Codable {
|
||||
}
|
||||
|
||||
enum PushRelayRegistrationStore {
|
||||
private static let service = "ai.openclaw.pushrelay"
|
||||
private static let service = "ai.openclawfoundation.app.pushrelay"
|
||||
private static let registrationStateAccount = "registration-state"
|
||||
private static let appAttestKeyIDAccount = "app-attest-key-id"
|
||||
private static let appAttestedKeyIDAccount = "app-attested-key-id"
|
||||
|
||||
@@ -56,7 +56,7 @@ final class ScreenRecordService: @unchecked Sendable {
|
||||
outPath: outPath)
|
||||
|
||||
let state = CaptureState()
|
||||
let recordQueue = DispatchQueue(label: "ai.openclaw.screenrecord")
|
||||
let recordQueue = DispatchQueue(label: "ai.openclawfoundation.app.screenrecord")
|
||||
|
||||
try await self.startCapture(state: state, config: config, recordQueue: recordQueue)
|
||||
try await Task.sleep(nanoseconds: UInt64(config.durationMs) * 1_000_000)
|
||||
|
||||
@@ -26,7 +26,7 @@ private func sendReachableWatchMessage(_ payload: [String: Any], with session: W
|
||||
}
|
||||
|
||||
final class WatchConnectivityTransport: NSObject, @unchecked Sendable {
|
||||
private nonisolated static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging")
|
||||
private nonisolated static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "watch.messaging")
|
||||
|
||||
private let session: WCSession?
|
||||
private let callbacksLock = NSLock()
|
||||
|
||||
@@ -121,13 +121,18 @@ struct PrivacyAccessSectionView: View {
|
||||
switch self.contactsStatus {
|
||||
case .notDetermined:
|
||||
Task {
|
||||
_ = await PermissionRequestBridge.awaitRequest { completion in
|
||||
let granted = await PermissionRequestBridge.awaitRequest { completion in
|
||||
let store = CNContactStore()
|
||||
store.requestAccess(for: .contacts) { granted, _ in
|
||||
completion(granted)
|
||||
}
|
||||
}
|
||||
await MainActor.run { self.refreshAll() }
|
||||
await MainActor.run {
|
||||
self.refreshAll()
|
||||
if granted {
|
||||
self.contactsStatus = .authorized
|
||||
}
|
||||
}
|
||||
}
|
||||
case .denied, .restricted:
|
||||
self.openSettings()
|
||||
@@ -164,8 +169,13 @@ struct PrivacyAccessSectionView: View {
|
||||
switch self.calendarStatus {
|
||||
case .notDetermined:
|
||||
Task {
|
||||
_ = await self.requestCalendarWriteOnly()
|
||||
await MainActor.run { self.refreshAll() }
|
||||
let granted = await self.requestCalendarWriteOnly()
|
||||
await MainActor.run {
|
||||
self.refreshAll()
|
||||
if granted {
|
||||
self.calendarStatus = .writeOnly
|
||||
}
|
||||
}
|
||||
}
|
||||
case .denied, .restricted:
|
||||
self.openSettings()
|
||||
@@ -206,8 +216,13 @@ struct PrivacyAccessSectionView: View {
|
||||
switch self.calendarStatus {
|
||||
case .notDetermined, .writeOnly:
|
||||
Task {
|
||||
_ = await self.requestCalendarFull()
|
||||
await MainActor.run { self.refreshAll() }
|
||||
let granted = await self.requestCalendarFull()
|
||||
await MainActor.run {
|
||||
self.refreshAll()
|
||||
if granted {
|
||||
self.calendarStatus = .fullAccess
|
||||
}
|
||||
}
|
||||
}
|
||||
case .denied, .restricted:
|
||||
self.openSettings()
|
||||
@@ -248,8 +263,13 @@ struct PrivacyAccessSectionView: View {
|
||||
switch self.remindersStatus {
|
||||
case .notDetermined, .writeOnly:
|
||||
Task {
|
||||
_ = await self.requestRemindersFull()
|
||||
await MainActor.run { self.refreshAll() }
|
||||
let granted = await self.requestRemindersFull()
|
||||
await MainActor.run {
|
||||
self.refreshAll()
|
||||
if granted {
|
||||
self.remindersStatus = .fullAccess
|
||||
}
|
||||
}
|
||||
}
|
||||
case .denied, .restricted:
|
||||
self.openSettings()
|
||||
|
||||
@@ -121,7 +121,7 @@ final class RealtimeTalkRelaySession {
|
||||
private let gateway: GatewayNodeSession
|
||||
private let options: Options
|
||||
private let pcmPlayer: PCMStreamingAudioPlaying
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "RealtimeTalkRelay")
|
||||
private let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "RealtimeTalkRelay")
|
||||
private let onStatus: (String) -> Void
|
||||
private let onIssue: (TalkRuntimeIssue) -> Void
|
||||
private let onSpeakingChanged: (Bool) -> Void
|
||||
|
||||
@@ -170,7 +170,7 @@ final class TalkModeManager: NSObject {
|
||||
private var incrementalSpeechPrefetch: IncrementalSpeechPrefetchState?
|
||||
private var incrementalSpeechPrefetchMonitorTask: Task<Void, Never>?
|
||||
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "TalkMode")
|
||||
private let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "TalkMode")
|
||||
|
||||
private static func nowSeconds() -> TimeInterval {
|
||||
ProcessInfo.processInfo.systemUptime
|
||||
@@ -220,6 +220,34 @@ final class TalkModeManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
func enterScreenshotFixtureMode() {
|
||||
self.updateGatewayConnected(true)
|
||||
self.isEnabled = false
|
||||
self.isListening = false
|
||||
self.isSpeaking = false
|
||||
self.isUserSpeechDetected = false
|
||||
self.statusText = "Ready"
|
||||
self.gatewayTalkConfigLoaded = true
|
||||
self.gatewayTalkApiKeyConfigured = true
|
||||
self.gatewayTalkDefaultModelId = "gpt-realtime-2"
|
||||
self.gatewayTalkDefaultVoiceId = "marin"
|
||||
self.gatewayTalkProviderLabel = "OpenAI"
|
||||
self.gatewayTalkTransportLabel = "Gateway Relay"
|
||||
self.gatewayTalkUsesRealtime = true
|
||||
self.gatewayTalkUsesRealtimeRelay = true
|
||||
self.gatewayTalkRealtimeProviderLabel = "OpenAI"
|
||||
self.gatewayTalkRealtimeModelId = "gpt-realtime-2"
|
||||
self.gatewayTalkRealtimeVoiceId = "marin"
|
||||
self.gatewayTalkVoiceModeTitle = "Realtime Voice"
|
||||
self.gatewayTalkVoiceModeSubtitle = "Gateway relay ready"
|
||||
self.gatewayTalkVoiceModeAccessibilityValue = "Realtime Voice, Gateway relay ready"
|
||||
self.gatewayTalkActiveModeTitle = "Ready"
|
||||
self.gatewayTalkActiveModeSubtitle = "Listening starts from this phone"
|
||||
self.gatewayTalkLastIssueText = nil
|
||||
self.gatewayTalkCurrentFallbackIssue = nil
|
||||
self.gatewayTalkPermissionState = .ready
|
||||
}
|
||||
|
||||
func setEnabled(_ enabled: Bool) {
|
||||
self.isEnabled = enabled
|
||||
if enabled {
|
||||
|
||||
@@ -17,7 +17,7 @@ protocol TalkRealtimeWebRTCSessionDelegate: AnyObject {
|
||||
|
||||
@MainActor
|
||||
final class TalkRealtimeWebRTCSession: NSObject {
|
||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "TalkRealtimeWebRTC")
|
||||
private static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "TalkRealtimeWebRTC")
|
||||
private static let consultToolName = "openclaw_agent_consult"
|
||||
private static let controlToolName = "openclaw_agent_control"
|
||||
private static let defaultOfferURL = "https://api.openai.com/v1/realtime/calls"
|
||||
|
||||
@@ -371,15 +371,18 @@ import UIKit
|
||||
}
|
||||
|
||||
@Test @MainActor func loadLastConnectionReadsSavedValues() {
|
||||
let prior = KeychainStore.loadString(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
let prior = KeychainStore.loadString(service: "ai.openclawfoundation.app.gateway", account: "lastConnection")
|
||||
defer {
|
||||
if let prior {
|
||||
_ = KeychainStore.saveString(prior, service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
_ = KeychainStore.saveString(
|
||||
prior,
|
||||
service: "ai.openclawfoundation.app.gateway",
|
||||
account: "lastConnection")
|
||||
} else {
|
||||
_ = KeychainStore.delete(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
_ = KeychainStore.delete(service: "ai.openclawfoundation.app.gateway", account: "lastConnection")
|
||||
}
|
||||
}
|
||||
_ = KeychainStore.delete(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
_ = KeychainStore.delete(service: "ai.openclawfoundation.app.gateway", account: "lastConnection")
|
||||
|
||||
GatewaySettingsStore.saveLastGatewayConnectionManual(
|
||||
host: "gateway.example.com",
|
||||
@@ -395,15 +398,18 @@ import UIKit
|
||||
}
|
||||
|
||||
@Test @MainActor func loadLastConnectionReturnsNilForInvalidData() {
|
||||
let prior = KeychainStore.loadString(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
let prior = KeychainStore.loadString(service: "ai.openclawfoundation.app.gateway", account: "lastConnection")
|
||||
defer {
|
||||
if let prior {
|
||||
_ = KeychainStore.saveString(prior, service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
_ = KeychainStore.saveString(
|
||||
prior,
|
||||
service: "ai.openclawfoundation.app.gateway",
|
||||
account: "lastConnection")
|
||||
} else {
|
||||
_ = KeychainStore.delete(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
_ = KeychainStore.delete(service: "ai.openclawfoundation.app.gateway", account: "lastConnection")
|
||||
}
|
||||
}
|
||||
_ = KeychainStore.delete(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
_ = KeychainStore.delete(service: "ai.openclawfoundation.app.gateway", account: "lastConnection")
|
||||
|
||||
// Plant legacy UserDefaults with invalid host/port to exercise migration + validation.
|
||||
withUserDefaults([
|
||||
|
||||
@@ -7,8 +7,8 @@ private struct KeychainEntry: Hashable {
|
||||
let account: String
|
||||
}
|
||||
|
||||
private let gatewayService = "ai.openclaw.gateway"
|
||||
private let nodeService = "ai.openclaw.node"
|
||||
private let gatewayService = "ai.openclawfoundation.app.gateway"
|
||||
private let nodeService = "ai.openclawfoundation.app.node"
|
||||
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
|
||||
private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID")
|
||||
private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID")
|
||||
|
||||
@@ -4,7 +4,7 @@ import Testing
|
||||
|
||||
@Suite struct KeychainStoreTests {
|
||||
@Test func saveLoadUpdateDeleteRoundTrip() {
|
||||
let service = "ai.openclaw.tests.\(UUID().uuidString)"
|
||||
let service = "ai.openclawfoundation.app.tests.\(UUID().uuidString)"
|
||||
let account = "value"
|
||||
|
||||
#expect(KeychainStore.delete(service: service, account: account))
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized) struct OpenClawAppDelegateTests {
|
||||
@Test @MainActor func resolvesRegistryModelBeforeViewTaskAssignsDelegateModel() {
|
||||
@Test @MainActor func `resolves registry model before view task assigns delegate model`() {
|
||||
let registryModel = NodeAppModel()
|
||||
OpenClawAppModelRegistry.appModel = registryModel
|
||||
defer { OpenClawAppModelRegistry.appModel = nil }
|
||||
@@ -12,7 +13,7 @@ import Testing
|
||||
#expect(delegate._test_resolvedAppModel() === registryModel)
|
||||
}
|
||||
|
||||
@Test @MainActor func prefersExplicitDelegateModelOverRegistryFallback() {
|
||||
@Test @MainActor func `prefers explicit delegate model over registry fallback`() {
|
||||
let registryModel = NodeAppModel()
|
||||
let explicitModel = NodeAppModel()
|
||||
OpenClawAppModelRegistry.appModel = registryModel
|
||||
@@ -23,4 +24,11 @@ import Testing
|
||||
|
||||
#expect(delegate._test_resolvedAppModel() === explicitModel)
|
||||
}
|
||||
|
||||
@Test @MainActor func `derives background refresh task identifier from app bundle identifier`() {
|
||||
let delegate = OpenClawAppDelegate()
|
||||
let bundleIdentifier = Bundle.main.bundleIdentifier ?? "ai.openclawfoundation.app.tests"
|
||||
|
||||
#expect(delegate._test_wakeRefreshTaskIdentifier() == "\(bundleIdentifier).bgrefresh")
|
||||
}
|
||||
}
|
||||
|
||||
24
apps/ios/UITests/Info.plist
Normal file
24
apps/ios/UITests/Info.plist
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>OpenClawUITests</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(OPENCLAW_MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(OPENCLAW_BUILD_VERSION)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
58
apps/ios/UITests/OpenClawSnapshotUITests.swift
Normal file
58
apps/ios/UITests/OpenClawSnapshotUITests.swift
Normal file
@@ -0,0 +1,58 @@
|
||||
import XCTest
|
||||
|
||||
@MainActor
|
||||
final class OpenClawSnapshotUITests: XCTestCase {
|
||||
private struct ScreenshotTarget {
|
||||
let initialTab: String
|
||||
let initialDestination: String
|
||||
let name: String
|
||||
}
|
||||
|
||||
private static let screenshotTargets = [
|
||||
ScreenshotTarget(initialTab: "control", initialDestination: "overview", name: "01-control-connected"),
|
||||
ScreenshotTarget(initialTab: "chat", initialDestination: "chat", name: "02-chat-connected"),
|
||||
ScreenshotTarget(initialTab: "talk", initialDestination: "talk", name: "03-talk-connected"),
|
||||
ScreenshotTarget(initialTab: "agent", initialDestination: "agents", name: "04-agent-connected"),
|
||||
ScreenshotTarget(initialTab: "settings", initialDestination: "settings", name: "05-settings-connected"),
|
||||
]
|
||||
|
||||
private var app: XCUIApplication?
|
||||
|
||||
override func setUpWithError() throws {
|
||||
try super.setUpWithError()
|
||||
self.continueAfterFailure = false
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
self.app?.terminate()
|
||||
self.app = nil
|
||||
try super.tearDownWithError()
|
||||
}
|
||||
|
||||
func testConnectedGatewayTabs() {
|
||||
for target in Self.screenshotTargets {
|
||||
self.launchApp(for: target)
|
||||
snapshot(target.name, timeWaitingForIdle: 5)
|
||||
}
|
||||
}
|
||||
|
||||
private func launchApp(for target: ScreenshotTarget) {
|
||||
self.app?.terminate()
|
||||
|
||||
let app = XCUIApplication()
|
||||
setupSnapshot(app, waitForAnimations: false)
|
||||
app.launchArguments += [
|
||||
"--openclaw-screenshot-mode",
|
||||
"--openclaw-initial-tab",
|
||||
target.initialTab,
|
||||
"--openclaw-initial-destination",
|
||||
target.initialDestination,
|
||||
"--openclaw-sidebar-visibility",
|
||||
"hidden",
|
||||
]
|
||||
app.launch()
|
||||
self.app = app
|
||||
|
||||
XCTAssertTrue(app.wait(for: .runningForeground, timeout: 8))
|
||||
}
|
||||
}
|
||||
309
apps/ios/UITests/SnapshotHelper.swift
Normal file
309
apps/ios/UITests/SnapshotHelper.swift
Normal file
@@ -0,0 +1,309 @@
|
||||
//
|
||||
// SnapshotHelper.swift
|
||||
// Example
|
||||
//
|
||||
// Created by Felix Krause on 10/8/15.
|
||||
//
|
||||
|
||||
// -----------------------------------------------------
|
||||
// IMPORTANT: When modifying this file, make sure to
|
||||
// increment the version number at the very
|
||||
// bottom of the file to notify users about
|
||||
// the new SnapshotHelper.swift
|
||||
// -----------------------------------------------------
|
||||
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
var deviceLanguage = ""
|
||||
var locale = ""
|
||||
|
||||
func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
|
||||
Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations)
|
||||
}
|
||||
|
||||
func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
|
||||
if waitForLoadingIndicator {
|
||||
Snapshot.snapshot(name)
|
||||
} else {
|
||||
Snapshot.snapshot(name, timeWaitingForIdle: 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// - Parameters:
|
||||
/// - name: The name of the snapshot
|
||||
/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait.
|
||||
func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
|
||||
Snapshot.snapshot(name, timeWaitingForIdle: timeout)
|
||||
}
|
||||
|
||||
enum SnapshotError: Error, CustomDebugStringConvertible {
|
||||
case cannotFindSimulatorHomeDirectory
|
||||
case cannotRunOnPhysicalDevice
|
||||
|
||||
var debugDescription: String {
|
||||
switch self {
|
||||
case .cannotFindSimulatorHomeDirectory:
|
||||
return "Couldn't find simulator home location. Please, check SIMULATOR_HOST_HOME env variable."
|
||||
case .cannotRunOnPhysicalDevice:
|
||||
return "Can't use Snapshot on a physical device."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objcMembers
|
||||
open class Snapshot: NSObject {
|
||||
static var app: XCUIApplication?
|
||||
static var waitForAnimations = true
|
||||
static var cacheDirectory: URL?
|
||||
static var screenshotsDirectory: URL? {
|
||||
return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true)
|
||||
}
|
||||
|
||||
open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
|
||||
|
||||
Snapshot.app = app
|
||||
Snapshot.waitForAnimations = waitForAnimations
|
||||
|
||||
do {
|
||||
let cacheDir = try getCacheDirectory()
|
||||
Snapshot.cacheDirectory = cacheDir
|
||||
setLanguage(app)
|
||||
setLocale(app)
|
||||
setLaunchArguments(app)
|
||||
} catch let error {
|
||||
NSLog(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
class func setLanguage(_ app: XCUIApplication) {
|
||||
guard let cacheDirectory = self.cacheDirectory else {
|
||||
NSLog("CacheDirectory is not set - probably running on a physical device?")
|
||||
return
|
||||
}
|
||||
|
||||
let path = cacheDirectory.appendingPathComponent("language.txt")
|
||||
|
||||
do {
|
||||
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
|
||||
deviceLanguage = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
|
||||
app.launchArguments += ["-AppleLanguages", "(\(deviceLanguage))"]
|
||||
} catch {
|
||||
NSLog("Couldn't detect/set language...")
|
||||
}
|
||||
}
|
||||
|
||||
class func setLocale(_ app: XCUIApplication) {
|
||||
guard let cacheDirectory = self.cacheDirectory else {
|
||||
NSLog("CacheDirectory is not set - probably running on a physical device?")
|
||||
return
|
||||
}
|
||||
|
||||
let path = cacheDirectory.appendingPathComponent("locale.txt")
|
||||
|
||||
do {
|
||||
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
|
||||
locale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
|
||||
} catch {
|
||||
NSLog("Couldn't detect/set locale...")
|
||||
}
|
||||
|
||||
if locale.isEmpty && !deviceLanguage.isEmpty {
|
||||
locale = Locale(identifier: deviceLanguage).identifier
|
||||
}
|
||||
|
||||
if !locale.isEmpty {
|
||||
app.launchArguments += ["-AppleLocale", "\"\(locale)\""]
|
||||
}
|
||||
}
|
||||
|
||||
class func setLaunchArguments(_ app: XCUIApplication) {
|
||||
guard let cacheDirectory = self.cacheDirectory else {
|
||||
NSLog("CacheDirectory is not set - probably running on a physical device?")
|
||||
return
|
||||
}
|
||||
|
||||
let path = cacheDirectory.appendingPathComponent("snapshot-launch_arguments.txt")
|
||||
app.launchArguments += ["-FASTLANE_SNAPSHOT", "YES", "-ui_testing"]
|
||||
|
||||
do {
|
||||
let launchArguments = try String(contentsOf: path, encoding: String.Encoding.utf8)
|
||||
let regex = try NSRegularExpression(pattern: "(\\\".+?\\\"|\\S+)", options: [])
|
||||
let matches = regex.matches(in: launchArguments, options: [], range: NSRange(location: 0, length: launchArguments.count))
|
||||
let results = matches.map { result -> String in
|
||||
(launchArguments as NSString).substring(with: result.range)
|
||||
}
|
||||
app.launchArguments += results
|
||||
} catch {
|
||||
NSLog("Couldn't detect/set launch_arguments...")
|
||||
}
|
||||
}
|
||||
|
||||
open class func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
|
||||
if timeout > 0 {
|
||||
waitForLoadingIndicatorToDisappear(within: timeout)
|
||||
}
|
||||
|
||||
NSLog("snapshot: \(name)") // more information about this, check out https://docs.fastlane.tools/actions/snapshot/#how-does-it-work
|
||||
|
||||
if Snapshot.waitForAnimations {
|
||||
sleep(1) // Waiting for the animation to be finished (kind of)
|
||||
}
|
||||
|
||||
#if os(OSX)
|
||||
guard let app = self.app else {
|
||||
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
||||
return
|
||||
}
|
||||
|
||||
app.typeKey(XCUIKeyboardKeySecondaryFn, modifierFlags: [])
|
||||
#else
|
||||
|
||||
guard self.app != nil else {
|
||||
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
||||
return
|
||||
}
|
||||
|
||||
let screenshot = XCUIScreen.main.screenshot()
|
||||
#if os(iOS)
|
||||
let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image
|
||||
#else
|
||||
let image = screenshot.image
|
||||
#endif
|
||||
|
||||
guard var simulator = ProcessInfo().environment["SIMULATOR_DEVICE_NAME"], let screenshotsDir = screenshotsDirectory else { return }
|
||||
|
||||
do {
|
||||
// The simulator name contains "Clone X of " inside the screenshot file when running parallelized UI Tests on concurrent devices
|
||||
let regex = try NSRegularExpression(pattern: "Clone [0-9]+ of ")
|
||||
let range = NSRange(location: 0, length: simulator.count)
|
||||
simulator = regex.stringByReplacingMatches(in: simulator, range: range, withTemplate: "")
|
||||
|
||||
let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png")
|
||||
#if swift(<5.0)
|
||||
UIImagePNGRepresentation(image)?.write(to: path, options: .atomic)
|
||||
#else
|
||||
try image.pngData()?.write(to: path, options: .atomic)
|
||||
#endif
|
||||
} catch let error {
|
||||
NSLog("Problem writing screenshot: \(name) to \(screenshotsDir)/\(simulator)-\(name).png")
|
||||
NSLog(error.localizedDescription)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
class func fixLandscapeOrientation(image: UIImage) -> UIImage {
|
||||
#if os(watchOS)
|
||||
return image
|
||||
#else
|
||||
if #available(iOS 10.0, *) {
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = image.scale
|
||||
let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
|
||||
return renderer.image { context in
|
||||
image.draw(in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height))
|
||||
}
|
||||
} else {
|
||||
return image
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
class func waitForLoadingIndicatorToDisappear(within timeout: TimeInterval) {
|
||||
#if os(tvOS)
|
||||
return
|
||||
#endif
|
||||
|
||||
guard let app = self.app else {
|
||||
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
||||
return
|
||||
}
|
||||
|
||||
let networkLoadingIndicator = app.otherElements.deviceStatusBars.networkLoadingIndicators.element
|
||||
let networkLoadingIndicatorDisappeared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: networkLoadingIndicator)
|
||||
_ = XCTWaiter.wait(for: [networkLoadingIndicatorDisappeared], timeout: timeout)
|
||||
}
|
||||
|
||||
class func getCacheDirectory() throws -> URL {
|
||||
let cachePath = "Library/Caches/tools.fastlane"
|
||||
// on OSX config is stored in /Users/<username>/Library
|
||||
// and on iOS/tvOS/WatchOS it's in simulator's home dir
|
||||
#if os(OSX)
|
||||
let homeDir = URL(fileURLWithPath: NSHomeDirectory())
|
||||
return homeDir.appendingPathComponent(cachePath)
|
||||
#elseif arch(i386) || arch(x86_64) || arch(arm64)
|
||||
guard let simulatorHostHome = ProcessInfo().environment["SIMULATOR_HOST_HOME"] else {
|
||||
throw SnapshotError.cannotFindSimulatorHomeDirectory
|
||||
}
|
||||
let homeDir = URL(fileURLWithPath: simulatorHostHome)
|
||||
return homeDir.appendingPathComponent(cachePath)
|
||||
#else
|
||||
throw SnapshotError.cannotRunOnPhysicalDevice
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private extension XCUIElementAttributes {
|
||||
var isNetworkLoadingIndicator: Bool {
|
||||
if hasAllowListedIdentifier { return false }
|
||||
|
||||
let hasOldLoadingIndicatorSize = frame.size == CGSize(width: 10, height: 20)
|
||||
let hasNewLoadingIndicatorSize = frame.size.width.isBetween(46, and: 47) && frame.size.height.isBetween(2, and: 3)
|
||||
|
||||
return hasOldLoadingIndicatorSize || hasNewLoadingIndicatorSize
|
||||
}
|
||||
|
||||
var hasAllowListedIdentifier: Bool {
|
||||
let allowListedIdentifiers = ["GeofenceLocationTrackingOn", "StandardLocationTrackingOn"]
|
||||
|
||||
return allowListedIdentifiers.contains(identifier)
|
||||
}
|
||||
|
||||
func isStatusBar(_ deviceWidth: CGFloat) -> Bool {
|
||||
if elementType == .statusBar { return true }
|
||||
guard frame.origin == .zero else { return false }
|
||||
|
||||
let oldStatusBarSize = CGSize(width: deviceWidth, height: 20)
|
||||
let newStatusBarSize = CGSize(width: deviceWidth, height: 44)
|
||||
|
||||
return [oldStatusBarSize, newStatusBarSize].contains(frame.size)
|
||||
}
|
||||
}
|
||||
|
||||
private extension XCUIElementQuery {
|
||||
var networkLoadingIndicators: XCUIElementQuery {
|
||||
let isNetworkLoadingIndicator = NSPredicate { (evaluatedObject, _) in
|
||||
guard let element = evaluatedObject as? XCUIElementAttributes else { return false }
|
||||
|
||||
return element.isNetworkLoadingIndicator
|
||||
}
|
||||
|
||||
return self.containing(isNetworkLoadingIndicator)
|
||||
}
|
||||
|
||||
var deviceStatusBars: XCUIElementQuery {
|
||||
guard let app = Snapshot.app else {
|
||||
fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
||||
}
|
||||
|
||||
let deviceWidth = app.windows.firstMatch.frame.width
|
||||
|
||||
let isStatusBar = NSPredicate { (evaluatedObject, _) in
|
||||
guard let element = evaluatedObject as? XCUIElementAttributes else { return false }
|
||||
|
||||
return element.isStatusBar(deviceWidth)
|
||||
}
|
||||
|
||||
return self.containing(isStatusBar)
|
||||
}
|
||||
}
|
||||
|
||||
private extension CGFloat {
|
||||
func isBetween(_ numberA: CGFloat, and numberB: CGFloat) -> Bool {
|
||||
return numberA...numberB ~= self
|
||||
}
|
||||
}
|
||||
|
||||
// Please don't remove the lines below
|
||||
// They are used to detect outdated configuration files
|
||||
// SnapshotHelperVersion [1.27]
|
||||
@@ -64,7 +64,7 @@ Pinned iOS version `2026.4.10` maps to:
|
||||
- `apps/ios/fastlane/metadata/en-US/release_notes.txt`
|
||||
- generated from `apps/ios/CHANGELOG.md`
|
||||
- `apps/ios/build/Version.xcconfig`
|
||||
- local gitignored build override generated per build or beta prep
|
||||
- local gitignored build override generated per build or release prep
|
||||
|
||||
## Tooling surfaces
|
||||
|
||||
@@ -81,16 +81,21 @@ Pinned iOS version `2026.4.10` maps to:
|
||||
- `scripts/ios-pin-version.ts`
|
||||
- explicitly pins iOS to a chosen release version or the current gateway version
|
||||
|
||||
### Build and beta flow
|
||||
### Build and App Store release flow
|
||||
|
||||
- `scripts/ios-write-version-xcconfig.sh`
|
||||
- reads the pinned iOS version
|
||||
- writes the local numeric build override file in `apps/ios/build/Version.xcconfig`
|
||||
- `scripts/ios-beta-prepare.sh`
|
||||
- prepares beta signing and bundle settings against the pinned iOS version
|
||||
- `scripts/ios-release-prepare.sh`
|
||||
- prepares App Store distribution signing and bundle settings against the pinned iOS version
|
||||
- `scripts/ios-release-signing.mjs`
|
||||
- validates the checked-in App Store signing manifest
|
||||
- creates or verifies Developer Portal bundle IDs, capabilities, certificates, and profiles through `asc`
|
||||
- syncs encrypted signing assets with the private shared signing repo
|
||||
- `apps/ios/fastlane/Fastfile`
|
||||
- resolves version metadata from the pinned iOS helper
|
||||
- increments TestFlight build numbers for the pinned short version
|
||||
- increments App Store Connect build numbers for the pinned short version
|
||||
- uploads screenshots and release notes before archiving a release build
|
||||
|
||||
## Release-note resolution order
|
||||
|
||||
@@ -118,7 +123,7 @@ pnpm ios:version:pin -- --version 2026.4.10
|
||||
|
||||
1. keep `apps/ios/version.json` pinned to the current TestFlight train version
|
||||
2. update `apps/ios/CHANGELOG.md` under `## Unreleased` while iterating
|
||||
3. upload more betas with the usual flow
|
||||
3. upload more App Store Connect builds with `pnpm ios:release:upload`
|
||||
4. let Fastlane increment only `CFBundleVersion`
|
||||
|
||||
This keeps the TestFlight version stable while review is in flight.
|
||||
@@ -139,12 +144,15 @@ pnpm ios:version:pin -- --from-gateway
|
||||
- `apps/ios/fastlane/metadata/en-US/release_notes.txt`
|
||||
3. update `apps/ios/CHANGELOG.md` for the new release if needed
|
||||
4. run `pnpm ios:version:sync` again if the changelog changed
|
||||
5. submit the first TestFlight build for that newly pinned version
|
||||
5. upload the first App Store Connect build for that newly pinned version
|
||||
6. keep iterating only by build number until the release candidate is ready
|
||||
7. release that reviewed TestFlight build to production
|
||||
7. manually submit the reviewed build for App Review in App Store Connect
|
||||
8. release the approved build to production
|
||||
|
||||
## Important invariant
|
||||
|
||||
Fastlane and Xcode should consume only the pinned iOS version from `apps/ios/version.json`.
|
||||
|
||||
Changing `package.json.version` alone must not change the iOS app version until a maintainer explicitly runs the pin step.
|
||||
|
||||
App Review submission must remain manual. Automation may create/update the editable App Store version, upload screenshots, upload release notes, and upload builds, but it should not submit a build for review.
|
||||
|
||||
@@ -3,10 +3,17 @@ import SwiftUI
|
||||
@main
|
||||
struct OpenClawWatchApp: App {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var inboxStore = WatchInboxStore()
|
||||
@State private var inboxStore = WatchInboxStore(
|
||||
requestNotificationAuthorization: !OpenClawWatchApp.isScreenshotMode)
|
||||
@State private var receiver: WatchConnectivityReceiver?
|
||||
@State private var execApprovalRefreshTask: Task<Void, Never>?
|
||||
|
||||
private static let screenshotModeDefaultsKey = "openclaw.watch.screenshotMode"
|
||||
private static let isScreenshotMode = ProcessInfo.processInfo.arguments.contains(
|
||||
"--openclaw-watch-screenshot-mode")
|
||||
|| ProcessInfo.processInfo.environment["OPENCLAW_WATCH_SCREENSHOT_MODE"] == "1"
|
||||
|| UserDefaults.standard.bool(forKey: OpenClawWatchApp.screenshotModeDefaultsKey)
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
WatchInboxView(
|
||||
@@ -37,6 +44,10 @@ struct OpenClawWatchApp: App {
|
||||
self.refreshExecApprovalReview(force: true)
|
||||
})
|
||||
.task {
|
||||
if OpenClawWatchApp.isScreenshotMode {
|
||||
self.inboxStore.configureScreenshotFixture()
|
||||
return
|
||||
}
|
||||
if self.receiver == nil {
|
||||
let receiver = WatchConnectivityReceiver(store: self.inboxStore)
|
||||
receiver.activate()
|
||||
@@ -78,3 +89,32 @@ struct OpenClawWatchApp: App {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
extension WatchInboxStore {
|
||||
fileprivate func configureScreenshotFixture() {
|
||||
self.consume(
|
||||
execApprovalSnapshot: WatchExecApprovalSnapshotMessage(
|
||||
approvals: [],
|
||||
sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
|
||||
snapshotId: nil),
|
||||
transport: "screenshot")
|
||||
self.consume(
|
||||
message: WatchNotifyMessage(
|
||||
id: "watch-screenshot-quick-reply",
|
||||
title: "Molty request",
|
||||
body: "Molty Gateway checklist ready.",
|
||||
sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
|
||||
promptId: "watch-screenshot-prompt",
|
||||
sessionKey: "watch-screenshot-session",
|
||||
kind: "release-checklist",
|
||||
details: nil,
|
||||
expiresAtMs: nil,
|
||||
risk: "medium",
|
||||
actions: [
|
||||
WatchPromptAction(id: "approve", label: "Approve", style: nil),
|
||||
WatchPromptAction(id: "later", label: "Later", style: "cancel"),
|
||||
]),
|
||||
transport: "screenshot")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,12 +170,17 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||
private var hasCompletedExecApprovalSnapshotRefreshInSession = false
|
||||
private var lastDeliveryKey: String?
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
init(
|
||||
defaults: UserDefaults = .standard,
|
||||
requestNotificationAuthorization: Bool = true)
|
||||
{
|
||||
self.defaults = defaults
|
||||
self.restorePersistedState()
|
||||
self.pruneExpiredExecApprovals(nowMs: Self.nowMs())
|
||||
Task {
|
||||
await self.ensureNotificationAuthorization()
|
||||
if requestNotificationAuthorization {
|
||||
Task {
|
||||
await self.ensureNotificationAuthorization()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,3 +19,6 @@
|
||||
# Deliver toggles (off by default)
|
||||
# DELIVER_METADATA=1
|
||||
# DELIVER_SCREENSHOTS=1
|
||||
|
||||
# Screenshot generation
|
||||
# OPENCLAW_SNAPSHOT_DEVICES=iPhone 16 Pro Max,iPad Pro 13-inch (M4)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
app_identifier("ai.openclaw.client")
|
||||
app_identifier("ai.openclawfoundation.app")
|
||||
|
||||
# Auth is expected via App Store Connect API key.
|
||||
# Provide either:
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
require "shellwords"
|
||||
require "open3"
|
||||
require "json"
|
||||
require "fileutils"
|
||||
require "tmpdir"
|
||||
require "tempfile"
|
||||
require "cgi"
|
||||
|
||||
default_platform(:ios)
|
||||
|
||||
BETA_APP_IDENTIFIER = "ai.openclaw.client"
|
||||
APP_STORE_APP_IDENTIFIER = "ai.openclawfoundation.app"
|
||||
DEFAULT_SNAPSHOT_DEVICES = ["iPhone 16 Pro Max", "iPad Pro 13-inch (M4)"].freeze
|
||||
DEFAULT_WATCH_SNAPSHOT_DEVICE = "Apple Watch Ultra 3 (49mm)"
|
||||
WATCH_SCREENSHOT_MODE_DEFAULTS_KEY = "openclaw.watch.screenshotMode"
|
||||
WATCH_SNAPSHOT_STATUS_BAR_TIME = "09:41"
|
||||
SNAPSHOT_STATUS_BAR_ARGUMENTS = "--time 09:41 --dataNetwork wifi --wifiMode active --wifiBars 3 --cellularMode active --cellularBars 4 --batteryState charged --batteryLevel 100".freeze
|
||||
REQUIRED_SCREENSHOT_FAMILIES = {
|
||||
"iPhone" => /iPhone/,
|
||||
"13-inch iPad" => /iPad (Air|Pro) 13-inch/
|
||||
}.freeze
|
||||
|
||||
def load_env_file(path)
|
||||
return unless File.exist?(path)
|
||||
@@ -33,10 +46,294 @@ def screenshot_upload_requested?
|
||||
ENV["DELIVER_SCREENSHOTS"] == "1"
|
||||
end
|
||||
|
||||
def release_notes_upload_requested?
|
||||
ENV["DELIVER_RELEASE_NOTES"] == "1"
|
||||
end
|
||||
|
||||
def screenshot_paths
|
||||
Dir[File.join(__dir__, "screenshots", "**", "*.png")]
|
||||
end
|
||||
|
||||
def validate_required_screenshots!(paths)
|
||||
missing_families = REQUIRED_SCREENSHOT_FAMILIES.filter_map do |name, pattern|
|
||||
name unless paths.any? { |path| File.basename(path).match?(pattern) }
|
||||
end
|
||||
return if missing_families.empty?
|
||||
|
||||
UI.user_error!("DELIVER_SCREENSHOTS=1 but screenshots are missing for: #{missing_families.join(', ')}.")
|
||||
end
|
||||
|
||||
def snapshot_devices
|
||||
raw = ENV["OPENCLAW_SNAPSHOT_DEVICES"].to_s.strip
|
||||
return DEFAULT_SNAPSHOT_DEVICES if raw.empty?
|
||||
|
||||
raw.split(",").map(&:strip).reject(&:empty?)
|
||||
end
|
||||
|
||||
def watch_snapshot_device
|
||||
raw = ENV["OPENCLAW_WATCH_SNAPSHOT_DEVICE"].to_s.strip
|
||||
raw.empty? ? DEFAULT_WATCH_SNAPSHOT_DEVICE : raw
|
||||
end
|
||||
|
||||
def available_simulator_devices
|
||||
stdout, stderr, status = Open3.capture3("xcrun", "simctl", "list", "devices", "available", "--json")
|
||||
unless status.success?
|
||||
detail = stderr.to_s.strip
|
||||
detail = stdout.to_s.strip if detail.empty?
|
||||
UI.user_error!("Failed to list simulator devices: #{detail}")
|
||||
end
|
||||
|
||||
JSON.parse(stdout).fetch("devices").values.flatten
|
||||
rescue JSON::ParserError => e
|
||||
UI.user_error!("Invalid JSON from simctl device list: #{e.message}")
|
||||
end
|
||||
|
||||
def resolve_simulator_device(name)
|
||||
devices = available_simulator_devices
|
||||
exact = devices.find { |device| device["name"] == name }
|
||||
return exact if exact
|
||||
|
||||
watch_devices = devices.select { |device| device["name"].to_s.include?("Apple Watch") }
|
||||
fallback = watch_devices.find { |device| device["name"].to_s.include?("Ultra") } || watch_devices.first
|
||||
UI.user_error!("No available Apple Watch simulators found.") unless fallback
|
||||
|
||||
UI.important("Apple Watch simulator '#{name}' was not found; using '#{fallback.fetch("name")}'.")
|
||||
fallback
|
||||
end
|
||||
|
||||
def bundle_identifier_for_product(product_path)
|
||||
info_plist_path = File.join(product_path, "Info.plist")
|
||||
UI.user_error!("Expected Info.plist at #{info_plist_path}.") unless File.exist?(info_plist_path)
|
||||
|
||||
stdout, stderr, status = Open3.capture3(
|
||||
"/usr/libexec/PlistBuddy",
|
||||
"-c",
|
||||
"Print:CFBundleIdentifier",
|
||||
info_plist_path
|
||||
)
|
||||
unless status.success?
|
||||
detail = stderr.to_s.strip
|
||||
detail = stdout.to_s.strip if detail.empty?
|
||||
UI.user_error!("Failed to read bundle identifier from #{info_plist_path}: #{detail}")
|
||||
end
|
||||
|
||||
bundle_identifier = stdout.to_s.strip
|
||||
UI.user_error!("Missing bundle identifier in #{info_plist_path}.") if bundle_identifier.empty?
|
||||
bundle_identifier
|
||||
end
|
||||
|
||||
def write_watch_screenshot_mode_defaults(udid, bundle_identifiers)
|
||||
bundle_identifiers.each do |bundle_identifier|
|
||||
sh(
|
||||
shell_join([
|
||||
"xcrun",
|
||||
"simctl",
|
||||
"spawn",
|
||||
udid,
|
||||
"defaults",
|
||||
"write",
|
||||
bundle_identifier,
|
||||
WATCH_SCREENSHOT_MODE_DEFAULTS_KEY,
|
||||
"-bool",
|
||||
"YES",
|
||||
])
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def clear_watch_screenshot_mode_defaults(udid, bundle_identifiers)
|
||||
bundle_identifiers.each do |bundle_identifier|
|
||||
sh(
|
||||
"#{shell_join([
|
||||
"xcrun",
|
||||
"simctl",
|
||||
"spawn",
|
||||
udid,
|
||||
"defaults",
|
||||
"delete",
|
||||
bundle_identifier,
|
||||
WATCH_SCREENSHOT_MODE_DEFAULTS_KEY,
|
||||
])} >/dev/null 2>&1 || true"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def status_bar_unsupported?(detail)
|
||||
detail.include?("Status bar overrides not supported on this platform") ||
|
||||
detail.include?("Operation not supported")
|
||||
end
|
||||
|
||||
def set_watch_status_bar_override(udid)
|
||||
stdout, stderr, status = Open3.capture3(
|
||||
"xcrun",
|
||||
"simctl",
|
||||
"status_bar",
|
||||
udid,
|
||||
"override",
|
||||
"--time",
|
||||
WATCH_SNAPSHOT_STATUS_BAR_TIME
|
||||
)
|
||||
return true if status.success?
|
||||
|
||||
detail = stderr.to_s.strip
|
||||
detail = stdout.to_s.strip if detail.empty?
|
||||
if status_bar_unsupported?(detail)
|
||||
UI.important("watchOS simulator status bar overrides are not supported; Watch screenshot clock will use simulator time.")
|
||||
return false
|
||||
end
|
||||
|
||||
UI.user_error!("Failed to override Watch simulator status bar: #{detail}")
|
||||
end
|
||||
|
||||
def clear_watch_status_bar_override(udid)
|
||||
stdout, stderr, status = Open3.capture3("xcrun", "simctl", "status_bar", udid, "clear")
|
||||
return if status.success?
|
||||
|
||||
detail = stderr.to_s.strip
|
||||
detail = stdout.to_s.strip if detail.empty?
|
||||
UI.user_error!("Failed to clear Watch simulator status bar override: #{detail}") unless status_bar_unsupported?(detail)
|
||||
end
|
||||
|
||||
def normalize_watch_screenshot_status_bar(path)
|
||||
script = <<~SWIFT
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
let path = CommandLine.arguments[1]
|
||||
let timeText = CommandLine.arguments[2]
|
||||
|
||||
guard let source = NSImage(contentsOfFile: path),
|
||||
let cgImage = source.cgImage(forProposedRect: nil, context: nil, hints: nil)
|
||||
else {
|
||||
fputs("Failed to load screenshot at \\(path)\\n", stderr)
|
||||
exit(2)
|
||||
}
|
||||
|
||||
let width = CGFloat(cgImage.width)
|
||||
let height = CGFloat(cgImage.height)
|
||||
guard let bitmap = NSBitmapImageRep(
|
||||
bitmapDataPlanes: nil,
|
||||
pixelsWide: Int(width),
|
||||
pixelsHigh: Int(height),
|
||||
bitsPerSample: 8,
|
||||
samplesPerPixel: 4,
|
||||
hasAlpha: true,
|
||||
isPlanar: false,
|
||||
colorSpaceName: .deviceRGB,
|
||||
bytesPerRow: 0,
|
||||
bitsPerPixel: 0),
|
||||
let graphicsContext = NSGraphicsContext(bitmapImageRep: bitmap)
|
||||
else {
|
||||
fputs("Failed to create normalized screenshot bitmap at \\(path)\\n", stderr)
|
||||
exit(3)
|
||||
}
|
||||
|
||||
bitmap.size = NSSize(width: width, height: height)
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = graphicsContext
|
||||
source.draw(
|
||||
in: NSRect(x: 0, y: 0, width: width, height: height),
|
||||
from: NSRect(x: 0, y: 0, width: width, height: height),
|
||||
operation: .copy,
|
||||
fraction: 1.0)
|
||||
|
||||
NSColor.black.setFill()
|
||||
NSBezierPath(rect: NSRect(x: width - 146, y: height - 92, width: 124, height: 70)).fill()
|
||||
|
||||
let paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.alignment = .right
|
||||
let attributes: [NSAttributedString.Key: Any] = [
|
||||
.font: NSFont.monospacedDigitSystemFont(ofSize: 34, weight: .semibold),
|
||||
.foregroundColor: NSColor.white,
|
||||
.paragraphStyle: paragraphStyle,
|
||||
]
|
||||
timeText.draw(
|
||||
in: NSRect(x: width - 134, y: height - 82, width: 102, height: 44),
|
||||
withAttributes: attributes)
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
|
||||
guard let png = bitmap.representation(using: .png, properties: [:])
|
||||
else {
|
||||
fputs("Failed to encode normalized screenshot at \\(path)\\n", stderr)
|
||||
exit(4)
|
||||
}
|
||||
|
||||
try png.write(to: URL(fileURLWithPath: path))
|
||||
SWIFT
|
||||
|
||||
Tempfile.create(["openclaw-watch-status-bar", ".swift"]) do |file|
|
||||
file.write(script)
|
||||
file.flush
|
||||
sh(shell_join(["xcrun", "swift", file.path, path, "9:41"]))
|
||||
end
|
||||
end
|
||||
|
||||
def capture_watch_screenshot
|
||||
device = resolve_simulator_device(watch_snapshot_device)
|
||||
device_name = device.fetch("name")
|
||||
udid = device.fetch("udid")
|
||||
output_dir = File.join(ios_root, "fastlane", "screenshots", "en-US")
|
||||
output_path = File.join(output_dir, "#{device_name}-01-quick-reply.png")
|
||||
derived_data_path = File.join(ios_root, "build", "WatchScreenshotDerivedData")
|
||||
app_path = File.join(derived_data_path, "Build", "Products", "Debug-watchsimulator", "OpenClawWatchApp.app")
|
||||
|
||||
FileUtils.mkdir_p(output_dir)
|
||||
Dir[File.join(output_dir, "Apple Watch*-*.png")].each { |path| FileUtils.rm_f(path) }
|
||||
FileUtils.rm_rf(derived_data_path)
|
||||
|
||||
sh(
|
||||
xcodebuild_shell_join([
|
||||
"xcodebuild",
|
||||
"-project",
|
||||
File.join(ios_root, "OpenClaw.xcodeproj"),
|
||||
"-scheme",
|
||||
"OpenClawWatchApp",
|
||||
"-configuration",
|
||||
"Debug",
|
||||
"-destination",
|
||||
"platform=watchOS Simulator,id=#{udid}",
|
||||
"-derivedDataPath",
|
||||
derived_data_path,
|
||||
"build",
|
||||
])
|
||||
)
|
||||
|
||||
UI.user_error!("Watch screenshot build did not produce #{app_path}.") unless File.exist?(app_path)
|
||||
extension_path = File.join(app_path, "PlugIns", "OpenClawWatchExtension.appex")
|
||||
watch_app_identifier = bundle_identifier_for_product(app_path)
|
||||
watch_extension_identifier = bundle_identifier_for_product(extension_path)
|
||||
screenshot_mode_bundle_identifiers = [watch_app_identifier, watch_extension_identifier]
|
||||
|
||||
sh("#{shell_join(["xcrun", "simctl", "boot", udid])} >/dev/null 2>&1 || true")
|
||||
sh(shell_join(["xcrun", "simctl", "bootstatus", udid, "-b"]))
|
||||
sh("#{shell_join(["xcrun", "simctl", "uninstall", udid, watch_app_identifier])} >/dev/null 2>&1 || true")
|
||||
status_bar_overridden = false
|
||||
begin
|
||||
sh(shell_join(["xcrun", "simctl", "install", udid, app_path]))
|
||||
write_watch_screenshot_mode_defaults(udid, screenshot_mode_bundle_identifiers)
|
||||
status_bar_overridden = set_watch_status_bar_override(udid)
|
||||
sh(
|
||||
"SIMCTL_CHILD_OPENCLAW_WATCH_SCREENSHOT_MODE=1 #{shell_join([
|
||||
"xcrun",
|
||||
"simctl",
|
||||
"launch",
|
||||
udid,
|
||||
watch_app_identifier,
|
||||
"--openclaw-watch-screenshot-mode",
|
||||
])}"
|
||||
)
|
||||
sleep(3)
|
||||
sh(shell_join(["xcrun", "simctl", "io", udid, "screenshot", output_path]))
|
||||
normalize_watch_screenshot_status_bar(output_path)
|
||||
ensure
|
||||
clear_watch_status_bar_override(udid) if status_bar_overridden
|
||||
clear_watch_screenshot_mode_defaults(udid, screenshot_mode_bundle_identifiers)
|
||||
end
|
||||
|
||||
UI.success("Captured Apple Watch screenshot: #{output_path}")
|
||||
output_path
|
||||
end
|
||||
|
||||
def maybe_decode_hex_keychain_secret(value)
|
||||
return value unless env_present?(value)
|
||||
|
||||
@@ -103,6 +400,92 @@ def ios_root
|
||||
File.expand_path("..", __dir__)
|
||||
end
|
||||
|
||||
def preserve_file(path)
|
||||
existed = File.exist?(path)
|
||||
contents = existed ? File.binread(path) : nil
|
||||
|
||||
yield
|
||||
ensure
|
||||
if existed
|
||||
File.binwrite(path, contents)
|
||||
else
|
||||
FileUtils.rm_f(path)
|
||||
end
|
||||
end
|
||||
|
||||
def preserve_local_signing
|
||||
preserve_file(File.join(ios_root, ".local-signing.xcconfig")) do
|
||||
yield
|
||||
end
|
||||
end
|
||||
|
||||
def app_store_signing_manifest
|
||||
JSON.parse(File.read(File.join(ios_root, "Config", "AppStoreSigning.json")))
|
||||
end
|
||||
|
||||
def app_store_provisioning_profiles
|
||||
app_store_signing_manifest.fetch("targets").each_with_object({}) do |target, profiles|
|
||||
profiles[target.fetch("bundleId")] = target.fetch("profileName")
|
||||
end
|
||||
end
|
||||
|
||||
def xml_string(value)
|
||||
CGI.escapeHTML(value.to_s)
|
||||
end
|
||||
|
||||
def write_app_store_export_options(path)
|
||||
manifest = app_store_signing_manifest
|
||||
profile_entries = app_store_provisioning_profiles.map do |bundle_id, profile_name|
|
||||
" <key>#{xml_string(bundle_id)}</key>\n <string>#{xml_string(profile_name)}</string>"
|
||||
end.join("\n")
|
||||
|
||||
File.write(path, <<~PLIST)
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>app-store-connect</string>
|
||||
<key>signingStyle</key>
|
||||
<string>manual</string>
|
||||
<key>signingCertificate</key>
|
||||
<string>Apple Distribution</string>
|
||||
<key>teamID</key>
|
||||
<string>#{xml_string(manifest.fetch("teamId"))}</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
#{profile_entries}
|
||||
</dict>
|
||||
<key>destination</key>
|
||||
<string>export</string>
|
||||
<key>stripSwiftSymbols</key>
|
||||
<true/>
|
||||
<key>manageAppVersionAndBuildNumber</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
end
|
||||
|
||||
def release_signing_check!
|
||||
sh(shell_join(["node", File.join(repo_root, "scripts", "ios-release-signing.mjs"), "--mode", "check"]))
|
||||
end
|
||||
|
||||
def release_notes_path
|
||||
File.join(__dir__, "metadata", "en-US", "release_notes.txt")
|
||||
end
|
||||
|
||||
def release_notes_metadata_path
|
||||
source = release_notes_path
|
||||
UI.user_error!("Missing release notes at #{source}. Run `pnpm ios:version:sync`.") unless File.exist?(source)
|
||||
|
||||
temp_root = Dir.mktmpdir("openclaw-release-notes")
|
||||
target_dir = File.join(temp_root, "en-US")
|
||||
FileUtils.mkdir_p(target_dir)
|
||||
FileUtils.cp(source, File.join(target_dir, "release_notes.txt"))
|
||||
temp_root
|
||||
end
|
||||
|
||||
def read_ios_version_metadata
|
||||
script_path = File.join(repo_root, "scripts", "ios-version.ts")
|
||||
stdout, stderr, status = Open3.capture3(
|
||||
@@ -156,69 +539,102 @@ def shell_join(parts)
|
||||
Shellwords.join(parts.compact)
|
||||
end
|
||||
|
||||
def resolve_beta_build_number(api_key:, short_version:)
|
||||
explicit = ENV["IOS_BETA_BUILD_NUMBER"]
|
||||
def xcodebuild_shell_join(parts)
|
||||
xcode_path = "/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin"
|
||||
shell_join(["env", "PATH=#{xcode_path}", *parts])
|
||||
end
|
||||
|
||||
def resolve_release_build_number(api_key:, short_version:)
|
||||
explicit = ENV["IOS_RELEASE_BUILD_NUMBER"]
|
||||
if env_present?(explicit)
|
||||
UI.user_error!("Invalid IOS_BETA_BUILD_NUMBER '#{explicit}'. Expected digits only.") unless explicit.match?(/\A\d+\z/)
|
||||
UI.message("Using explicit iOS beta build number #{explicit}.")
|
||||
UI.user_error!("Invalid iOS release build number '#{explicit}'. Expected digits only.") unless explicit.match?(/\A\d+\z/)
|
||||
UI.message("Using explicit iOS release build number #{explicit}.")
|
||||
return explicit
|
||||
end
|
||||
|
||||
latest_build = latest_testflight_build_number(
|
||||
api_key: api_key,
|
||||
app_identifier: BETA_APP_IDENTIFIER,
|
||||
app_identifier: APP_STORE_APP_IDENTIFIER,
|
||||
version: short_version,
|
||||
initial_build_number: 0
|
||||
)
|
||||
next_build = latest_build.to_i + 1
|
||||
UI.message("Resolved iOS beta build number #{next_build} for #{short_version} (latest TestFlight build: #{latest_build}).")
|
||||
UI.message("Resolved iOS release build number #{next_build} for #{short_version} (latest App Store Connect build: #{latest_build}).")
|
||||
next_build.to_s
|
||||
end
|
||||
|
||||
def beta_build_number_needs_asc_auth?
|
||||
explicit = ENV["IOS_BETA_BUILD_NUMBER"]
|
||||
def release_build_number_needs_asc_auth?
|
||||
explicit = ENV["IOS_RELEASE_BUILD_NUMBER"]
|
||||
!env_present?(explicit)
|
||||
end
|
||||
|
||||
def prepare_beta_release!(version:, build_number:)
|
||||
script_path = File.join(repo_root, "scripts", "ios-beta-prepare.sh")
|
||||
UI.message("Preparing iOS beta release #{version} (build #{build_number}).")
|
||||
def prepare_app_store_release!(version:, build_number:)
|
||||
script_path = File.join(repo_root, "scripts", "ios-release-prepare.sh")
|
||||
UI.message("Preparing iOS App Store release #{version} (build #{build_number}).")
|
||||
sh(shell_join(["bash", script_path, "--build-number", build_number]))
|
||||
|
||||
beta_xcconfig = File.join(ios_root, "build", "BetaRelease.xcconfig")
|
||||
UI.user_error!("Missing beta xcconfig at #{beta_xcconfig}.") unless File.exist?(beta_xcconfig)
|
||||
release_xcconfig = File.join(ios_root, "build", "AppStoreRelease.xcconfig")
|
||||
UI.user_error!("Missing App Store release xcconfig at #{release_xcconfig}.") unless File.exist?(release_xcconfig)
|
||||
|
||||
ENV["XCODE_XCCONFIG_FILE"] = beta_xcconfig
|
||||
beta_xcconfig
|
||||
ENV["XCODE_XCCONFIG_FILE"] = release_xcconfig
|
||||
release_xcconfig
|
||||
end
|
||||
|
||||
def build_beta_release(context)
|
||||
def build_app_store_release(context)
|
||||
version = context[:version]
|
||||
output_directory = File.join("build", "beta")
|
||||
project_path = File.join(ios_root, "OpenClaw.xcodeproj")
|
||||
output_directory = File.join(ios_root, "build", "app-store")
|
||||
archive_path = File.join(output_directory, "OpenClaw-#{version}.xcarchive")
|
||||
export_options_path = File.join(output_directory, "ExportOptions.plist")
|
||||
output_name = "OpenClaw-#{version}.ipa"
|
||||
expected_ipa_path = File.join(output_directory, output_name)
|
||||
|
||||
build_app(
|
||||
project: "OpenClaw.xcodeproj",
|
||||
scheme: "OpenClaw",
|
||||
configuration: "Release",
|
||||
export_method: "app-store",
|
||||
clean: true,
|
||||
skip_profile_detection: true,
|
||||
build_path: "build",
|
||||
archive_path: archive_path,
|
||||
output_directory: output_directory,
|
||||
output_name: "OpenClaw-#{version}.ipa",
|
||||
xcargs: "-allowProvisioningUpdates",
|
||||
export_xcargs: "-allowProvisioningUpdates",
|
||||
export_options: {
|
||||
signingStyle: "automatic"
|
||||
}
|
||||
FileUtils.mkdir_p(output_directory)
|
||||
FileUtils.rm_rf(archive_path)
|
||||
Dir[File.join(output_directory, "*.ipa")].each { |path| FileUtils.rm_f(path) }
|
||||
write_app_store_export_options(export_options_path)
|
||||
|
||||
sh(
|
||||
xcodebuild_shell_join([
|
||||
"xcodebuild",
|
||||
"-project",
|
||||
project_path,
|
||||
"-scheme",
|
||||
"OpenClaw",
|
||||
"-configuration",
|
||||
"Release",
|
||||
"-destination",
|
||||
"generic/platform=iOS",
|
||||
"-archivePath",
|
||||
archive_path,
|
||||
"clean",
|
||||
"archive",
|
||||
])
|
||||
)
|
||||
|
||||
sh(
|
||||
xcodebuild_shell_join([
|
||||
"xcodebuild",
|
||||
"-exportArchive",
|
||||
"-archivePath",
|
||||
archive_path,
|
||||
"-exportPath",
|
||||
output_directory,
|
||||
"-exportOptionsPlist",
|
||||
export_options_path,
|
||||
])
|
||||
)
|
||||
|
||||
exported_ipas = Dir[File.join(output_directory, "*.ipa")]
|
||||
UI.user_error!("xcodebuild export did not produce an IPA in #{output_directory}.") if exported_ipas.empty?
|
||||
UI.user_error!("xcodebuild export produced multiple IPAs in #{output_directory}: #{exported_ipas.join(", ")}") if exported_ipas.length > 1
|
||||
exported_ipa = exported_ipas.first
|
||||
FileUtils.mv(exported_ipa, expected_ipa_path) unless exported_ipa == expected_ipa_path
|
||||
|
||||
{
|
||||
archive_path: archive_path,
|
||||
build_number: context[:build_number],
|
||||
ipa_path: lane_context[SharedValues::IPA_OUTPUT_PATH],
|
||||
ipa_path: expected_ipa_path,
|
||||
short_version: context[:short_version],
|
||||
version: version
|
||||
}
|
||||
@@ -272,40 +688,40 @@ platform :ios do
|
||||
api_key
|
||||
end
|
||||
|
||||
private_lane :prepare_beta_context do |options|
|
||||
private_lane :prepare_app_store_context do |options|
|
||||
require_api_key = options[:require_api_key] == true
|
||||
needs_api_key = require_api_key || beta_build_number_needs_asc_auth?
|
||||
needs_api_key = require_api_key || release_build_number_needs_asc_auth?
|
||||
api_key = needs_api_key ? asc_api_key : nil
|
||||
sync_ios_versioning!
|
||||
version_metadata = read_ios_version_metadata
|
||||
version = version_metadata[:version]
|
||||
short_version = version_metadata[:short_version]
|
||||
build_number = resolve_beta_build_number(api_key: api_key, short_version: short_version)
|
||||
beta_xcconfig = prepare_beta_release!(version: version, build_number: build_number)
|
||||
build_number = resolve_release_build_number(api_key: api_key, short_version: short_version)
|
||||
release_xcconfig = prepare_app_store_release!(version: version, build_number: build_number)
|
||||
|
||||
{
|
||||
api_key: api_key,
|
||||
beta_xcconfig: beta_xcconfig,
|
||||
build_number: build_number,
|
||||
release_xcconfig: release_xcconfig,
|
||||
short_version: short_version,
|
||||
version: version
|
||||
}
|
||||
end
|
||||
|
||||
desc "Build a beta archive locally without uploading"
|
||||
lane :beta_archive do
|
||||
context = prepare_beta_context(require_api_key: false)
|
||||
build = build_beta_release(context)
|
||||
UI.success("Built iOS beta archive: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
|
||||
desc "Build an App Store distribution archive locally without uploading"
|
||||
lane :app_store_archive do
|
||||
context = prepare_app_store_context(require_api_key: false)
|
||||
build = build_app_store_release(context)
|
||||
UI.success("Built iOS App Store archive: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
|
||||
build
|
||||
ensure
|
||||
ENV.delete("XCODE_XCCONFIG_FILE")
|
||||
end
|
||||
|
||||
desc "Build + upload a beta to TestFlight"
|
||||
lane :beta do
|
||||
context = prepare_beta_context(require_api_key: true)
|
||||
build = build_beta_release(context)
|
||||
desc "Build + upload an App Store distribution build to App Store Connect"
|
||||
lane :app_store do
|
||||
context = prepare_app_store_context(require_api_key: true)
|
||||
build = build_app_store_release(context)
|
||||
|
||||
upload_to_testflight(
|
||||
api_key: context[:api_key],
|
||||
@@ -314,7 +730,33 @@ platform :ios do
|
||||
uses_non_exempt_encryption: false
|
||||
)
|
||||
|
||||
UI.success("Uploaded iOS beta: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
|
||||
UI.success("Uploaded iOS App Store build: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
|
||||
ensure
|
||||
ENV.delete("XCODE_XCCONFIG_FILE")
|
||||
end
|
||||
|
||||
desc "Generate screenshots, update App Store version metadata, then upload an App Store build"
|
||||
lane :release_upload do
|
||||
release_signing_check!
|
||||
preserve_local_signing do
|
||||
screenshots
|
||||
end
|
||||
ENV["DELIVER_SCREENSHOTS"] = "1"
|
||||
ENV["DELIVER_RELEASE_NOTES"] = "1"
|
||||
metadata
|
||||
|
||||
context = prepare_app_store_context(require_api_key: true)
|
||||
build = build_app_store_release(context)
|
||||
|
||||
upload_to_testflight(
|
||||
api_key: context[:api_key],
|
||||
ipa: build[:ipa_path],
|
||||
skip_waiting_for_build_processing: true,
|
||||
uses_non_exempt_encryption: false
|
||||
)
|
||||
|
||||
UI.success("Uploaded iOS App Store build: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
|
||||
UI.important("App Review submission remains manual in App Store Connect.")
|
||||
ensure
|
||||
ENV.delete("XCODE_XCCONFIG_FILE")
|
||||
end
|
||||
@@ -330,8 +772,19 @@ platform :ios do
|
||||
app_identifier = nil unless env_present?(app_identifier)
|
||||
app_id = nil unless env_present?(app_id)
|
||||
|
||||
if screenshot_upload_requested? && screenshot_paths.empty?
|
||||
UI.user_error!("DELIVER_SCREENSHOTS=1 but no PNG screenshots were found under apps/ios/fastlane/screenshots.")
|
||||
if screenshot_upload_requested?
|
||||
paths = screenshot_paths
|
||||
if paths.empty?
|
||||
UI.user_error!("DELIVER_SCREENSHOTS=1 but no PNG screenshots were found under apps/ios/fastlane/screenshots.")
|
||||
end
|
||||
validate_required_screenshots!(paths)
|
||||
end
|
||||
|
||||
metadata_path = File.join(__dir__, "metadata")
|
||||
skip_metadata = ENV["DELIVER_METADATA"] != "1"
|
||||
if release_notes_upload_requested? && skip_metadata
|
||||
metadata_path = release_notes_metadata_path
|
||||
skip_metadata = false
|
||||
end
|
||||
|
||||
deliver_options = {
|
||||
@@ -341,10 +794,13 @@ platform :ios do
|
||||
copyright: "2026 OpenClaw",
|
||||
primary_category: "PRODUCTIVITY",
|
||||
secondary_category: "UTILITIES",
|
||||
metadata_path: metadata_path,
|
||||
skip_screenshots: !screenshot_upload_requested?,
|
||||
skip_metadata: ENV["DELIVER_METADATA"] != "1",
|
||||
skip_metadata: skip_metadata,
|
||||
skip_binary_upload: true,
|
||||
overwrite_screenshots: screenshot_upload_requested?,
|
||||
skip_app_version_update: false,
|
||||
submit_for_review: false,
|
||||
run_precheck_before_submit: false
|
||||
}
|
||||
deliver_options[:app_identifier] = app_identifier if app_identifier
|
||||
@@ -357,6 +813,40 @@ platform :ios do
|
||||
deliver(**deliver_options)
|
||||
end
|
||||
|
||||
desc "Generate deterministic iOS screenshots for App Store metadata"
|
||||
lane :screenshots do
|
||||
sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-configure-signing.sh")]))
|
||||
sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-write-version-xcconfig.sh")]))
|
||||
sh(shell_join(["xcodegen", "generate", "--spec", File.join(ios_root, "project.yml"), "--project", ios_root]))
|
||||
|
||||
capture_ios_screenshots(
|
||||
project: File.join(ios_root, "OpenClaw.xcodeproj"),
|
||||
scheme: "OpenClawUITests",
|
||||
configuration: "Debug",
|
||||
devices: snapshot_devices,
|
||||
languages: ["en-US"],
|
||||
launch_arguments: ["--openclaw-screenshot-mode"],
|
||||
output_directory: File.join(ios_root, "fastlane", "screenshots"),
|
||||
clear_previous_screenshots: true,
|
||||
reinstall_app: true,
|
||||
concurrent_simulators: false,
|
||||
override_status_bar: true,
|
||||
override_status_bar_arguments: SNAPSHOT_STATUS_BAR_ARGUMENTS,
|
||||
skip_open_summary: true,
|
||||
xcargs: "-allowProvisioningUpdates"
|
||||
)
|
||||
|
||||
watch_screenshot
|
||||
end
|
||||
|
||||
desc "Generate deterministic Apple Watch screenshot for App Store metadata"
|
||||
lane :watch_screenshot do
|
||||
sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-configure-signing.sh")]))
|
||||
sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-write-version-xcconfig.sh")]))
|
||||
sh(shell_join(["xcodegen", "generate", "--spec", File.join(ios_root, "project.yml"), "--project", ios_root]))
|
||||
capture_watch_screenshot
|
||||
end
|
||||
|
||||
desc "Validate App Store Connect API auth"
|
||||
lane :auth_check do
|
||||
asc_api_key
|
||||
|
||||
@@ -29,12 +29,12 @@ ASC_KEYCHAIN_SERVICE=openclaw-asc-key
|
||||
ASC_KEYCHAIN_ACCOUNT=YOUR_MAC_USERNAME
|
||||
```
|
||||
|
||||
Important: `apps/ios/fastlane/.env` is only for Fastlane/App Store Connect auth and optional beta-archive settings. It does **not** configure gateway-side direct APNs push delivery for local iOS builds.
|
||||
Important: `apps/ios/fastlane/.env` is only for Fastlane/App Store Connect auth and optional release-archive settings. It does **not** configure gateway-side direct APNs push delivery for local iOS builds.
|
||||
|
||||
Optional app targeting variables (helpful if Fastlane cannot auto-resolve app by bundle):
|
||||
|
||||
```bash
|
||||
ASC_APP_IDENTIFIER=ai.openclaw.client
|
||||
ASC_APP_IDENTIFIER=ai.openclawfoundation.app
|
||||
# or
|
||||
ASC_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID
|
||||
```
|
||||
@@ -53,7 +53,26 @@ Code signing variable (optional in `.env`):
|
||||
IOS_DEVELOPMENT_TEAM=YOUR_TEAM_ID
|
||||
```
|
||||
|
||||
Tip: run `scripts/ios-team-id.sh` from repo root to print a Team ID for `.env`. The helper prefers the canonical OpenClaw team (`Y5PE65HELJ`) when present locally; otherwise it prefers the first non-personal team from your Xcode account (then personal team if needed). Fastlane uses this helper automatically if `IOS_DEVELOPMENT_TEAM` is missing.
|
||||
Tip: run `scripts/ios-team-id.sh --require-canonical` from repo root to verify the canonical OpenClaw iOS team (`FWJYW4S8P8`) is available locally. Fastlane uses the same canonical-only path when `IOS_DEVELOPMENT_TEAM` is missing, and rejects non-canonical teams for release archives.
|
||||
|
||||
App Store release signing is manual and profile-pinned. The canonical manifest is `apps/ios/Config/AppStoreSigning.json`.
|
||||
|
||||
One-time or rotation setup:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:signing:plan
|
||||
pnpm ios:release:signing:check
|
||||
pnpm ios:release:signing:setup
|
||||
```
|
||||
|
||||
Shared encrypted signing storage:
|
||||
|
||||
```bash
|
||||
ASC_MATCH_PASSWORD=... pnpm ios:release:signing:sync:push
|
||||
ASC_MATCH_PASSWORD=... pnpm ios:release:signing:sync:pull
|
||||
```
|
||||
|
||||
The signing repo is private and encrypted. Store `ASC_MATCH_PASSWORD` in the release-owner vault, not in this product repo. `sync:pull` writes decrypted assets under `apps/ios/build/signing/`; import the distribution certificate/private key into Keychain before archiving.
|
||||
|
||||
For local/manual iOS builds that stay on direct APNs, configure the gateway host separately with `OPENCLAW_APNS_TEAM_ID`, `OPENCLAW_APNS_KEY_ID`, and either `OPENCLAW_APNS_PRIVATE_KEY_P8` or `OPENCLAW_APNS_PRIVATE_KEY_PATH`. Those gateway runtime env vars are separate from Fastlane's `.env`.
|
||||
|
||||
@@ -66,28 +85,36 @@ fastlane ios auth_check
|
||||
|
||||
ASC auth is only required when:
|
||||
|
||||
- uploading to TestFlight
|
||||
- uploading to App Store Connect
|
||||
- auto-resolving the next build number from App Store Connect
|
||||
|
||||
If you pass `--build-number` to `pnpm ios:beta:archive`, the local archive path does not need ASC auth.
|
||||
If you pass `--build-number` to `pnpm ios:release:archive`, the local archive path does not need ASC auth.
|
||||
|
||||
Archive locally without upload:
|
||||
|
||||
```bash
|
||||
pnpm ios:beta:archive
|
||||
pnpm ios:release:archive
|
||||
```
|
||||
|
||||
Upload to TestFlight:
|
||||
Generate deterministic App Store screenshots:
|
||||
|
||||
```bash
|
||||
pnpm ios:beta
|
||||
pnpm ios:screenshots
|
||||
```
|
||||
|
||||
The screenshot lane runs the app with `--openclaw-screenshot-mode`, which enters the built-in connected screenshot fixture instead of pairing with a live gateway. By default it captures the tab set on `iPhone 16 Pro Max` and `iPad Pro 13-inch (M4)`; override devices with a comma-separated `OPENCLAW_SNAPSHOT_DEVICES` value when the requested simulators exist locally.
|
||||
|
||||
Upload to App Store Connect:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:upload
|
||||
```
|
||||
|
||||
Direct Fastlane entry point:
|
||||
|
||||
```bash
|
||||
cd apps/ios
|
||||
fastlane ios beta
|
||||
fastlane ios release_upload
|
||||
```
|
||||
|
||||
Maintainer recovery path for a fresh clone on the same Mac:
|
||||
@@ -115,7 +142,7 @@ fastlane ios auth_check
|
||||
pnpm ios:version:pin -- --from-gateway
|
||||
```
|
||||
|
||||
5. Set the official/TestFlight relay URL before release:
|
||||
5. Set the official relay URL before release:
|
||||
|
||||
```bash
|
||||
export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
|
||||
@@ -124,14 +151,14 @@ export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
|
||||
6. Upload:
|
||||
|
||||
```bash
|
||||
pnpm ios:beta
|
||||
pnpm ios:release:upload
|
||||
```
|
||||
|
||||
Quick verification after upload:
|
||||
|
||||
- confirm `apps/ios/build/beta/OpenClaw-<version>.ipa` exists
|
||||
- confirm Fastlane prints `Uploaded iOS beta: version=<version> short=<short> build=<build>`
|
||||
- remember that TestFlight processing can take a few minutes after the upload succeeds
|
||||
- confirm `apps/ios/build/app-store/OpenClaw-<version>.ipa` exists
|
||||
- confirm Fastlane prints `Uploaded iOS App Store build: version=<version> short=<short> build=<build>`
|
||||
- remember that App Store Connect/TestFlight processing can take a few minutes after the upload succeeds
|
||||
|
||||
Versioning rules:
|
||||
|
||||
@@ -141,9 +168,10 @@ Versioning rules:
|
||||
- `pnpm ios:version:pin -- --from-gateway` promotes the current root gateway version into the pinned iOS release version
|
||||
- Fastlane uses the pinned iOS version only; changing `package.json.version` alone does not change the iOS app version
|
||||
- Fastlane sets `CFBundleShortVersionString` to the pinned iOS version, for example `2026.4.10`
|
||||
- Fastlane resolves `CFBundleVersion` as the next integer TestFlight build number for that short version
|
||||
- Fastlane resolves `CFBundleVersion` as the next integer App Store Connect build number for that short version
|
||||
- Run `pnpm ios:version:sync` after changing `apps/ios/version.json` or `apps/ios/CHANGELOG.md`
|
||||
- `pnpm ios:version:check` validates that checked-in iOS version artifacts are in sync
|
||||
- The beta flow regenerates `apps/ios/OpenClaw.xcodeproj` from `apps/ios/project.yml` before archiving
|
||||
- Local beta signing uses a temporary generated xcconfig and leaves local development signing overrides untouched
|
||||
- The release flow regenerates `apps/ios/OpenClaw.xcodeproj` from `apps/ios/project.yml` before archiving
|
||||
- Local App Store signing uses a temporary generated xcconfig with profile names from `apps/ios/Config/AppStoreSigning.json` and leaves local development signing overrides untouched
|
||||
- `pnpm ios:release:upload` generates and uploads screenshots and release notes before archiving, then uploads the IPA without submitting it for App Review
|
||||
- See `apps/ios/VERSIONING.md` for the detailed workflow
|
||||
|
||||
24
apps/ios/fastlane/Snapfile
Normal file
24
apps/ios/fastlane/Snapfile
Normal file
@@ -0,0 +1,24 @@
|
||||
project("OpenClaw.xcodeproj")
|
||||
scheme("OpenClawUITests")
|
||||
configuration("Debug")
|
||||
|
||||
devices([
|
||||
"iPhone 16 Pro Max",
|
||||
"iPad Pro 13-inch (M4)",
|
||||
])
|
||||
|
||||
languages([
|
||||
"en-US",
|
||||
])
|
||||
|
||||
launch_arguments([
|
||||
"--openclaw-screenshot-mode",
|
||||
])
|
||||
|
||||
output_directory("fastlane/screenshots")
|
||||
clear_previous_screenshots(true)
|
||||
reinstall_app(true)
|
||||
concurrent_simulators(false)
|
||||
override_status_bar(true)
|
||||
override_status_bar_arguments("--time 09:41 --dataNetwork wifi --wifiMode active --wifiBars 3 --cellularMode active --cellularBars 4 --batteryState charged --batteryLevel 100")
|
||||
skip_open_summary(true)
|
||||
@@ -10,6 +10,15 @@ ASC_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID \
|
||||
DELIVER_METADATA=1 fastlane ios metadata
|
||||
```
|
||||
|
||||
## Release notes only
|
||||
|
||||
`pnpm ios:release:upload` uses this mode before archiving so the editable App Store version has current release notes without rewriting all metadata:
|
||||
|
||||
```bash
|
||||
cd apps/ios
|
||||
DELIVER_RELEASE_NOTES=1 fastlane ios metadata
|
||||
```
|
||||
|
||||
## Optional: include screenshots
|
||||
|
||||
```bash
|
||||
@@ -39,6 +48,7 @@ Or set `APP_STORE_CONNECT_API_KEY_PATH`.
|
||||
- `release_notes.txt` is generated from `apps/ios/CHANGELOG.md`; after changelog updates, run `pnpm ios:version:sync`.
|
||||
- Release notes resolve from `## <pinned iOS version>` first, then fall back to `## Unreleased` while a TestFlight train is still in progress.
|
||||
- When starting a new production release train, pin the iOS version first with `pnpm ios:version:pin -- --from-gateway`.
|
||||
- The release upload flow uploads release notes and screenshots before the IPA, and never submits for App Review.
|
||||
- `privacy_url.txt` is set to `https://openclaw.ai/privacy`.
|
||||
- If app lookup fails in `deliver`, set one of:
|
||||
- `ASC_APP_IDENTIFIER` (bundle ID)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: OpenClaw
|
||||
options:
|
||||
bundleIdPrefix: ai.openclaw
|
||||
bundleIdPrefix: ai.openclawfoundation
|
||||
deploymentTarget:
|
||||
iOS: "18.0"
|
||||
xcodeVersion: "16.0"
|
||||
@@ -37,6 +37,19 @@ schemes:
|
||||
test:
|
||||
targets:
|
||||
- OpenClawLogicTests
|
||||
OpenClawUITests:
|
||||
shared: true
|
||||
build:
|
||||
targets:
|
||||
OpenClawUITests: all
|
||||
test:
|
||||
targets:
|
||||
- OpenClawUITests
|
||||
OpenClawWatchApp:
|
||||
shared: true
|
||||
build:
|
||||
targets:
|
||||
OpenClawWatchApp: all
|
||||
|
||||
targets:
|
||||
OpenClaw:
|
||||
@@ -91,7 +104,7 @@ targets:
|
||||
swiftlint lint --config "$SRCROOT/.swiftlint.yml" --use-script-input-file-lists
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_IDENTITY: "Apple Development"
|
||||
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
|
||||
CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
@@ -105,11 +118,13 @@ targets:
|
||||
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
|
||||
configs:
|
||||
Debug:
|
||||
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT: development
|
||||
OPENCLAW_PUSH_TRANSPORT: direct
|
||||
OPENCLAW_PUSH_DISTRIBUTION: local
|
||||
OPENCLAW_PUSH_RELAY_BASE_URL: ""
|
||||
OPENCLAW_PUSH_APNS_ENVIRONMENT: sandbox
|
||||
Release:
|
||||
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT: production
|
||||
OPENCLAW_PUSH_TRANSPORT: direct
|
||||
OPENCLAW_PUSH_DISTRIBUTION: local
|
||||
OPENCLAW_PUSH_RELAY_BASE_URL: ""
|
||||
@@ -120,7 +135,7 @@ targets:
|
||||
CFBundleDisplayName: OpenClaw
|
||||
CFBundleIconName: AppIcon
|
||||
CFBundleURLTypes:
|
||||
- CFBundleURLName: ai.openclaw.ios
|
||||
- CFBundleURLName: ai.openclawfoundation.app
|
||||
CFBundleURLSchemes:
|
||||
- openclaw
|
||||
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
|
||||
@@ -133,7 +148,7 @@ targets:
|
||||
- audio
|
||||
- remote-notification
|
||||
BGTaskSchedulerPermittedIdentifiers:
|
||||
- ai.openclaw.ios.bgrefresh
|
||||
- "$(OPENCLAW_APP_BUNDLE_ID).bgrefresh"
|
||||
NSLocalNetworkUsageDescription: OpenClaw discovers and connects to your OpenClaw gateway on the local network.
|
||||
NSAppTransportSecurity:
|
||||
NSAllowsArbitraryLoadsInWebContent: true
|
||||
@@ -176,7 +191,7 @@ targets:
|
||||
- sdk: AppIntents.framework
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_IDENTITY: "Apple Development"
|
||||
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
ENABLE_APPINTENTS_METADATA: NO
|
||||
@@ -216,10 +231,11 @@ targets:
|
||||
- sdk: ActivityKit.framework
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_IDENTITY: "Apple Development"
|
||||
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID)"
|
||||
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_ACTIVITY_WIDGET_PROFILE)"
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
@@ -251,7 +267,7 @@ targets:
|
||||
settings:
|
||||
base:
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
CODE_SIGN_IDENTITY: "Apple Development"
|
||||
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
ENABLE_APPINTENTS_METADATA: NO
|
||||
@@ -285,7 +301,7 @@ targets:
|
||||
ProvisioningStyle: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_IDENTITY: "Apple Development"
|
||||
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_EXTENSION_BUNDLE_ID)"
|
||||
@@ -318,10 +334,10 @@ targets:
|
||||
- sdk: AppIntents.framework
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_IDENTITY: "Apple Development"
|
||||
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: ai.openclaw.ios.tests
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_APP_BUNDLE_ID).tests"
|
||||
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
@@ -346,10 +362,10 @@ targets:
|
||||
- package: OpenClawKit
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_IDENTITY: "Apple Development"
|
||||
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: ai.openclaw.ios.logic-tests
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_APP_BUNDLE_ID).logic-tests"
|
||||
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
|
||||
SWIFT_EMIT_CONST_VALUE_PROTOCOLS: ""
|
||||
SWIFT_VERSION: "6.0"
|
||||
@@ -360,3 +376,32 @@ targets:
|
||||
CFBundleDisplayName: OpenClawLogicTests
|
||||
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
|
||||
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
|
||||
|
||||
OpenClawUITests:
|
||||
type: bundle.ui-testing
|
||||
platform: iOS
|
||||
configFiles:
|
||||
Debug: Signing.xcconfig
|
||||
Release: Signing.xcconfig
|
||||
sources:
|
||||
- path: UITests
|
||||
dependencies:
|
||||
- target: OpenClaw
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_APP_BUNDLE_ID).ui-tests"
|
||||
SDKROOT: iphoneos
|
||||
SUPPORTED_PLATFORMS: "iphonesimulator iphoneos"
|
||||
SUPPORTS_MACCATALYST: NO
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
|
||||
TEST_TARGET_NAME: OpenClaw
|
||||
SWIFT_VERSION: "5.0"
|
||||
info:
|
||||
path: UITests/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClawUITests
|
||||
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
|
||||
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
b121079a0912b3051a9fc319a675ef920da9db23364ca0c0ccd3c9f0a05a3a49 plugin-sdk-api-baseline.json
|
||||
61a0108da670e0f44ba4b861c002eb6eaa5cf63e392d4e7e7de42044cbe7d115 plugin-sdk-api-baseline.jsonl
|
||||
303312830e2d7275bfe5abcdbdb3b47fd8648067a7b51ca043503a78bb18d275 plugin-sdk-api-baseline.json
|
||||
71e94e1de9f1b03aa44da55ec63d16146ab279740c44854d5998bc0f04d6ae0d plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -157,6 +157,9 @@ If stdout is non-empty, that text is the delivered result. If stdout is empty an
|
||||
<ParamField path="--model" type="string">
|
||||
Model override; uses the selected allowed model for the job.
|
||||
</ParamField>
|
||||
<ParamField path="--clear-model" type="boolean">
|
||||
On `cron edit`, removes the per-job model override so the job follows normal cron model-selection precedence (a stored cron-session override if set, otherwise the agent/default model). Cannot be combined with `--model`.
|
||||
</ParamField>
|
||||
<ParamField path="--thinking" type="string">
|
||||
Thinking level override.
|
||||
</ParamField>
|
||||
@@ -471,6 +474,7 @@ Model override note:
|
||||
- If the model is allowed, that exact provider/model reaches the isolated agent run.
|
||||
- If it is not allowed or cannot be resolved, cron fails the run with an explicit validation error.
|
||||
- API `cron.update` payload patches can set `model: null` to clear a stored job model override.
|
||||
- `openclaw cron edit <job-id> --clear-model` clears that override from the CLI (same effect as the `model: null` patch) and cannot be combined with `--model`.
|
||||
- Configured fallback chains still apply because cron `--model` is a job primary, not a session `/model` override.
|
||||
- Payload `fallbacks` replaces configured fallbacks for that job; `fallbacks: []` disables fallback and makes the run strict.
|
||||
- A plain `--model` with no explicit or configured fallback list does not fall through to the agent primary as a silent extra retry target.
|
||||
|
||||
@@ -360,7 +360,7 @@ A sweeper runs every **60 seconds** and handles four things:
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Tasks and sessions">
|
||||
A task may reference a `childSessionKey` (where work runs) and a `requesterSessionKey` (who started it). Sessions are conversation context; tasks are activity tracking on top of that.
|
||||
A task may reference a `childSessionKey` (where work runs) and a `requesterSessionKey` (who started it). Its `agentId` identifies the agent executing the work, while the requester and owner fields preserve launch and control context. Sessions are conversation context; tasks are activity tracking on top of that.
|
||||
</Accordion>
|
||||
<Accordion title="Tasks and agent runs">
|
||||
A task's `runId` links to the agent run doing the work. Agent lifecycle events (start, end, error) automatically update the task status - you do not need to manage the lifecycle manually.
|
||||
|
||||
@@ -274,6 +274,23 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Group history context defaults to `mention-only`: prior group messages are
|
||||
included only when they were addressed to the bot, are replies to the bot,
|
||||
or are the bot's own messages. Set `includeGroupHistoryContext: "recent"` to
|
||||
include recent room history for trusted groups. Set
|
||||
`includeGroupHistoryContext: "none"` to send no prior Telegram group history
|
||||
with the next turn.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
includeGroupHistoryContext: "recent",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Getting the group chat ID:
|
||||
@@ -403,11 +420,11 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
<Accordion title="Rich message formatting">
|
||||
Outbound text uses Telegram rich messages.
|
||||
|
||||
- Markdown text is sent as rich Markdown without converting it to HTML.
|
||||
- Explicit HTML payloads are sent as rich HTML.
|
||||
- Markdown text is rendered through OpenClaw's Markdown IR and sent as Telegram rich HTML.
|
||||
- Explicit rich HTML payloads preserve supported Bot API 10.1 tags such as headings, tables, details, rich media, and formulas.
|
||||
- Media captions still use Telegram HTML captions because rich messages do not replace captions.
|
||||
|
||||
Long rich text is split automatically across Telegram's rich text and rich block limits. Tables over Telegram's column limit are sent as code blocks.
|
||||
This keeps model text away from Telegram Rich Markdown sigils, so currency like `$400-600K` is not parsed as math. Long rich text is split automatically across Telegram's rich text and rich block limits. Tables over Telegram's column limit are sent as code blocks.
|
||||
|
||||
Link previews are enabled by default. `channels.telegram.linkPreview: false` skips automatic entity detection for rich text.
|
||||
|
||||
|
||||
@@ -168,7 +168,7 @@ Use `--due` when you want the manual command to run only if the job is currently
|
||||
|
||||
## Models
|
||||
|
||||
`cron add|edit --model <ref>` selects an allowed model for the job.
|
||||
`cron add|edit --model <ref>` selects an allowed model for the job. `cron edit <job-id> --clear-model` removes the per-job model override so the job follows normal cron model-selection precedence (a stored cron-session override if present, otherwise the agent/default model); it cannot be combined with `--model`.
|
||||
|
||||
<Warning>
|
||||
If the model is not allowed or cannot be resolved, cron fails the run with an explicit validation error instead of falling back to the job's agent or default model selection.
|
||||
|
||||
@@ -18,9 +18,8 @@ report drift through `doctor --lint`. The final conformance signal is a clean
|
||||
instead of creating a separate health gate.
|
||||
|
||||
Policy currently manages configured channels, MCP servers, model providers,
|
||||
network SSRF posture, ingress/channel access posture, Gateway exposure posture,
|
||||
feed catalog source posture, agent workspace posture, data-handling posture,
|
||||
OpenClaw config secret provider/auth profile posture, and governed tool
|
||||
network SSRF posture, ingress/channel access posture, Gateway exposure posture, agent workspace posture,
|
||||
data-handling posture, OpenClaw config secret provider/auth profile posture, and governed tool
|
||||
declarations. For example, IT or a workspace operator can record that Telegram
|
||||
is not an approved channel provider, restrict MCP servers and model refs to
|
||||
approved entries, require private-network fetch/browser access to remain
|
||||
@@ -115,13 +114,6 @@ data-handling posture, config secret provider/auth profile posture, and tool met
|
||||
"requireUrlAllowlists": true,
|
||||
},
|
||||
},
|
||||
"feeds": {
|
||||
"sources": {
|
||||
"require": ["company-approved"],
|
||||
"requirePinned": true,
|
||||
"allowUnsigned": false,
|
||||
},
|
||||
},
|
||||
"agents": {
|
||||
"workspace": {
|
||||
"allowedAccess": ["none", "ro"],
|
||||
@@ -180,9 +172,8 @@ when a concrete rule is present. OpenClaw reads current `channels.*` settings
|
||||
settings, direct-message session scope, channel DM policy, channel group policy,
|
||||
channel/group mention gates, Gateway bind/auth/Control UI/Tailscale/remote/HTTP
|
||||
posture, OpenClaw config agent sandbox workspace access and tool deny posture,
|
||||
configured Feeds plugin source declarations, OpenClaw config agent sandbox
|
||||
workspace access and tool deny posture, data-handling config posture, config
|
||||
secret provider and SecretRef provenance, config auth profile metadata, configured
|
||||
data-handling config posture, config secret
|
||||
provider and SecretRef provenance, config auth profile metadata, configured
|
||||
global/per-agent tool posture, and `TOOLS.md` declarations as evidence, then
|
||||
reports observed state that does not conform. If a policy denies non-loopback
|
||||
Gateway binds, omit `gateway.bind` only when you
|
||||
@@ -369,17 +360,6 @@ Every scope present in `policy.jsonc` must be valid and enforceable.
|
||||
| `gateway.http.denyEndpoints` | Gateway HTTP API endpoints | Deny endpoint ids such as `chatCompletions` or `responses`. |
|
||||
| `gateway.http.requireUrlAllowlists` | Gateway HTTP URL-fetch inputs | Set to `true` to require URL allowlists on URL-fetch inputs. |
|
||||
|
||||
#### Feed catalog sources
|
||||
|
||||
| Policy field | Observed state | Use when |
|
||||
| ----------------------------- | ------------------------------------------------ | -------------------------------------------------------------- |
|
||||
| `feeds.sources.require` | `plugins.entries.feeds.config.sources[].id` | Require specific feed source ids to be configured and enabled. |
|
||||
| `feeds.sources.requirePinned` | Feed source `trust` and `integrity` declarations | Set to `true` to require enabled feed sources to be pinned. |
|
||||
| `feeds.sources.allowUnsigned` | Feed source `trust` declarations | Set to `false` to reject enabled sources using unsigned trust. |
|
||||
|
||||
Feed policy observes only configured source declarations. It does not fetch
|
||||
feed documents, install entries, or enforce install decisions at runtime.
|
||||
|
||||
#### Agent workspace
|
||||
|
||||
| Policy field | Observed state | Use when |
|
||||
@@ -611,16 +591,6 @@ Example JSON output:
|
||||
"value": false
|
||||
}
|
||||
],
|
||||
"feeds": [
|
||||
{
|
||||
"id": "company-approved",
|
||||
"source": "oc://openclaw.config/plugins/entries/feeds/config/sources/#0",
|
||||
"enabled": true,
|
||||
"url": "https://feeds.example.com#0123456789ab",
|
||||
"trust": "pinned",
|
||||
"integrityPresent": true
|
||||
}
|
||||
],
|
||||
"gatewayExposure": [
|
||||
{
|
||||
"id": "gateway-bind",
|
||||
@@ -770,9 +740,6 @@ Policy currently verifies:
|
||||
| `policy/gateway-remote-enabled` | Gateway remote mode is active when policy denies it. |
|
||||
| `policy/gateway-http-endpoint-enabled` | A Gateway HTTP API endpoint is enabled while denied by policy. |
|
||||
| `policy/gateway-http-url-fetch-unrestricted` | Gateway HTTP URL-fetch input lacks a required URL allowlist. |
|
||||
| `policy/feeds-required-source-missing` | A required feed source id is not configured and enabled. |
|
||||
| `policy/feeds-source-unpinned` | An enabled feed source is not pinned when policy requires pinned feeds. |
|
||||
| `policy/feeds-source-unsigned` | An enabled feed source uses unsigned trust when policy denies unsigned feeds. |
|
||||
| `policy/agents-workspace-access-denied` | Agent sandbox mode or workspace access is outside the policy allowlist. |
|
||||
| `policy/agents-tool-not-denied` | An agent or default config does not deny a tool required by policy. |
|
||||
| `policy/tools-profile-unapproved` | A configured global or per-agent tool profile is outside the allowlist. |
|
||||
|
||||
@@ -166,7 +166,7 @@ surfaces, while Codex native hooks remain a separate lower-level Codex mechanism
|
||||
- `agent.wait` default: 30s (just the wait). `timeoutMs` param overrides.
|
||||
- Agent runtime: `agents.defaults.timeoutSeconds` default 172800s (48 hours); enforced in `runEmbeddedAgent` abort timer.
|
||||
- Cron runtime: isolated agent-turn `timeoutSeconds` is owned by cron. The scheduler starts that timer when execution begins, aborts the underlying run at the configured deadline, then runs bounded cleanup before recording the timeout so a stale child session cannot keep the lane stuck.
|
||||
- Session liveness diagnostics: with diagnostics enabled, `diagnostics.stuckSessionWarnMs` classifies long `processing` sessions that have no observed reply, tool, status, block, or ACP progress. Active embedded runs, model calls, and tool calls report as `session.long_running`; active work with no recent progress reports as `session.stalled`; `session.stuck` is reserved for recoverable stale session bookkeeping, including idle queued sessions with stale ownerless model/tool activity. Stale session bookkeeping releases the affected session lane immediately after recovery gates pass; stalled embedded runs are abort-drained only after `diagnostics.stuckSessionAbortMs` (default: at least 5 minutes and 3x the warning threshold) so queued work can resume without cutting off merely slow runs. Recovery emits structured requested/completed outcomes, and diagnostic state is marked idle only if the same processing generation is still current. Repeated `session.stuck` diagnostics back off while the session remains unchanged.
|
||||
- Session liveness diagnostics: with diagnostics enabled, `diagnostics.stuckSessionWarnMs` classifies long `processing` sessions that have no observed reply, tool, status, block, or ACP progress. Active embedded runs, model calls, and tool calls report as `session.long_running`; owned silent model calls also stay `session.long_running` until `diagnostics.stuckSessionAbortMs` so slow or non-streaming providers are not reported as stalled too early. Active work with no recent progress reports as `session.stalled`; owned model calls switch to `session.stalled` at or after the abort threshold, and ownerless stale model/tool activity is not hidden as long-running. `session.stuck` is reserved for recoverable stale session bookkeeping, including idle queued sessions with stale ownerless model/tool activity. Stale session bookkeeping releases the affected session lane immediately after recovery gates pass; stalled embedded runs are abort-drained only after `diagnostics.stuckSessionAbortMs` (default: at least 5 minutes and 3x the warning threshold) so queued work can resume without cutting off merely slow runs. Recovery emits structured requested/completed outcomes, and diagnostic state is marked idle only if the same processing generation is still current. Repeated `session.stuck` diagnostics back off while the session remains unchanged.
|
||||
- Model idle timeout: OpenClaw aborts a model request when no response chunks arrive before the idle window. `models.providers.<id>.timeoutSeconds` extends this idle watchdog for slow local/self-hosted providers, but it is still bounded by any lower `agents.defaults.timeoutSeconds` or run-specific timeout because those control the whole agent run. Otherwise OpenClaw uses `agents.defaults.timeoutSeconds` when configured, capped at 120s by default. Cron-triggered runs with no explicit model or agent timeout disable the idle watchdog and rely on the cron outer timeout.
|
||||
- Provider HTTP request timeout: `models.providers.<id>.timeoutSeconds` applies to that provider's model HTTP fetches, including connect, headers, body, SDK request timeout, total guarded-fetch abort handling, and model stream idle watchdog. Use this for slow local/self-hosted providers such as Ollama before raising the whole agent runtime timeout, and keep the agent/runtime timeout at least as high when the model request needs to run longer.
|
||||
|
||||
|
||||
@@ -56,8 +56,9 @@ the resolved scenarios through `qa suite`. `--surface` and
|
||||
`--category` filter the selected profile instead of defining separate lanes.
|
||||
The resulting `qa-evidence.json` includes a profile scorecard summary with
|
||||
selected-category counts and missing coverage IDs; the individual evidence
|
||||
entries remain the source of truth for the tests, coverage roles, artifacts,
|
||||
and results:
|
||||
entries remain the source of truth for the tests, coverage roles, and results.
|
||||
Slim evidence omits per-entry `execution` and sets `evidenceMode: "slim"`;
|
||||
`smoke-ci` defaults to slim, and `--evidence-mode full` restores full entries:
|
||||
|
||||
```bash
|
||||
pnpm openclaw qa run \
|
||||
|
||||
@@ -126,7 +126,7 @@ keys.
|
||||
- If commands seem stuck, enable verbose logs and look for "queued for ...ms" lines to confirm the queue is draining.
|
||||
- If you need queue depth, enable verbose logs and watch for queue timing lines.
|
||||
- Codex app-server runs that accept a turn and then stop emitting progress are interrupted by the Codex adapter so the active session lane can release instead of waiting for the outer run timeout.
|
||||
- When diagnostics are enabled, sessions that remain in `processing` past `diagnostics.stuckSessionWarnMs` with no observed reply, tool, status, block, or ACP progress are classified by current activity. Active work logs as `session.long_running`; active work with no recent progress logs as `session.stalled`; `session.stuck` is reserved for recoverable stale session bookkeeping, including idle queued sessions with stale ownerless model/tool activity, and only that path can release the affected session lane so queued work drains. Repeated `session.stuck` diagnostics back off while the session remains unchanged.
|
||||
- When diagnostics are enabled, sessions that remain in `processing` past `diagnostics.stuckSessionWarnMs` with no observed reply, tool, status, block, or ACP progress are classified by current activity. Active work logs as `session.long_running`; owned silent model calls also stay `session.long_running` until `diagnostics.stuckSessionAbortMs` so slow or non-streaming providers are not reported as stalled too early. Active work with no recent progress logs as `session.stalled`; owned model calls switch to `session.stalled` at or after the abort threshold, and ownerless stale model/tool activity is not hidden as long-running. `session.stuck` is reserved for recoverable stale session bookkeeping, including idle queued sessions with stale ownerless model/tool activity, and only that path can release the affected session lane so queued work drains. Repeated `session.stuck` diagnostics back off while the session remains unchanged.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -35,9 +35,12 @@ You can use Claude Code CLI **without any config** (the bundled Anthropic plugin
|
||||
registers a default backend):
|
||||
|
||||
```bash
|
||||
openclaw agent --message "hi" --model claude-cli/claude-sonnet-4-6
|
||||
openclaw agent --agent main --message "hi" --model claude-cli/claude-sonnet-4-6
|
||||
```
|
||||
|
||||
`main` is the default agent id when no explicit agent list is configured. If
|
||||
you use multiple agents, replace it with the agent id you want to run.
|
||||
|
||||
If your gateway runs under launchd/systemd and PATH is minimal, add just the
|
||||
command path:
|
||||
|
||||
|
||||
@@ -249,12 +249,18 @@ still be detected.
|
||||
OpenClaw classifies sessions by the work it can still observe:
|
||||
|
||||
- `session.long_running`: active embedded work, model calls, or tool calls are
|
||||
still making progress.
|
||||
still making progress. Owned model calls that stay silent past
|
||||
`diagnostics.stuckSessionWarnMs` also report as long-running before
|
||||
`diagnostics.stuckSessionAbortMs` so slow or non-streaming model providers do
|
||||
not look like stalled gateway sessions while they remain abort-observable.
|
||||
- `session.stalled`: active work exists, but the active run has not reported
|
||||
recent progress. Stalled embedded runs stay observe-only at first, then
|
||||
abort-drain after `diagnostics.stuckSessionAbortMs` with no progress so queued
|
||||
turns behind the lane can resume. When unset, the abort threshold defaults to
|
||||
the safer extended window of at least 5 minutes and 3x
|
||||
recent progress. Owned model calls switch from `session.long_running` to
|
||||
`session.stalled` at or after `diagnostics.stuckSessionAbortMs`; ownerless
|
||||
stale model/tool activity is not treated as harmless long-running work.
|
||||
Stalled embedded runs stay observe-only at first, then abort-drain after
|
||||
`diagnostics.stuckSessionAbortMs` with no progress so queued turns behind the
|
||||
lane can resume. When unset, the abort threshold defaults to the safer
|
||||
extended window of at least 5 minutes and 3x
|
||||
`diagnostics.stuckSessionWarnMs`.
|
||||
- `session.stuck`: stale session bookkeeping with no active work, or an idle
|
||||
queued session with stale ownerless model/tool activity. This releases the
|
||||
|
||||
@@ -542,7 +542,9 @@ runtime state.
|
||||
`TaskSummary` includes `id`, `status`, and optional metadata such as `kind`,
|
||||
`runtime`, `title`, `agentId`, `sessionKey`, `childSessionKey`, `ownerKey`,
|
||||
`runId`, `taskId`, `flowId`, `parentTaskId`, `sourceId`, timestamps, progress,
|
||||
terminal summary, and sanitized error text.
|
||||
terminal summary, and sanitized error text. `agentId` identifies the agent
|
||||
executing the task; `sessionKey` and `ownerKey` preserve requester and control
|
||||
context.
|
||||
|
||||
### Operator helper methods
|
||||
|
||||
|
||||
@@ -189,6 +189,8 @@ inside every shard.
|
||||
mixed flow, Vitest, and Playwright scenario selections.
|
||||
- When dispatched by `pnpm openclaw qa run --qa-profile <profile>`, embeds the
|
||||
selected taxonomy profile scorecard in the same `qa-evidence.json`.
|
||||
`smoke-ci` writes slim evidence, which sets `evidenceMode: "slim"` and omits
|
||||
per-entry `execution`.
|
||||
- Runs multiple selected scenarios in parallel by default with isolated
|
||||
gateway workers. `qa-channel` defaults to concurrency 4 (bounded by the
|
||||
selected scenario count). Use `--concurrency <count>` to tune the worker
|
||||
|
||||
@@ -431,7 +431,7 @@ Notes:
|
||||
- Unrecognized node `platform` / `deviceFamily` metadata uses a conservative default allowlist that excludes `system.run` and `system.which`. If you intentionally need those commands for an unknown platform, add them explicitly via `gateway.nodes.allowCommands`.
|
||||
- `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`.
|
||||
- For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped `--env` values are reduced to an explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`).
|
||||
- For allow-always decisions in allowlist mode, known dispatch wrappers (`env`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper paths. If unwrapping is not safe, no allowlist entry is persisted automatically.
|
||||
- For allow-always decisions in allowlist mode, known dispatch wrappers (`env`, `flock`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper paths. If unwrapping is not safe, no allowlist entry is persisted automatically.
|
||||
- On Windows node hosts in allowlist mode, shell-wrapper runs via `cmd.exe /c` require approval (allowlist entry alone does not auto-allow the wrapper form).
|
||||
- `system.notify` supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`.
|
||||
- Node hosts ignore `PATH` overrides and strip dangerous startup/shell keys (`DYLD_*`, `LD_*`, `BASHOPTS`, `FPATH`, `KSH_ENV`, `NODE_OPTIONS`, `NODE_REDIRECT_WARNINGS`, `NODE_REPL_EXTERNAL_MODULE`, `NODE_REPL_HISTORY`, `NODE_V8_COVERAGE`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`, `TCLLIBPATH`). If you need extra PATH entries, configure the node host service environment (or install tools in standard locations) instead of passing `PATH` via `--env`.
|
||||
|
||||
@@ -109,7 +109,7 @@ Notes:
|
||||
- Choosing "Always Allow" in the prompt adds that command to the allowlist.
|
||||
- `system.run` environment overrides are filtered (drops `PATH`, `DYLD_*`, `LD_*`, `BASHOPTS`, `FPATH`, `KSH_ENV`, `NODE_OPTIONS`, `NODE_REDIRECT_WARNINGS`, `NODE_REPL_EXTERNAL_MODULE`, `NODE_REPL_HISTORY`, `NODE_V8_COVERAGE`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`, `TCLLIBPATH`) and then merged with the app's environment.
|
||||
- For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped environment overrides are reduced to a small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`).
|
||||
- For allow-always decisions in allowlist mode, known dispatch wrappers (`env`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper paths. If unwrapping is not safe, no allowlist entry is persisted automatically.
|
||||
- For allow-always decisions in allowlist mode, known dispatch wrappers (`env`, `flock`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper paths. If unwrapping is not safe, no allowlist entry is persisted automatically.
|
||||
|
||||
## Deep links
|
||||
|
||||
|
||||
@@ -186,6 +186,7 @@ into Windows.
|
||||
Inside WSL:
|
||||
|
||||
```bash
|
||||
sudo apt-get install -y dbus-x11
|
||||
sudo loginctl enable-linger "$(whoami)"
|
||||
openclaw gateway install
|
||||
```
|
||||
@@ -193,7 +194,7 @@ openclaw gateway install
|
||||
In PowerShell as Administrator:
|
||||
|
||||
```powershell
|
||||
schtasks /create /tn "WSL Boot" /tr "wsl.exe -d Ubuntu --exec /bin/true" /sc onstart /ru SYSTEM
|
||||
schtasks /create /tn "WSL Boot" /tr "wsl.exe -d Ubuntu --exec dbus-launch true" /sc onstart /ru "$env:USERNAME"
|
||||
```
|
||||
|
||||
Replace `Ubuntu` with your distro name from:
|
||||
@@ -202,6 +203,11 @@ Replace `Ubuntu` with your distro name from:
|
||||
wsl --list --verbose
|
||||
```
|
||||
|
||||
> **Note:** Two changes from older recipes:
|
||||
>
|
||||
> - **`dbus-launch true` instead of `/bin/true`** — On WSL ≥ 2.6.1.0 a regression ([microsoft/WSL #13416](https://github.com/microsoft/WSL/issues/13416)) causes the distro to idle-terminate 15–20 seconds after the last client exits, even with linger enabled. `dbus-launch true` keeps a child-of-init process alive as a workaround ([community discussion, microsoft/WSL #9245](https://github.com/microsoft/WSL/discussions/9245)).
|
||||
> - **`/ru "$env:USERNAME"` instead of `/ru SYSTEM`** — Per-user WSL distros (the default setup) are not visible to the SYSTEM account; the task appears to run but the distro is never started. Running as your own account avoids this. Windows will prompt for your password when the task is created.
|
||||
|
||||
After reboot, verify from WSL:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -132,6 +132,9 @@ Current compatibility records include:
|
||||
`reply(...)`, and `mediaPath` while callback consumers migrate to the nested
|
||||
`WebInboundCallbackMessage` `event`, `payload`, `quote`, `group`, and
|
||||
`platform` contexts
|
||||
- WhatsApp `WebInboundMessage` top-level admission fields such as `from`,
|
||||
`conversationId`, `accountId`, `accessControlPassed`, and `chatType` while
|
||||
callback consumers migrate to the `admission` envelope
|
||||
- legacy memory-plugin split registration while memory plugins move to
|
||||
`registerMemoryCapability`
|
||||
- legacy memory-specific embedding provider registration while embedding
|
||||
@@ -191,6 +194,23 @@ name its exact nested replacement. Common examples:
|
||||
Plugins should inspect the `label`, `source`, and `type` before treating its
|
||||
`payload` as authoritative.
|
||||
|
||||
### WhatsApp Inbound Admission Fields
|
||||
|
||||
Accepted WhatsApp callback messages now carry `admission`, a public-safe
|
||||
envelope for the access-control decision that admitted the message. New callback
|
||||
code should read admission facts from `msg.admission` instead of the older
|
||||
top-level admission fields.
|
||||
|
||||
The top-level fields remain available until **2026-08-30**. The TypeScript
|
||||
`@deprecated` annotations name each replacement:
|
||||
|
||||
- `from` and `conversationId` move to `admission.conversation.id`.
|
||||
- `accountId` moves to `admission.accountId`.
|
||||
- `accessControlPassed` is a derived compatibility view of
|
||||
`admission.ingress.decision === "allow"`; on messages that already carry
|
||||
`admission`, writing the legacy boolean does not rewrite the ingress graph.
|
||||
- `chatType` moves to `admission.conversation.kind`.
|
||||
|
||||
## Release notes
|
||||
|
||||
Release notes should include upcoming plugin deprecations with target dates and
|
||||
|
||||
@@ -51,7 +51,7 @@ Each entry lists the package, distribution route, and description.
|
||||
|
||||
## Core npm package
|
||||
|
||||
91 plugins
|
||||
90 plugins
|
||||
|
||||
- **[admin-http-rpc](/plugins/reference/admin-http-rpc)** (`@openclaw/admin-http-rpc`) - included in OpenClaw. OpenClaw admin HTTP RPC endpoint.
|
||||
|
||||
@@ -101,8 +101,6 @@ Each entry lists the package, distribution route, and description.
|
||||
|
||||
- **[fal](/plugins/reference/fal)** (`@openclaw/fal-provider`) - included in OpenClaw. Adds fal model provider support to OpenClaw.
|
||||
|
||||
- **[feeds](/plugins/reference/feeds)** (`@openclaw/feeds`) - included in OpenClaw. Adds configured catalog feed source validation for skills and plugins.
|
||||
|
||||
- **[file-transfer](/plugins/reference/file-transfer)** (`@openclaw/file-transfer`) - included in OpenClaw. Fetch, list, and write files on paired nodes via dedicated node commands. Bypasses bash stdout truncation by using base64 over node.invoke for binaries up to 16 MB.
|
||||
|
||||
- **[firecrawl](/plugins/reference/firecrawl)** (`@openclaw/firecrawl-plugin`) - included in OpenClaw. Adds agent-callable tools. Adds web fetch provider support. Adds web search provider support.
|
||||
|
||||
@@ -15,5 +15,5 @@ This page is generated from `extensions/*/package.json` and
|
||||
pnpm plugins:inventory:gen
|
||||
```
|
||||
|
||||
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 128
|
||||
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 127
|
||||
generated plugin reference pages by distribution, package, and description.
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
---
|
||||
summary: "Adds configured catalog feed source validation for skills and plugins."
|
||||
read_when:
|
||||
- You are installing, configuring, or auditing the feeds plugin
|
||||
title: "Feeds plugin"
|
||||
---
|
||||
|
||||
# Feeds plugin
|
||||
|
||||
Adds configured catalog feed source validation for skills and plugins.
|
||||
|
||||
## Distribution
|
||||
|
||||
- Package: `@openclaw/feeds`
|
||||
- Install route: included in OpenClaw
|
||||
|
||||
## Surface
|
||||
|
||||
plugin
|
||||
|
||||
## Configure feed sources
|
||||
|
||||
Feed sources live under the bundled `feeds` plugin config. A source can point at
|
||||
an `https://` or `file://` feed document and can optionally be pinned by
|
||||
integrity.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"feeds": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"id": "company-approved",
|
||||
"url": "https://feeds.example.com/openclaw/feed.json",
|
||||
"trust": "pinned",
|
||||
"integrity": "sha256:...",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Discover entries
|
||||
|
||||
```bash
|
||||
openclaw feeds sources
|
||||
openclaw feeds list --source company-approved
|
||||
openclaw feeds search calendar --type plugin
|
||||
```
|
||||
|
||||
## Install from a feed
|
||||
|
||||
`openclaw feeds install` resolves exactly one feed entry, checks the configured
|
||||
feed install policy, and then hands off to the existing OpenClaw skill or plugin
|
||||
install command. The feeds plugin does not introduce a second installer.
|
||||
|
||||
```bash
|
||||
openclaw feeds install calendar-helper --source company-approved --type plugin --dry-run
|
||||
openclaw feeds install calendar-helper --source company-approved --type plugin
|
||||
openclaw feeds install calendar-helper --source company-approved --type plugin --force
|
||||
```
|
||||
|
||||
Use `--dry-run` to print the underlying install command without running it. Use
|
||||
`--force` to forward force behavior to the existing installer.
|
||||
|
||||
## Install policy
|
||||
|
||||
`installPolicy` controls approval checks for explicit feed-backed installs.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"feeds": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"installPolicy": {
|
||||
"mode": "enforce",
|
||||
"requireApproval": true,
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"id": "company-approved",
|
||||
"url": "file:///opt/openclaw/feeds/company.json",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- `mode: "off"` performs no approval check.
|
||||
- `mode: "warn"` reports unapproved entries and continues.
|
||||
- `mode: "enforce"` blocks unapproved entries.
|
||||
- `requireApproval: true` requires `approval.status: "approved"` on feed entries.
|
||||
|
||||
If `requireApproval` is `true` and `mode` is omitted, OpenClaw treats the policy
|
||||
as enforce. If `mode` is `enforce` and `requireApproval` is omitted, approval is
|
||||
required.
|
||||
@@ -89,10 +89,11 @@ reduced to a small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`,
|
||||
`NO_COLOR`, `FORCE_COLOR`).
|
||||
|
||||
For `allow-always` decisions in allowlist mode, known dispatch wrappers (`env`,
|
||||
`nice`, `nohup`, `stdbuf`, `timeout`) persist the inner executable path instead
|
||||
of the wrapper path. Shell multiplexers (`busybox`, `toybox`) are unwrapped for
|
||||
shell applets (`sh`, `ash`, etc.) the same way. If a wrapper or multiplexer
|
||||
cannot be safely unwrapped, no allowlist entry is persisted automatically.
|
||||
`flock`, `nice`, `nohup`, `stdbuf`, `timeout`) persist the inner executable path
|
||||
instead of the wrapper path. Shell multiplexers (`busybox`, `toybox`) are
|
||||
unwrapped for shell applets (`sh`, `ash`, etc.) the same way. If a wrapper or
|
||||
multiplexer cannot be safely unwrapped, no allowlist entry is persisted
|
||||
automatically.
|
||||
|
||||
If you allowlist interpreters like `python3` or `node`, prefer
|
||||
`tools.exec.strictInlineEval=true` so inline eval still requires an explicit
|
||||
|
||||
@@ -231,7 +231,8 @@ plugins.
|
||||
| `/help` | Show the short help summary |
|
||||
| `/commands` | Show the generated command catalog |
|
||||
| `/tools [compact\|verbose]` | Show what the current agent can use right now |
|
||||
| `/status` | Show execution/runtime status, Gateway and system uptime, plus provider usage/quota |
|
||||
| `/status` | Show execution/runtime status, Gateway and system uptime, plugin health, plus provider usage/quota |
|
||||
| `/status plugins` | Show detailed plugin health: load errors, quarantines, channel failures, dependency issues, compatibility notices |
|
||||
| `/goal [status\|start\|pause\|resume\|complete\|block\|clear] ...` | Manage the current session's durable [goal](/tools/goal) |
|
||||
| `/diagnostics [note]` | Owner-only support-report flow. Asks for exec approval every time |
|
||||
| `/crestodian <request>` | Run the Crestodian setup and repair helper from an owner DM |
|
||||
|
||||
@@ -48,6 +48,8 @@ export function buildAnthropicCliBackend(): CliBackendPlugin {
|
||||
"user",
|
||||
"--allowedTools",
|
||||
"mcp__openclaw__*",
|
||||
"--disallowedTools",
|
||||
"ScheduleWakeup,CronCreate",
|
||||
],
|
||||
resumeArgs: [
|
||||
"-p",
|
||||
@@ -59,6 +61,8 @@ export function buildAnthropicCliBackend(): CliBackendPlugin {
|
||||
"user",
|
||||
"--allowedTools",
|
||||
"mcp__openclaw__*",
|
||||
"--disallowedTools",
|
||||
"ScheduleWakeup,CronCreate",
|
||||
"--resume",
|
||||
"{sessionId}",
|
||||
],
|
||||
|
||||
6
extensions/diagnostics-otel/npm-shrinkwrap.json
generated
6
extensions/diagnostics-otel/npm-shrinkwrap.json
generated
@@ -748,9 +748,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.4.0.tgz",
|
||||
"integrity": "sha512-iriNhQ57SYA5Jbdi+41AyPdx6jPPkFO7DODzkOBmqFhgYn/JzX2HxgxYPY18eQAs3CP/AWqtPvkWn8rclRAxdQ==",
|
||||
"version": "8.4.1",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.4.1.tgz",
|
||||
"integrity": "sha512-oXf2UgIty8jnwfN4yvL1x79VLhL5uiKjZJbSGXGCIUmHmItTP4eS/UIlWDCeNx3seg+ujfn9vDlPMSrsh7wO+Q==",
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
|
||||
@@ -2390,6 +2390,49 @@ describe("DiscordVoiceManager", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects malformed realtime consult tool calls without crashing Discord voice", async () => {
|
||||
const manager = createManager({
|
||||
groupPolicy: "open",
|
||||
voice: {
|
||||
enabled: true,
|
||||
mode: "agent-proxy",
|
||||
realtime: { provider: "openai" },
|
||||
},
|
||||
});
|
||||
|
||||
await manager.join({ guildId: "g1", channelId: "1001" });
|
||||
const bridgeParams = lastRealtimeBridgeParams() as
|
||||
| {
|
||||
onToolCall?: (
|
||||
event: {
|
||||
itemId: string;
|
||||
callId: string;
|
||||
name: string;
|
||||
args: unknown;
|
||||
},
|
||||
session: typeof realtimeSessionMock,
|
||||
) => void;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
expect(() =>
|
||||
bridgeParams?.onToolCall?.(
|
||||
{
|
||||
itemId: "item-empty-consult",
|
||||
callId: "call-empty-consult",
|
||||
name: "openclaw_agent_consult",
|
||||
args: {},
|
||||
},
|
||||
realtimeSessionMock,
|
||||
),
|
||||
).not.toThrow();
|
||||
|
||||
expect(agentCommandMock).not.toHaveBeenCalled();
|
||||
expect(realtimeSessionMock.submitToolResult).toHaveBeenCalledWith("call-empty-consult", {
|
||||
error: "question required",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not require speaker context for internal exact-speech consults", async () => {
|
||||
const manager = createManager({
|
||||
groupPolicy: "open",
|
||||
|
||||
@@ -1124,7 +1124,17 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
|
||||
session.submitToolResult(callId, { text: exactSpeechText });
|
||||
return;
|
||||
}
|
||||
const consultMessage = buildRealtimeVoiceAgentConsultChatMessage(event.args);
|
||||
let consultMessage: string;
|
||||
try {
|
||||
consultMessage = buildRealtimeVoiceAgentConsultChatMessage(event.args);
|
||||
} catch (error) {
|
||||
const message = formatErrorMessage(error);
|
||||
logger.warn(
|
||||
`discord voice: realtime consult rejected malformed args call=${callId || "unknown"}: ${message}`,
|
||||
);
|
||||
session.submitToolResult(callId, { error: message });
|
||||
return;
|
||||
}
|
||||
logger.info(
|
||||
`discord voice: realtime consult requested call=${callId || "unknown"} voiceSession=${this.params.entry.voiceSessionKey} supervisorSession=${this.params.entry.route.sessionKey} agent=${this.params.entry.route.agentId} question=${formatRealtimeLogPreview(consultMessage)}`,
|
||||
);
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { registerFeedsDoctorChecks } from "./src/doctor/register.js";
|
||||
@@ -1,28 +0,0 @@
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { registerFeedsCli } from "./src/cli.js";
|
||||
import { registerFeedsDoctorChecks } from "./src/doctor/register.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "feeds",
|
||||
name: "Feeds",
|
||||
description: "Adds configured catalog feed source validation for skills and plugins.",
|
||||
register(api) {
|
||||
api.registerCli(
|
||||
async ({ program }) => {
|
||||
registerFeedsCli(program);
|
||||
},
|
||||
{
|
||||
descriptors: [
|
||||
{
|
||||
name: "feeds",
|
||||
description: "Inspect configured skill and plugin catalog feeds",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
registerFeedsDoctorChecks();
|
||||
},
|
||||
});
|
||||
export { registerFeedsCli } from "./src/cli.js";
|
||||
export { registerFeedsDoctorChecks } from "./src/doctor/register.js";
|
||||
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"id": "feeds",
|
||||
"name": "Feeds",
|
||||
"description": "Adds configured catalog feed source validation for skills and plugins.",
|
||||
"activation": {
|
||||
"onStartup": false,
|
||||
"onCommands": ["doctor", "feeds"]
|
||||
},
|
||||
"commandAliases": [{ "name": "feeds", "kind": "cli" }],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable feeds doctor checks."
|
||||
},
|
||||
"installPolicy": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "Optional install-time policy for entries discovered through feeds.",
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["off", "warn", "enforce"],
|
||||
"description": "Install policy mode. Defaults to off."
|
||||
},
|
||||
"requireApproval": {
|
||||
"type": "boolean",
|
||||
"description": "Require feed entries to declare approval.status=approved before install."
|
||||
}
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"description": "Catalog feed sources used for curated skill and plugin discovery.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "url"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Stable local feed identifier."
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "Absolute https:// or file:// feed document URL."
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable this feed source. Defaults to true."
|
||||
},
|
||||
"trust": {
|
||||
"type": "string",
|
||||
"enum": ["unsigned", "pinned"],
|
||||
"description": "Whether this source is accepted unsigned or pinned by integrity hash."
|
||||
},
|
||||
"integrity": {
|
||||
"type": "string",
|
||||
"description": "Optional sha256:<hex> hash for pinned feed documents."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"name": "@openclaw/feeds",
|
||||
"version": "2026.5.28",
|
||||
"private": true,
|
||||
"description": "OpenClaw feed source configuration and doctor checks",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*",
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.5.28"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,848 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
feedsBuildCommand,
|
||||
feedsDiffCommand,
|
||||
feedsInstallCommand,
|
||||
feedsListCommand,
|
||||
feedsNoticesCommand,
|
||||
feedsHashCommand,
|
||||
feedsSearchCommand,
|
||||
feedsSourcesCommand,
|
||||
feedsUpdatesCommand,
|
||||
feedsValidateCommand,
|
||||
type FeedsCommandRuntime,
|
||||
} from "./cli.js";
|
||||
|
||||
describe("Feeds CLI", () => {
|
||||
it("lists configured sources", async () => {
|
||||
const runtime = createRuntime({ sources: [{ id: "approved", url: "file:///feeds.json" }] });
|
||||
const exitCode = await feedsSourcesCommand({ json: true }, runtime);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(JSON.parse(runtime.stdout).sources).toEqual([
|
||||
{ id: "approved", url: "file:///feeds.json", enabled: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists an empty source set when no sources are configured", async () => {
|
||||
const runtime = createRuntime({});
|
||||
const exitCode = await feedsSourcesCommand({ json: true }, runtime);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(JSON.parse(runtime.stdout).sources).toEqual([]);
|
||||
expect(runtime.stderr).toBe("");
|
||||
});
|
||||
|
||||
it("loads file-backed feed entries", async () => {
|
||||
const feed = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "company-approved",
|
||||
entries: [
|
||||
{ type: "skill", id: "excel-review", version: "1.2.3", name: "Excel Review" },
|
||||
{ type: "plugin", id: "teams-channel", tags: ["m365", "channel"] },
|
||||
],
|
||||
});
|
||||
const runtime = createRuntime({
|
||||
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
|
||||
files: { "/feeds/company.json": feed },
|
||||
});
|
||||
|
||||
const exitCode = await feedsListCommand({ json: true }, runtime);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(JSON.parse(runtime.stdout).entries).toEqual([
|
||||
expect.objectContaining({
|
||||
sourceId: "approved",
|
||||
feedId: "company-approved",
|
||||
id: "excel-review",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
sourceId: "approved",
|
||||
feedId: "company-approved",
|
||||
id: "teams-channel",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("searches across entry metadata", async () => {
|
||||
const feed = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "company-approved",
|
||||
entries: [
|
||||
{ type: "skill", id: "excel-review", tags: ["m365"] },
|
||||
{ type: "plugin", id: "calendar-helper", tags: ["outlook"] },
|
||||
],
|
||||
});
|
||||
const runtime = createRuntime({
|
||||
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
|
||||
files: { "/feeds/company.json": feed },
|
||||
});
|
||||
|
||||
const exitCode = await feedsSearchCommand("outlook", { json: true }, runtime);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(JSON.parse(runtime.stdout).entries).toEqual([
|
||||
expect.objectContaining({ id: "calendar-helper" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("checks pinned feed integrity while loading entries", async () => {
|
||||
const feed = JSON.stringify({ schemaVersion: 1, id: "company-approved", entries: [] });
|
||||
const integrity = `sha256:${createHash("sha256").update(feed).digest("hex").toUpperCase()}`;
|
||||
const runtime = createRuntime({
|
||||
sources: [{ id: "approved", url: "file:///feeds/company.json", trust: "pinned", integrity }],
|
||||
files: { "/feeds/company.json": feed },
|
||||
});
|
||||
|
||||
const exitCode = await feedsListCommand({ json: true }, runtime);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(JSON.parse(runtime.stdout).entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects pinned feed sources without integrity", async () => {
|
||||
const feed = JSON.stringify({ schemaVersion: 1, id: "company-approved", entries: [] });
|
||||
const runtime = createRuntime({
|
||||
sources: [{ id: "approved", url: "file:///feeds/company.json", trust: "pinned" }],
|
||||
files: { "/feeds/company.json": feed },
|
||||
});
|
||||
|
||||
const exitCode = await feedsListCommand({ json: true }, runtime);
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(runtime.stderr).toContain("Feed source approved requires integrity for pinned trust.");
|
||||
});
|
||||
|
||||
it("formats install hints without installing feed entries", async () => {
|
||||
const feed = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "company-approved",
|
||||
entries: [
|
||||
{
|
||||
type: "plugin",
|
||||
id: "calendar-helper",
|
||||
name: "Calendar Helper",
|
||||
install: { source: "clawhub", spec: "openclaw-calendar" },
|
||||
},
|
||||
{
|
||||
type: "skill",
|
||||
id: "excel-review",
|
||||
install: { source: "clawhub", slug: "excel-review" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const runtime = createRuntime({
|
||||
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
|
||||
files: { "/feeds/company.json": feed },
|
||||
});
|
||||
|
||||
runtime.isTTY = true;
|
||||
|
||||
const exitCode = await feedsSearchCommand("calendar", { type: "plugin" }, runtime);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(runtime.stdout).toContain("approved\tplugin\tcalendar-helper - Calendar Helper");
|
||||
expect(runtime.stdout).toContain("Install: openclaw plugins install clawhub:openclaw-calendar");
|
||||
expect(runtime.stdout).not.toContain("excel-review");
|
||||
});
|
||||
|
||||
it("quotes install hint specs from feed metadata", async () => {
|
||||
const feed = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "company-approved",
|
||||
entries: [
|
||||
{
|
||||
type: "plugin",
|
||||
id: "unsafe-helper",
|
||||
install: { source: "npm", spec: "safe-package && curl example.invalid" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const runtime = createRuntime({
|
||||
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
|
||||
files: { "/feeds/company.json": feed },
|
||||
});
|
||||
|
||||
runtime.isTTY = true;
|
||||
|
||||
const exitCode = await feedsSearchCommand("unsafe", { type: "plugin" }, runtime);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(runtime.stdout).toContain(
|
||||
"Install: openclaw plugins install 'safe-package && curl example.invalid'",
|
||||
);
|
||||
});
|
||||
|
||||
it("filters search results by entry type", async () => {
|
||||
const feed = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "company-approved",
|
||||
entries: [
|
||||
{ type: "plugin", id: "calendar-helper", tags: ["shared"] },
|
||||
{ type: "skill", id: "calendar-review", tags: ["shared"] },
|
||||
],
|
||||
});
|
||||
const runtime = createRuntime({
|
||||
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
|
||||
files: { "/feeds/company.json": feed },
|
||||
});
|
||||
|
||||
const exitCode = await feedsSearchCommand("shared", { type: "plugin", json: true }, runtime);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(JSON.parse(runtime.stdout).entries).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "plugin",
|
||||
id: "calendar-helper",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects unsupported type filters", async () => {
|
||||
const runtime = createRuntime({ sources: [{ id: "approved", url: "file:///feeds.json" }] });
|
||||
const exitCode = await feedsListCommand({ type: "tool" }, runtime);
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(runtime.stderr).toContain("Invalid --type value. Expected skill or plugin.");
|
||||
});
|
||||
|
||||
it("reports available feed updates from an installed inventory", async () => {
|
||||
const feed = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "company-approved",
|
||||
entries: [
|
||||
{
|
||||
type: "skill",
|
||||
id: "excel-review",
|
||||
version: "1.2.0",
|
||||
approval: { status: "approved" },
|
||||
},
|
||||
{
|
||||
type: "plugin",
|
||||
id: "calendar-helper",
|
||||
version: "1.0.0",
|
||||
},
|
||||
],
|
||||
});
|
||||
const inventory = JSON.stringify({
|
||||
entries: [
|
||||
{ type: "skill", id: "excel-review", version: "1.0.0" },
|
||||
{ type: "plugin", id: "calendar-helper", version: "1.0.0" },
|
||||
],
|
||||
});
|
||||
const runtime = createRuntime({
|
||||
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
|
||||
files: {
|
||||
"/feeds/company.json": feed,
|
||||
"/installed.json": inventory,
|
||||
},
|
||||
});
|
||||
|
||||
const exitCode = await feedsUpdatesCommand(
|
||||
{ installed: "/installed.json", json: true },
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(JSON.parse(runtime.stdout).updates).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "skill",
|
||||
id: "excel-review",
|
||||
installedVersion: "1.0.0",
|
||||
availableVersion: "1.2.0",
|
||||
approved: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters feed updates to approved entries", async () => {
|
||||
const feed = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "company-approved",
|
||||
entries: [
|
||||
{ type: "skill", id: "approved-skill", version: "2.0.0", approval: { status: "approved" } },
|
||||
{ type: "skill", id: "draft-skill", version: "2.0.0" },
|
||||
],
|
||||
});
|
||||
const inventory = JSON.stringify({
|
||||
entries: [
|
||||
{ type: "skill", id: "approved-skill", version: "1.0.0" },
|
||||
{ type: "skill", id: "draft-skill", version: "1.0.0" },
|
||||
],
|
||||
});
|
||||
const runtime = createRuntime({
|
||||
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
|
||||
files: {
|
||||
"/feeds/company.json": feed,
|
||||
"/installed.json": inventory,
|
||||
},
|
||||
});
|
||||
|
||||
const exitCode = await feedsUpdatesCommand(
|
||||
{ installed: "/installed.json", approvedOnly: true, json: true },
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(JSON.parse(runtime.stdout).updates.map((entry: { id: string }) => entry.id)).toEqual([
|
||||
"approved-skill",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses semver prerelease ordering for update notices", async () => {
|
||||
const feed = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "company-approved",
|
||||
entries: [
|
||||
{
|
||||
type: "plugin",
|
||||
id: "calendar-helper",
|
||||
version: "1.0.0-beta.1",
|
||||
approval: { status: "approved" },
|
||||
install: { source: "clawhub", spec: "openclaw-calendar" },
|
||||
},
|
||||
{
|
||||
type: "plugin",
|
||||
id: "sheet-helper",
|
||||
version: "1.0.0",
|
||||
approval: { status: "approved" },
|
||||
install: { source: "clawhub", spec: "openclaw-sheet" },
|
||||
},
|
||||
{
|
||||
type: "plugin",
|
||||
id: "docs-helper",
|
||||
version: "1.0.0-rc.1",
|
||||
approval: { status: "approved" },
|
||||
install: { source: "clawhub", spec: "openclaw-docs" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const inventory = JSON.stringify({
|
||||
entries: [
|
||||
{ type: "plugin", id: "calendar-helper", version: "1.0.0" },
|
||||
{ type: "plugin", id: "sheet-helper", version: "1.0.0-beta.1" },
|
||||
{ type: "plugin", id: "docs-helper", version: "1.0.0-beta.9" },
|
||||
],
|
||||
});
|
||||
const runtime = createRuntime({
|
||||
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
|
||||
files: {
|
||||
"/feeds/company.json": feed,
|
||||
"/installed.json": inventory,
|
||||
},
|
||||
});
|
||||
|
||||
const exitCode = await feedsUpdatesCommand(
|
||||
{ installed: "/installed.json", approvedOnly: true, json: true },
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
const updates = JSON.parse(runtime.stdout).updates;
|
||||
expect(updates).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "sheet-helper",
|
||||
installedVersion: "1.0.0-beta.1",
|
||||
availableVersion: "1.0.0",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "docs-helper",
|
||||
installedVersion: "1.0.0-beta.9",
|
||||
availableVersion: "1.0.0-rc.1",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(updates).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("reports subscriber update notices", async () => {
|
||||
const feed = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "company-approved",
|
||||
entries: [
|
||||
{
|
||||
type: "plugin",
|
||||
id: "calendar-helper",
|
||||
version: "1.2.0",
|
||||
approval: { status: "approved" },
|
||||
install: { source: "clawhub", spec: "openclaw-calendar" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const inventory = JSON.stringify({
|
||||
entries: [{ type: "plugin", id: "calendar-helper", version: "1.0.0" }],
|
||||
});
|
||||
const runtime = createRuntime({
|
||||
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
|
||||
files: {
|
||||
"/feeds/company.json": feed,
|
||||
"/installed.json": inventory,
|
||||
},
|
||||
});
|
||||
|
||||
const exitCode = await feedsNoticesCommand(
|
||||
{ installed: "/installed.json", approvedOnly: true, json: true },
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(JSON.parse(runtime.stdout).notices).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "plugin",
|
||||
id: "calendar-helper",
|
||||
sourceId: "approved",
|
||||
installedVersion: "1.0.0",
|
||||
availableVersion: "1.2.0",
|
||||
approved: true,
|
||||
installCommand: "openclaw plugins install clawhub:openclaw-calendar",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("validates a local feed document and prints its integrity", async () => {
|
||||
const feed = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "company-approved",
|
||||
entries: [{ type: "skill", id: "excel-review" }],
|
||||
});
|
||||
const integrity = `sha256:${createHash("sha256").update(feed).digest("hex")}`;
|
||||
const runtime = createRuntime({
|
||||
sources: [],
|
||||
files: { "/feeds/company.json": feed },
|
||||
});
|
||||
|
||||
const exitCode = await feedsValidateCommand("/feeds/company.json", { json: true }, runtime);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(JSON.parse(runtime.stdout)).toEqual({
|
||||
ok: true,
|
||||
id: "company-approved",
|
||||
entries: 1,
|
||||
integrity,
|
||||
});
|
||||
});
|
||||
|
||||
it("prints a local feed document integrity hash", async () => {
|
||||
const feed = JSON.stringify({ schemaVersion: 1, id: "company-approved", entries: [] });
|
||||
const integrity = `sha256:${createHash("sha256").update(feed).digest("hex")}`;
|
||||
const runtime = createRuntime({
|
||||
sources: [],
|
||||
files: { "/feeds/company.json": feed },
|
||||
});
|
||||
|
||||
const exitCode = await feedsHashCommand("/feeds/company.json", {}, runtime);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(runtime.stdout).toBe(`${integrity}\n`);
|
||||
});
|
||||
|
||||
it("rejects invalid local feed documents during validation", async () => {
|
||||
const runtime = createRuntime({
|
||||
sources: [],
|
||||
files: { "/feeds/broken.json": JSON.stringify({ schemaVersion: 1, id: "broken" }) },
|
||||
});
|
||||
|
||||
const exitCode = await feedsValidateCommand("/feeds/broken.json", {}, runtime);
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(runtime.stderr).toContain("entries must be an array");
|
||||
});
|
||||
|
||||
it("builds a curated feed artifact from local inventory rules", async () => {
|
||||
const inventory = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "upstream",
|
||||
entries: [
|
||||
{
|
||||
type: "skill",
|
||||
id: "excel-review",
|
||||
version: "1.0.0",
|
||||
tags: ["m365", "approved"],
|
||||
approval: { status: "approved" },
|
||||
},
|
||||
{ type: "plugin", id: "draft-plugin", tags: ["m365"] },
|
||||
{
|
||||
type: "skill",
|
||||
id: "blocked-skill",
|
||||
tags: ["m365", "blocked"],
|
||||
approval: { status: "approved" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const rules = JSON.stringify({
|
||||
includeTypes: ["skill"],
|
||||
includeTags: ["m365"],
|
||||
excludeTags: ["blocked"],
|
||||
requireApproval: true,
|
||||
});
|
||||
const runtime = createRuntime({
|
||||
sources: [],
|
||||
files: { "/feeds/inventory.json": inventory, "/feeds/rules.json": rules },
|
||||
});
|
||||
|
||||
const exitCode = await feedsBuildCommand(
|
||||
{
|
||||
inventory: "/feeds/inventory.json",
|
||||
rules: "/feeds/rules.json",
|
||||
out: "/feeds/lobster-approved.json",
|
||||
id: "lobster-approved",
|
||||
generatedAt: "2026-05-28T00:00:00.000Z",
|
||||
json: true,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(JSON.parse(runtime.stdout)).toEqual(
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
id: "lobster-approved",
|
||||
entries: 1,
|
||||
out: "/feeds/lobster-approved.json",
|
||||
}),
|
||||
);
|
||||
expect(JSON.parse(runtime.writes["/feeds/lobster-approved.json"])).toEqual({
|
||||
schemaVersion: 1,
|
||||
id: "lobster-approved",
|
||||
generatedAt: "2026-05-28T00:00:00.000Z",
|
||||
entries: [
|
||||
expect.objectContaining({
|
||||
type: "skill",
|
||||
id: "excel-review",
|
||||
approval: { status: "approved" },
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("reports feed artifact deltas", async () => {
|
||||
const previous = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "lobster-approved",
|
||||
entries: [
|
||||
{
|
||||
type: "skill",
|
||||
id: "excel-review",
|
||||
version: "1.0.0",
|
||||
name: "Excel Review",
|
||||
sha256: "old",
|
||||
approval: { status: "pending" },
|
||||
},
|
||||
{ type: "plugin", id: "removed-plugin" },
|
||||
],
|
||||
});
|
||||
const current = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "lobster-approved",
|
||||
entries: [
|
||||
{
|
||||
type: "skill",
|
||||
id: "excel-review",
|
||||
version: "1.1.0",
|
||||
name: "Excel Review Pro",
|
||||
sha256: "new",
|
||||
approval: { status: "approved" },
|
||||
},
|
||||
{ type: "skill", id: "new-skill" },
|
||||
],
|
||||
});
|
||||
const runtime = createRuntime({
|
||||
sources: [],
|
||||
files: { "/feeds/previous.json": previous, "/feeds/current.json": current },
|
||||
});
|
||||
|
||||
const exitCode = await feedsDiffCommand(
|
||||
{ previous: "/feeds/previous.json", current: "/feeds/current.json", json: true },
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(JSON.parse(runtime.stdout)).toEqual(
|
||||
expect.objectContaining({
|
||||
added: [expect.objectContaining({ type: "skill", id: "new-skill" })],
|
||||
removed: [expect.objectContaining({ type: "plugin", id: "removed-plugin" })],
|
||||
updated: [{ type: "skill", id: "excel-review", previous: "1.0.0", current: "1.1.0" }],
|
||||
approvalChanged: [
|
||||
{ type: "skill", id: "excel-review", previous: "pending", current: "approved" },
|
||||
],
|
||||
metadataChanged: [{ type: "skill", id: "excel-review" }],
|
||||
hashChanged: [{ type: "skill", id: "excel-review", previous: "old", current: "new" }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("dry-runs an explicit feed-backed plugin install", async () => {
|
||||
const feed = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "company-approved",
|
||||
entries: [
|
||||
{
|
||||
type: "plugin",
|
||||
id: "calendar-helper",
|
||||
install: { source: "clawhub", spec: "openclaw-calendar" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const runtime = createRuntime({
|
||||
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
|
||||
files: { "/feeds/company.json": feed },
|
||||
});
|
||||
|
||||
const exitCode = await feedsInstallCommand("calendar-helper", { dryRun: true }, runtime);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(runtime.stdout).toBe("openclaw plugins install clawhub:openclaw-calendar\n");
|
||||
expect(runtime.commands).toEqual([]);
|
||||
});
|
||||
|
||||
it("runs the existing install command for a selected feed entry", async () => {
|
||||
const feed = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "company-approved",
|
||||
entries: [
|
||||
{ type: "skill", id: "excel-review", install: { source: "clawhub", slug: "excel-review" } },
|
||||
],
|
||||
});
|
||||
const runtime = createRuntime({
|
||||
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
|
||||
files: { "/feeds/company.json": feed },
|
||||
});
|
||||
|
||||
const exitCode = await feedsInstallCommand(
|
||||
"excel-review",
|
||||
{ type: "skill", force: true },
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(runtime.commands).toEqual([["skills", "install", "excel-review", "--force"]]);
|
||||
});
|
||||
|
||||
it("enforces approved feed install metadata when configured", async () => {
|
||||
const feed = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "company-approved",
|
||||
entries: [
|
||||
{
|
||||
type: "plugin",
|
||||
id: "calendar-helper",
|
||||
install: { source: "clawhub", spec: "openclaw-calendar" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const runtime = createRuntime({
|
||||
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
|
||||
installPolicy: { mode: "enforce", requireApproval: true },
|
||||
files: { "/feeds/company.json": feed },
|
||||
});
|
||||
|
||||
const exitCode = await feedsInstallCommand("calendar-helper", {}, runtime);
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(runtime.stderr).toContain("is not approved by feed metadata");
|
||||
expect(runtime.commands).toEqual([]);
|
||||
});
|
||||
|
||||
it("defaults enforce mode to approved-only installs", async () => {
|
||||
const feed = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "company-approved",
|
||||
entries: [
|
||||
{
|
||||
type: "plugin",
|
||||
id: "calendar-helper",
|
||||
install: { source: "clawhub", spec: "openclaw-calendar" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const runtime = createRuntime({
|
||||
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
|
||||
installPolicy: { mode: "enforce" },
|
||||
files: { "/feeds/company.json": feed },
|
||||
});
|
||||
|
||||
const exitCode = await feedsInstallCommand("calendar-helper", {}, runtime);
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(runtime.stderr).toContain("is not approved by feed metadata");
|
||||
expect(runtime.commands).toEqual([]);
|
||||
});
|
||||
|
||||
it("defaults requireApproval without a mode to enforce", async () => {
|
||||
const feed = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "company-approved",
|
||||
entries: [
|
||||
{
|
||||
type: "plugin",
|
||||
id: "calendar-helper",
|
||||
install: { source: "clawhub", spec: "openclaw-calendar" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const runtime = createRuntime({
|
||||
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
|
||||
installPolicy: { requireApproval: true },
|
||||
files: { "/feeds/company.json": feed },
|
||||
});
|
||||
|
||||
const exitCode = await feedsInstallCommand("calendar-helper", {}, runtime);
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(runtime.stderr).toContain("is not approved by feed metadata");
|
||||
expect(runtime.commands).toEqual([]);
|
||||
});
|
||||
|
||||
it("warns but installs unapproved feed entries in warn mode", async () => {
|
||||
const feed = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "company-approved",
|
||||
entries: [
|
||||
{
|
||||
type: "plugin",
|
||||
id: "calendar-helper",
|
||||
install: { source: "clawhub", spec: "openclaw-calendar" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const runtime = createRuntime({
|
||||
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
|
||||
installPolicy: { mode: "warn", requireApproval: true },
|
||||
files: { "/feeds/company.json": feed },
|
||||
});
|
||||
|
||||
const exitCode = await feedsInstallCommand("calendar-helper", {}, runtime);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(runtime.stderr).toContain("Warning: Feed entry 'calendar-helper' is not approved");
|
||||
expect(runtime.commands).toEqual([["plugins", "install", "clawhub:openclaw-calendar"]]);
|
||||
});
|
||||
|
||||
it("installs approved feed entries when enforcement is enabled", async () => {
|
||||
const feed = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "company-approved",
|
||||
entries: [
|
||||
{
|
||||
type: "plugin",
|
||||
id: "calendar-helper",
|
||||
install: { source: "clawhub", spec: "openclaw-calendar" },
|
||||
approval: { status: "approved", owner: "platform" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const runtime = createRuntime({
|
||||
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
|
||||
installPolicy: { mode: "enforce", requireApproval: true },
|
||||
files: { "/feeds/company.json": feed },
|
||||
});
|
||||
|
||||
const exitCode = await feedsInstallCommand("calendar-helper", {}, runtime);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(runtime.stderr).toBe("");
|
||||
expect(runtime.commands).toEqual([["plugins", "install", "clawhub:openclaw-calendar"]]);
|
||||
});
|
||||
|
||||
it("requires disambiguation before installing duplicate feed entry ids", async () => {
|
||||
const feed = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "company-approved",
|
||||
entries: [
|
||||
{ type: "skill", id: "shared", install: { source: "clawhub", slug: "shared-skill" } },
|
||||
{ type: "plugin", id: "shared", install: { source: "clawhub", spec: "shared-plugin" } },
|
||||
],
|
||||
});
|
||||
const runtime = createRuntime({
|
||||
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
|
||||
files: { "/feeds/company.json": feed },
|
||||
});
|
||||
|
||||
const exitCode = await feedsInstallCommand("shared", {}, runtime);
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(runtime.stderr).toContain("Use --source or --type to choose one.");
|
||||
expect(runtime.commands).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects feed install entries without supported install metadata", async () => {
|
||||
const feed = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: "company-approved",
|
||||
entries: [{ type: "plugin", id: "unknown", install: { source: "container" } }],
|
||||
});
|
||||
const runtime = createRuntime({
|
||||
sources: [{ id: "approved", url: "file:///feeds/company.json" }],
|
||||
files: { "/feeds/company.json": feed },
|
||||
});
|
||||
|
||||
const exitCode = await feedsInstallCommand("unknown", {}, runtime);
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(runtime.stderr).toContain("does not include supported install metadata");
|
||||
expect(runtime.commands).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function createRuntime(params: {
|
||||
readonly sources?: readonly Record<string, unknown>[];
|
||||
readonly files?: Readonly<Record<string, string>>;
|
||||
readonly installPolicy?: Record<string, unknown>;
|
||||
}): FeedsCommandRuntime & {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
isTTY?: boolean;
|
||||
commands: readonly string[][];
|
||||
writes: Record<string, string>;
|
||||
} {
|
||||
const runtime: FeedsCommandRuntime & {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
isTTY?: boolean;
|
||||
commands: string[][];
|
||||
writes: Record<string, string>;
|
||||
} = {
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
commands: [],
|
||||
writes: {},
|
||||
writeStdout(value) {
|
||||
this.stdout += value;
|
||||
},
|
||||
error(value) {
|
||||
this.stderr += `${value}\n`;
|
||||
},
|
||||
async runOpenClawCommand(argv) {
|
||||
runtime.commands.push([...argv]);
|
||||
return 0;
|
||||
},
|
||||
async writeFile(path, value) {
|
||||
runtime.writes[path] = value;
|
||||
},
|
||||
async readConfigSnapshot(): Promise<any> {
|
||||
return {
|
||||
valid: true,
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
feeds: {
|
||||
enabled: true,
|
||||
config: { sources: params.sources, installPolicy: params.installPolicy },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
async readFile(path) {
|
||||
const value = params.files?.[path];
|
||||
if (value === undefined) {
|
||||
throw new Error(`missing test file ${path}`);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
};
|
||||
return runtime;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,139 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
evaluateFeedsConfig,
|
||||
FEEDS_CHECK_IDS,
|
||||
registerFeedsDoctorChecks,
|
||||
resetFeedsDoctorChecksForTest,
|
||||
} from "./register.js";
|
||||
|
||||
describe("Feeds doctor checks", () => {
|
||||
it("registers each feeds health check once", () => {
|
||||
const registered: string[] = [];
|
||||
resetFeedsDoctorChecksForTest();
|
||||
|
||||
registerFeedsDoctorChecks({
|
||||
registerHealthCheck(check) {
|
||||
registered.push(check.id);
|
||||
},
|
||||
});
|
||||
|
||||
expect(registered).toEqual(FEEDS_CHECK_IDS);
|
||||
});
|
||||
|
||||
it("accepts configured https and file feed sources", () => {
|
||||
const findings = evaluateFeedsConfig({
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
feeds: {
|
||||
enabled: true,
|
||||
config: {
|
||||
sources: [
|
||||
{
|
||||
id: "company-approved",
|
||||
url: "https://feeds.example.com/openclaw/feed.json",
|
||||
trust: "pinned",
|
||||
integrity:
|
||||
"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
},
|
||||
{
|
||||
id: "local-review",
|
||||
url: "file:///opt/openclaw/feeds/review.json",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(findings).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports invalid install policy config", () => {
|
||||
const findings = evaluateFeedsConfig({
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
feeds: {
|
||||
enabled: true,
|
||||
config: {
|
||||
installPolicy: { mode: "block", requireApproval: "yes" },
|
||||
sources: [{ id: "approved", url: "https://feeds.example.com/root.json" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(findings).toEqual([
|
||||
expect.objectContaining({
|
||||
checkId: "feeds/config-invalid",
|
||||
ocPath: "oc://openclaw.config/plugins/entries/feeds/config/installPolicy/mode",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports duplicate ids, unsupported urls, and missing pinned integrity", () => {
|
||||
const findings = evaluateFeedsConfig({
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
feeds: {
|
||||
enabled: true,
|
||||
config: {
|
||||
sources: [
|
||||
{ id: "company", url: "https://feeds.example.com/openclaw/feed.json" },
|
||||
{ id: "company", url: "http://feeds.example.com/feed.json" },
|
||||
{ id: "pinned", url: "https://feeds.example.com/pinned.json", trust: "pinned" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "feeds/source-duplicate-id",
|
||||
ocPath: "oc://openclaw.config/plugins/entries/feeds/config/sources/#1/id",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
checkId: "feeds/source-url-invalid",
|
||||
ocPath: "oc://openclaw.config/plugins/entries/feeds/config/sources/#1/url",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
checkId: "feeds/source-integrity-missing",
|
||||
ocPath: "oc://openclaw.config/plugins/entries/feeds/config/sources/#2/integrity",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when the enabled feeds plugin has no sources", () => {
|
||||
const findings = evaluateFeedsConfig({
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
feeds: {
|
||||
enabled: true,
|
||||
config: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(findings).toEqual([
|
||||
expect.objectContaining({
|
||||
checkId: "feeds/source-missing",
|
||||
severity: "warning",
|
||||
ocPath: "oc://openclaw.config/plugins/entries/feeds/config/sources",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,337 +0,0 @@
|
||||
import {
|
||||
registerHealthCheck as registerPluginHealthCheck,
|
||||
type HealthCheck,
|
||||
type HealthCheckContext,
|
||||
type HealthFinding,
|
||||
} from "openclaw/plugin-sdk/health";
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
const CHECK_IDS = {
|
||||
configInvalid: "feeds/config-invalid",
|
||||
sourceMissing: "feeds/source-missing",
|
||||
sourceDuplicateId: "feeds/source-duplicate-id",
|
||||
sourceUrlInvalid: "feeds/source-url-invalid",
|
||||
sourceIntegrityInvalid: "feeds/source-integrity-invalid",
|
||||
sourceIntegrityMissing: "feeds/source-integrity-missing",
|
||||
} as const;
|
||||
|
||||
export const FEEDS_CHECK_IDS = [
|
||||
CHECK_IDS.configInvalid,
|
||||
CHECK_IDS.sourceMissing,
|
||||
CHECK_IDS.sourceDuplicateId,
|
||||
CHECK_IDS.sourceUrlInvalid,
|
||||
CHECK_IDS.sourceIntegrityInvalid,
|
||||
CHECK_IDS.sourceIntegrityMissing,
|
||||
] as const;
|
||||
|
||||
type FeedsCheckId = (typeof FEEDS_CHECK_IDS)[number];
|
||||
|
||||
export type FeedsDoctorRegistrationHost = {
|
||||
readonly registerHealthCheck: (check: HealthCheck) => void;
|
||||
};
|
||||
|
||||
let registered = false;
|
||||
|
||||
export function registerFeedsDoctorChecks(host?: FeedsDoctorRegistrationHost): void {
|
||||
if (registered) {
|
||||
return;
|
||||
}
|
||||
const registerHealthCheck = host?.registerHealthCheck ?? registerPluginHealthCheck;
|
||||
for (const check of feedsHealthChecks) {
|
||||
registerHealthCheck(check);
|
||||
}
|
||||
registered = true;
|
||||
}
|
||||
|
||||
export function resetFeedsDoctorChecksForTest(): void {
|
||||
registered = false;
|
||||
}
|
||||
|
||||
const feedsHealthChecks: readonly HealthCheck[] = FEEDS_CHECK_IDS.map((id) => ({
|
||||
id,
|
||||
kind: "plugin",
|
||||
description: feedsCheckDescription(id),
|
||||
source: "feeds",
|
||||
async detect(ctx) {
|
||||
return evaluateFeedsConfig(ctx).filter((finding) => finding.checkId === id);
|
||||
},
|
||||
}));
|
||||
|
||||
function feedsCheckDescription(id: FeedsCheckId): string {
|
||||
switch (id) {
|
||||
case CHECK_IDS.configInvalid:
|
||||
return "The Feeds plugin configuration is well-formed.";
|
||||
case CHECK_IDS.sourceMissing:
|
||||
return "The enabled Feeds plugin has at least one configured source.";
|
||||
case CHECK_IDS.sourceDuplicateId:
|
||||
return "Feed source ids are unique.";
|
||||
case CHECK_IDS.sourceUrlInvalid:
|
||||
return "Feed source URLs are supported absolute URLs.";
|
||||
case CHECK_IDS.sourceIntegrityInvalid:
|
||||
return "Feed source integrity hashes use sha256:<hex> syntax.";
|
||||
case CHECK_IDS.sourceIntegrityMissing:
|
||||
return "Pinned feed sources declare an integrity hash.";
|
||||
}
|
||||
const exhaustive: never = id;
|
||||
return exhaustive;
|
||||
}
|
||||
|
||||
export function evaluateFeedsConfig(
|
||||
ctx: Pick<HealthCheckContext, "cfg">,
|
||||
): readonly HealthFinding[] {
|
||||
const config = ctx.cfg.plugins?.entries?.feeds?.config;
|
||||
const configPath = "plugins.entries.feeds.config";
|
||||
const configOcPath = "oc://openclaw.config/plugins/entries/feeds/config";
|
||||
|
||||
if (config === undefined) {
|
||||
return [
|
||||
{
|
||||
checkId: CHECK_IDS.sourceMissing,
|
||||
severity: "warning",
|
||||
message: "The enabled Feeds plugin has no configured feed sources.",
|
||||
source: "feeds",
|
||||
path: configPath,
|
||||
ocPath: configOcPath,
|
||||
fixHint: "Add plugins.entries.feeds.config.sources with at least one feed source.",
|
||||
},
|
||||
];
|
||||
}
|
||||
if (!isRecord(config)) {
|
||||
return [
|
||||
invalidConfigFinding({
|
||||
propertyPath: configPath,
|
||||
target: configOcPath,
|
||||
message: "plugins.entries.feeds.config must be an object.",
|
||||
fixHint: "Set plugins.entries.feeds.config to an object with a sources array.",
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
const findings: HealthFinding[] = [];
|
||||
const installPolicyFinding = evaluateInstallPolicyConfig(config.installPolicy);
|
||||
if (installPolicyFinding !== undefined) {
|
||||
findings.push(installPolicyFinding);
|
||||
}
|
||||
const sources = config.sources;
|
||||
if (sources === undefined) {
|
||||
findings.push({
|
||||
checkId: CHECK_IDS.sourceMissing,
|
||||
severity: "warning",
|
||||
message: "The enabled Feeds plugin has no configured feed sources.",
|
||||
source: "feeds",
|
||||
path: `${configPath}.sources`,
|
||||
ocPath: `${configOcPath}/sources`,
|
||||
fixHint: "Add at least one feed source.",
|
||||
});
|
||||
return findings;
|
||||
}
|
||||
if (!Array.isArray(sources)) {
|
||||
findings.push(
|
||||
invalidConfigFinding({
|
||||
propertyPath: `${configPath}.sources`,
|
||||
target: `${configOcPath}/sources`,
|
||||
message: "plugins.entries.feeds.config.sources must be an array.",
|
||||
fixHint: "Set sources to an array of feed source objects.",
|
||||
}),
|
||||
);
|
||||
return findings;
|
||||
}
|
||||
if (sources.length === 0) {
|
||||
findings.push({
|
||||
checkId: CHECK_IDS.sourceMissing,
|
||||
severity: "warning",
|
||||
message: "The enabled Feeds plugin has an empty feed source list.",
|
||||
source: "feeds",
|
||||
path: `${configPath}.sources`,
|
||||
ocPath: `${configOcPath}/sources`,
|
||||
fixHint: "Add at least one feed source or disable the Feeds plugin.",
|
||||
});
|
||||
return findings;
|
||||
}
|
||||
|
||||
const seenIds = new Map<string, number>();
|
||||
sources.forEach((source, index) => {
|
||||
findings.push(...evaluateFeedSource(source, index, seenIds));
|
||||
});
|
||||
return findings;
|
||||
}
|
||||
|
||||
function evaluateInstallPolicyConfig(value: unknown): HealthFinding | undefined {
|
||||
const basePath = "plugins.entries.feeds.config.installPolicy";
|
||||
const baseOcPath = "oc://openclaw.config/plugins/entries/feeds/config/installPolicy";
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return invalidConfigFinding({
|
||||
propertyPath: basePath,
|
||||
target: baseOcPath,
|
||||
message: "plugins.entries.feeds.config.installPolicy must be an object.",
|
||||
fixHint: 'Use installPolicy: { mode: "warn", requireApproval: true } or remove it.',
|
||||
});
|
||||
}
|
||||
if (
|
||||
value.mode !== undefined &&
|
||||
value.mode !== "off" &&
|
||||
value.mode !== "warn" &&
|
||||
value.mode !== "enforce"
|
||||
) {
|
||||
return invalidConfigFinding({
|
||||
propertyPath: `${basePath}.mode`,
|
||||
target: `${baseOcPath}/mode`,
|
||||
message: "plugins.entries.feeds.config.installPolicy.mode must be off, warn, or enforce.",
|
||||
fixHint: 'Use mode "off", "warn", or "enforce".',
|
||||
});
|
||||
}
|
||||
if (value.requireApproval !== undefined && typeof value.requireApproval !== "boolean") {
|
||||
return invalidConfigFinding({
|
||||
propertyPath: `${basePath}.requireApproval`,
|
||||
target: `${baseOcPath}/requireApproval`,
|
||||
message: "plugins.entries.feeds.config.installPolicy.requireApproval must be a boolean.",
|
||||
fixHint: "Set requireApproval to true or false.",
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function evaluateFeedSource(
|
||||
source: unknown,
|
||||
index: number,
|
||||
seenIds: Map<string, number>,
|
||||
): readonly HealthFinding[] {
|
||||
const sourcePath = `plugins.entries.feeds.config.sources[${index}]`;
|
||||
const sourceOcPath = `oc://openclaw.config/plugins/entries/feeds/config/sources/#${index}`;
|
||||
if (!isRecord(source)) {
|
||||
return [
|
||||
invalidConfigFinding({
|
||||
propertyPath: sourcePath,
|
||||
target: sourceOcPath,
|
||||
message: `Feed source ${index} must be an object.`,
|
||||
fixHint: "Replace this source with an object containing id and url.",
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
const findings: HealthFinding[] = [];
|
||||
const id = typeof source.id === "string" ? source.id.trim() : "";
|
||||
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/u.test(id)) {
|
||||
findings.push(
|
||||
invalidConfigFinding({
|
||||
propertyPath: `${sourcePath}.id`,
|
||||
target: `${sourceOcPath}/id`,
|
||||
message: `Feed source ${index} must have a stable lowercase id.`,
|
||||
fixHint: "Use a lowercase id such as company-approved or clawhub-public.",
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const previous = seenIds.get(id);
|
||||
if (previous !== undefined) {
|
||||
findings.push({
|
||||
checkId: CHECK_IDS.sourceDuplicateId,
|
||||
severity: "error",
|
||||
message: `Feed source id '${id}' duplicates sources[${previous}].`,
|
||||
source: "feeds",
|
||||
path: `${sourcePath}.id`,
|
||||
ocPath: `${sourceOcPath}/id`,
|
||||
fixHint: "Give each feed source a unique id.",
|
||||
});
|
||||
} else {
|
||||
seenIds.set(id, index);
|
||||
}
|
||||
}
|
||||
|
||||
const url = typeof source.url === "string" ? source.url.trim() : "";
|
||||
if (!isSupportedFeedUrl(url)) {
|
||||
findings.push({
|
||||
checkId: CHECK_IDS.sourceUrlInvalid,
|
||||
severity: "error",
|
||||
message: `Feed source ${id || index} must use an absolute https:// or file:// URL.`,
|
||||
source: "feeds",
|
||||
path: `${sourcePath}.url`,
|
||||
ocPath: `${sourceOcPath}/url`,
|
||||
fixHint: "Use an absolute https:// URL for hosted feeds or file:// URL for local feeds.",
|
||||
});
|
||||
}
|
||||
|
||||
const trust = source.trust;
|
||||
if (trust !== undefined && trust !== "unsigned" && trust !== "pinned") {
|
||||
findings.push(
|
||||
invalidConfigFinding({
|
||||
propertyPath: `${sourcePath}.trust`,
|
||||
target: `${sourceOcPath}/trust`,
|
||||
message: `Feed source ${id || index} has unsupported trust value '${formatUnknown(trust)}'.`,
|
||||
fixHint: 'Use trust "unsigned" or "pinned".',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const integrity = source.integrity;
|
||||
if (integrity !== undefined && !isSha256Integrity(integrity)) {
|
||||
findings.push({
|
||||
checkId: CHECK_IDS.sourceIntegrityInvalid,
|
||||
severity: "error",
|
||||
message: `Feed source ${id || index} has an invalid integrity hash.`,
|
||||
source: "feeds",
|
||||
path: `${sourcePath}.integrity`,
|
||||
ocPath: `${sourceOcPath}/integrity`,
|
||||
fixHint: "Use sha256:<64 lowercase or uppercase hexadecimal characters>.",
|
||||
});
|
||||
}
|
||||
if (trust === "pinned" && integrity === undefined) {
|
||||
findings.push({
|
||||
checkId: CHECK_IDS.sourceIntegrityMissing,
|
||||
severity: "error",
|
||||
message: `Pinned feed source ${id || index} must declare an integrity hash.`,
|
||||
source: "feeds",
|
||||
path: `${sourcePath}.integrity`,
|
||||
ocPath: `${sourceOcPath}/integrity`,
|
||||
fixHint: 'Add integrity: "sha256:<hex>" or change trust to "unsigned".',
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
function invalidConfigFinding(params: {
|
||||
readonly propertyPath: string;
|
||||
readonly target: string;
|
||||
readonly message: string;
|
||||
readonly fixHint: string;
|
||||
}): HealthFinding {
|
||||
return {
|
||||
checkId: CHECK_IDS.configInvalid,
|
||||
severity: "error",
|
||||
message: params.message,
|
||||
source: "feeds",
|
||||
path: params.propertyPath,
|
||||
ocPath: params.target,
|
||||
fixHint: params.fixHint,
|
||||
};
|
||||
}
|
||||
|
||||
function isSupportedFeedUrl(value: string): boolean {
|
||||
if (value === "") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === "https:" || parsed.protocol === "file:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isSha256Integrity(value: unknown): boolean {
|
||||
return typeof value === "string" && /^sha256:[a-f0-9]{64}$/iu.test(value);
|
||||
}
|
||||
|
||||
function formatUnknown(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return "<unprintable>";
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
export type FeedSourceConfig = {
|
||||
readonly id: string;
|
||||
readonly url: string;
|
||||
readonly enabled: boolean;
|
||||
readonly trust?: "unsigned" | "pinned";
|
||||
readonly integrity?: string;
|
||||
};
|
||||
|
||||
export type FeedEntryType = "skill" | "plugin";
|
||||
|
||||
export type FeedEntry = {
|
||||
readonly type: FeedEntryType;
|
||||
readonly id: string;
|
||||
readonly version?: string;
|
||||
readonly name?: string;
|
||||
readonly description?: string;
|
||||
readonly tags?: readonly string[];
|
||||
readonly sourceUrl?: string;
|
||||
readonly sha256?: string;
|
||||
readonly install?: Record<string, unknown>;
|
||||
readonly approval?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type FeedDocument = {
|
||||
readonly schemaVersion: 1;
|
||||
readonly id: string;
|
||||
readonly generatedAt?: string;
|
||||
readonly entries: readonly FeedEntry[];
|
||||
};
|
||||
|
||||
export type LoadedFeedDocument = {
|
||||
readonly source: FeedSourceConfig;
|
||||
readonly document: FeedDocument;
|
||||
readonly sha256: string;
|
||||
};
|
||||
|
||||
export type FeedFetch = (url: string) => Promise<{ readonly ok: boolean; readonly text: string }>;
|
||||
|
||||
export type FeedDocumentRuntime = {
|
||||
readonly fetch?: FeedFetch;
|
||||
readonly readFile?: (path: string) => Promise<Buffer | string>;
|
||||
};
|
||||
|
||||
export async function loadFeedDocument(
|
||||
source: FeedSourceConfig,
|
||||
runtime: FeedDocumentRuntime = {},
|
||||
): Promise<LoadedFeedDocument> {
|
||||
const raw = await readFeedBytes(source.url, runtime);
|
||||
const sha256 = createHash("sha256").update(raw).digest("hex");
|
||||
if (source.trust === "pinned" && source.integrity === undefined) {
|
||||
throw new Error(`Feed source ${source.id} requires integrity for pinned trust.`);
|
||||
}
|
||||
if (source.integrity !== undefined && source.integrity.toLowerCase() !== `sha256:${sha256}`) {
|
||||
throw new Error(`Feed source ${source.id} integrity mismatch.`);
|
||||
}
|
||||
const parsed = parseFeedDocument(JSON.parse(raw.toString("utf8")), source.id);
|
||||
return { source, document: parsed, sha256 };
|
||||
}
|
||||
|
||||
export function parseFeedDocument(value: unknown, sourceId = "feed"): FeedDocument {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error(`Feed source ${sourceId} must contain a JSON object.`);
|
||||
}
|
||||
if (value.schemaVersion !== 1) {
|
||||
throw new Error(`Feed source ${sourceId} must use schemaVersion 1.`);
|
||||
}
|
||||
if (typeof value.id !== "string" || value.id.trim() === "") {
|
||||
throw new Error(`Feed source ${sourceId} must declare a feed id.`);
|
||||
}
|
||||
if (value.generatedAt !== undefined && typeof value.generatedAt !== "string") {
|
||||
throw new Error(`Feed source ${sourceId} generatedAt must be a string when present.`);
|
||||
}
|
||||
if (!Array.isArray(value.entries)) {
|
||||
throw new Error(`Feed source ${sourceId} entries must be an array.`);
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: value.id,
|
||||
...(typeof value.generatedAt === "string" ? { generatedAt: value.generatedAt } : {}),
|
||||
entries: value.entries.map((entry, index) => parseFeedEntry(entry, sourceId, index)),
|
||||
};
|
||||
}
|
||||
|
||||
export function feedEntryMatchesQuery(entry: FeedEntry, query: string): boolean {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (normalized === "") {
|
||||
return true;
|
||||
}
|
||||
const haystack = [
|
||||
entry.type,
|
||||
entry.id,
|
||||
entry.version,
|
||||
entry.name,
|
||||
entry.description,
|
||||
...(entry.tags ?? []),
|
||||
]
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
.join("\n")
|
||||
.toLowerCase();
|
||||
return haystack.includes(normalized);
|
||||
}
|
||||
|
||||
async function readFeedBytes(url: string, runtime: FeedDocumentRuntime): Promise<Buffer> {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol === "file:") {
|
||||
const read = runtime.readFile ?? readFile;
|
||||
const value = await read(fileURLToPath(parsed));
|
||||
return Buffer.isBuffer(value) ? value : Buffer.from(value);
|
||||
}
|
||||
if (parsed.protocol === "https:") {
|
||||
const fetcher = runtime.fetch ?? defaultFetch;
|
||||
const response = await fetcher(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Feed URL ${url} did not return a successful response.`);
|
||||
}
|
||||
return Buffer.from(response.text, "utf8");
|
||||
}
|
||||
throw new Error(`Unsupported feed URL protocol for ${url}.`);
|
||||
}
|
||||
|
||||
async function defaultFetch(url: string): Promise<{ readonly ok: boolean; readonly text: string }> {
|
||||
const response = await fetch(url);
|
||||
return { ok: response.ok, text: await response.text() };
|
||||
}
|
||||
|
||||
function parseFeedEntry(value: unknown, sourceId: string, index: number): FeedEntry {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error(`Feed source ${sourceId} entry ${index} must be an object.`);
|
||||
}
|
||||
if (value.type !== "skill" && value.type !== "plugin") {
|
||||
throw new Error(`Feed source ${sourceId} entry ${index} must be a skill or plugin.`);
|
||||
}
|
||||
if (typeof value.id !== "string" || value.id.trim() === "") {
|
||||
throw new Error(`Feed source ${sourceId} entry ${index} must declare an id.`);
|
||||
}
|
||||
return {
|
||||
type: value.type,
|
||||
id: value.id,
|
||||
...(typeof value.version === "string" ? { version: value.version } : {}),
|
||||
...(typeof value.name === "string" ? { name: value.name } : {}),
|
||||
...(typeof value.description === "string" ? { description: value.description } : {}),
|
||||
...(Array.isArray(value.tags) && value.tags.every((tag) => typeof tag === "string")
|
||||
? { tags: value.tags }
|
||||
: {}),
|
||||
...(typeof value.sourceUrl === "string" ? { sourceUrl: value.sourceUrl } : {}),
|
||||
...(typeof value.sha256 === "string" ? { sha256: value.sha256 } : {}),
|
||||
...(isRecord(value.install) ? { install: value.install } : {}),
|
||||
...(isRecord(value.approval) ? { approval: value.approval } : {}),
|
||||
};
|
||||
}
|
||||
22
extensions/feishu/npm-shrinkwrap.json
generated
22
extensions/feishu/npm-shrinkwrap.json
generated
@@ -183,16 +183,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "2.5.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.4.tgz",
|
||||
"integrity": "sha512-Y/3MmRiR8Nd+0CUtrbvcKtKzLWiUfpQ7DFVggH8PwmGt/0r7RSy32GuP4hpCJlQNEBusisSx1DLtD8uD386HJQ==",
|
||||
"deprecated": "This version has an incorrect dependency; please use v2.5.5",
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.6.tgz",
|
||||
"integrity": "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"has-own": "^1.0.1",
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35",
|
||||
"safe-buffer": "^5.2.1"
|
||||
},
|
||||
@@ -258,13 +257,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-own": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/has-own/-/has-own-1.0.1.tgz",
|
||||
"integrity": "sha512-RDKhzgQTQfMaLvIFhjahU+2gGnRBK6dYOd5Gd9BzkmnBneOCRYjRC003RIMrdAbH52+l+CnMS4bBCXGer8tEhg==",
|
||||
"deprecated": "This project is not maintained. Use Object.hasOwn() instead.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
@@ -371,9 +363,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.4.0.tgz",
|
||||
"integrity": "sha512-iriNhQ57SYA5Jbdi+41AyPdx6jPPkFO7DODzkOBmqFhgYn/JzX2HxgxYPY18eQAs3CP/AWqtPvkWn8rclRAxdQ==",
|
||||
"version": "8.4.1",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.4.1.tgz",
|
||||
"integrity": "sha512-oXf2UgIty8jnwfN4yvL1x79VLhL5uiKjZJbSGXGCIUmHmItTP4eS/UIlWDCeNx3seg+ujfn9vDlPMSrsh7wO+Q==",
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
|
||||
@@ -135,6 +135,7 @@ describe("googlechat message actions", () => {
|
||||
});
|
||||
sendGoogleChatMessage.mockResolvedValue({
|
||||
messageName: "spaces/AAA/messages/msg-1",
|
||||
threadName: "spaces/AAA/threads/thread-1",
|
||||
});
|
||||
|
||||
if (!googlechatMessageActions.handleAction) {
|
||||
@@ -174,7 +175,12 @@ describe("googlechat message actions", () => {
|
||||
thread: "thread-1",
|
||||
attachments: [{ attachmentUploadToken: "token-1", contentName: "remote.png" }],
|
||||
});
|
||||
expectJsonResult(result, { ok: true, to: "spaces/AAA" });
|
||||
expectJsonResult(result, {
|
||||
ok: true,
|
||||
to: "spaces/AAA",
|
||||
messageName: "spaces/AAA/messages/msg-1",
|
||||
threadName: "spaces/AAA/threads/thread-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("routes upload-file through the same attachment upload path with filename override", async () => {
|
||||
@@ -198,6 +204,7 @@ describe("googlechat message actions", () => {
|
||||
});
|
||||
sendGoogleChatMessage.mockResolvedValue({
|
||||
messageName: "spaces/BBB/messages/msg-2",
|
||||
threadName: "spaces/BBB/threads/thread-2",
|
||||
});
|
||||
|
||||
if (!googlechatMessageActions.handleAction) {
|
||||
@@ -232,7 +239,12 @@ describe("googlechat message actions", () => {
|
||||
thread: undefined,
|
||||
attachments: [{ attachmentUploadToken: "token-2", contentName: "renamed.txt" }],
|
||||
});
|
||||
expectJsonResult(result, { ok: true, to: "spaces/BBB" });
|
||||
expectJsonResult(result, {
|
||||
ok: true,
|
||||
to: "spaces/BBB",
|
||||
messageName: "spaces/BBB/messages/msg-2",
|
||||
threadName: "spaces/BBB/threads/thread-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes only matching app reactions on react remove", async () => {
|
||||
|
||||
@@ -148,7 +148,7 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = {
|
||||
buffer: loaded.buffer,
|
||||
contentType: loaded.contentType,
|
||||
});
|
||||
await sendGoogleChatMessage({
|
||||
const sent = await sendGoogleChatMessage({
|
||||
account,
|
||||
space,
|
||||
text: content,
|
||||
@@ -162,20 +162,20 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = {
|
||||
]
|
||||
: undefined,
|
||||
});
|
||||
return jsonResult({ ok: true, to: space });
|
||||
return jsonResult({ ok: true, to: space, ...sent });
|
||||
}
|
||||
|
||||
if (action === "upload-file") {
|
||||
throw new Error("upload-file requires media, filePath, or path");
|
||||
}
|
||||
|
||||
await sendGoogleChatMessage({
|
||||
const sent = await sendGoogleChatMessage({
|
||||
account,
|
||||
space,
|
||||
text: content,
|
||||
thread: threadId ?? undefined,
|
||||
});
|
||||
return jsonResult({ ok: true, to: space });
|
||||
return jsonResult({ ok: true, to: space, ...sent });
|
||||
}
|
||||
|
||||
if (action === "react") {
|
||||
|
||||
@@ -142,7 +142,7 @@ export async function sendGoogleChatMessage(params: {
|
||||
thread?: string;
|
||||
cardsV2?: GoogleChatCardV2[];
|
||||
attachments?: Array<{ attachmentUploadToken: string; contentName?: string }>;
|
||||
}): Promise<{ messageName?: string } | null> {
|
||||
}): Promise<{ messageName?: string; threadName?: string } | null> {
|
||||
const { account, space, text, thread, cardsV2, attachments } = params;
|
||||
if (
|
||||
text &&
|
||||
@@ -175,11 +175,11 @@ export async function sendGoogleChatMessage(params: {
|
||||
urlObj.searchParams.set("messageReplyOption", "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD");
|
||||
}
|
||||
const url = urlObj.toString();
|
||||
const result = await fetchJson<{ name?: string }>(account, url, {
|
||||
const result = await fetchJson<{ name?: string; thread?: { name?: string } }>(account, url, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return result ? { messageName: result.name } : null;
|
||||
return result ? { messageName: result.name, threadName: result.thread?.name } : null;
|
||||
}
|
||||
|
||||
export async function updateGoogleChatMessage(params: {
|
||||
|
||||
@@ -99,10 +99,15 @@ const account = {
|
||||
config: {},
|
||||
} as ResolvedGoogleChatAccount;
|
||||
|
||||
function stubSuccessfulSend(name: string) {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response(JSON.stringify({ name }), { status: 200 }));
|
||||
function stubSuccessfulSend(name: string, threadName?: string) {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ name, ...(threadName ? { thread: { name: threadName } } : {}) }),
|
||||
{
|
||||
status: 200,
|
||||
},
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
@@ -239,9 +244,9 @@ describe("sendGoogleChatMessage", () => {
|
||||
});
|
||||
|
||||
it("adds messageReplyOption when sending to an existing thread", async () => {
|
||||
const fetchMock = stubSuccessfulSend("spaces/AAA/messages/123");
|
||||
const fetchMock = stubSuccessfulSend("spaces/AAA/messages/123", "spaces/AAA/threads/xyz");
|
||||
|
||||
await sendGoogleChatMessage({
|
||||
const result = await sendGoogleChatMessage({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
text: "hello",
|
||||
@@ -260,6 +265,10 @@ describe("sendGoogleChatMessage", () => {
|
||||
};
|
||||
expect(body.text).toBe("hello");
|
||||
expect(body.thread?.name).toBe("spaces/AAA/threads/xyz");
|
||||
expect(result).toEqual({
|
||||
messageName: "spaces/AAA/messages/123",
|
||||
threadName: "spaces/AAA/threads/xyz",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not set messageReplyOption for non-thread sends", async () => {
|
||||
|
||||
@@ -392,10 +392,22 @@ export const imessageMessageActions: ChannelMessageActionAdapter = {
|
||||
react: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
|
||||
edit: { aliases: ["chatGuid", "chatIdentifier", "chatId", "messageId"] },
|
||||
unsend: { aliases: ["chatGuid", "chatIdentifier", "chatId", "messageId"] },
|
||||
reply: { aliases: ["chatGuid", "chatIdentifier", "chatId", "messageId"] },
|
||||
sendWithEffect: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
|
||||
sendAttachment: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
|
||||
"upload-file": { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
|
||||
reply: {
|
||||
aliases: ["chatGuid", "chatIdentifier", "chatId", "messageId"],
|
||||
deliveryTargetAliases: ["chatGuid", "chatIdentifier", "chatId"],
|
||||
},
|
||||
sendWithEffect: {
|
||||
aliases: ["chatGuid", "chatIdentifier", "chatId"],
|
||||
deliveryTargetAliases: ["chatGuid", "chatIdentifier", "chatId"],
|
||||
},
|
||||
sendAttachment: {
|
||||
aliases: ["chatGuid", "chatIdentifier", "chatId"],
|
||||
deliveryTargetAliases: ["chatGuid", "chatIdentifier", "chatId"],
|
||||
},
|
||||
"upload-file": {
|
||||
aliases: ["chatGuid", "chatIdentifier", "chatId"],
|
||||
deliveryTargetAliases: ["chatGuid", "chatIdentifier", "chatId"],
|
||||
},
|
||||
renameGroup: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
|
||||
setGroupIcon: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
|
||||
addParticipant: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
|
||||
|
||||
@@ -273,4 +273,39 @@ describe("memory reindex state", () => {
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to fts-only when provider.model is an empty string", () => {
|
||||
expect(
|
||||
resolveMemoryIndexIdentityState(
|
||||
createIdentityParams({
|
||||
provider: { id: "openai", model: "" },
|
||||
meta: createMeta({ model: "fts-only" }),
|
||||
}),
|
||||
),
|
||||
).toEqual({ status: "valid" });
|
||||
});
|
||||
|
||||
it("reports mismatch when empty-string expected model is compared to a non-fts index", () => {
|
||||
const state = resolveMemoryIndexIdentityState(
|
||||
createIdentityParams({
|
||||
provider: { id: "openai", model: "" },
|
||||
meta: createMeta({ model: "text-embedding-3-small" }),
|
||||
}),
|
||||
);
|
||||
expect(state.status).toBe("mismatched");
|
||||
if (state.status === "mismatched") {
|
||||
expect(state.reason).toContain("expected fts-only");
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to fts-only when provider.model is whitespace-only", () => {
|
||||
expect(
|
||||
resolveMemoryIndexIdentityState(
|
||||
createIdentityParams({
|
||||
provider: { id: "openai", model: " " },
|
||||
meta: createMeta({ model: "fts-only" }),
|
||||
}),
|
||||
),
|
||||
).toEqual({ status: "valid" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,7 +159,7 @@ export function resolveMemoryIndexIdentityState(params: {
|
||||
if (!meta) {
|
||||
return { status: "missing", reason: "index metadata is missing" };
|
||||
}
|
||||
const expectedModel = params.provider ? params.provider.model : "fts-only";
|
||||
const expectedModel = params.provider?.model?.trim() || "fts-only";
|
||||
const matchingModelIdentities = [
|
||||
{ model: expectedModel, providerKey: params.providerKey },
|
||||
...(params.providerAliases ?? []),
|
||||
|
||||
@@ -517,7 +517,7 @@ export abstract class MemoryManagerSyncOps {
|
||||
this.settings.provider,
|
||||
model:
|
||||
this.settings.model.trim() ||
|
||||
resolveEmbeddingProviderFallbackModel(this.settings.provider, "", this.cfg),
|
||||
resolveEmbeddingProviderFallbackModel(this.settings.provider, "fts-only", this.cfg),
|
||||
});
|
||||
const provider = hasProviderOverride
|
||||
? params.provider!
|
||||
|
||||
@@ -285,6 +285,10 @@ describe("QmdMemoryManager", () => {
|
||||
throw new Error(`expected missing path ${targetPath}`);
|
||||
}
|
||||
|
||||
function qmdIndexConfigPath(selectedAgentId = agentId): string {
|
||||
return path.join(stateDir, "agents", selectedAgentId, "qmd", "xdg-config", "qmd", "index.yml");
|
||||
}
|
||||
|
||||
async function createManager(params?: {
|
||||
mode?: "full" | "status" | "cli";
|
||||
cfg?: OpenClawConfig;
|
||||
@@ -1966,6 +1970,121 @@ describe("QmdMemoryManager", () => {
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
it("refreshes qmd index config with quoted collection values during update repair", async () => {
|
||||
const notesDir = path.join(workspaceDir, "Notes #1: blue");
|
||||
await fs.mkdir(notesDir, { recursive: true });
|
||||
cfg = {
|
||||
...cfg,
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: false,
|
||||
update: { interval: "0s", debounceMs: 0, onBoot: false },
|
||||
paths: [{ path: notesDir, pattern: "**/* #tag: [draft].md", name: "notes" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
let updateCalls = 0;
|
||||
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
||||
if (args[0] === "update") {
|
||||
updateCalls += 1;
|
||||
const child = createMockChild({ autoClose: false });
|
||||
if (updateCalls === 1) {
|
||||
emitAndClose(
|
||||
child,
|
||||
"stderr",
|
||||
"SQLiteError: UNIQUE constraint failed: documents.collection, documents.path",
|
||||
1,
|
||||
);
|
||||
return child;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
child.closeWith(0);
|
||||
});
|
||||
return child;
|
||||
}
|
||||
return createMockChild();
|
||||
});
|
||||
|
||||
const { manager } = await createManager({ mode: "status" });
|
||||
await expect(manager.sync({ reason: "manual" })).resolves.toBeUndefined();
|
||||
|
||||
const indexConfig = await fs.readFile(qmdIndexConfigPath(), "utf8");
|
||||
expect(indexConfig).toContain(' "notes-main":');
|
||||
expect(indexConfig).toContain(` path: ${JSON.stringify(notesDir)}`);
|
||||
expect(indexConfig).toContain(' pattern: "**/* #tag: [draft].md"');
|
||||
expect(updateCalls).toBe(2);
|
||||
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
it("forces repair remove/add even when managed collections are still listed", async () => {
|
||||
cfg = {
|
||||
...cfg,
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: true,
|
||||
update: { interval: "0s", debounceMs: 0, onBoot: false },
|
||||
paths: [],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
let updateCalls = 0;
|
||||
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
||||
if (args[0] === "collection" && args[1] === "list") {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
emitAndClose(
|
||||
child,
|
||||
"stdout",
|
||||
JSON.stringify([
|
||||
{ name: "memory-root-main", path: workspaceDir, mask: "MEMORY.md" },
|
||||
{ name: "memory-dir-main", path: path.join(workspaceDir, "memory"), mask: "**/*.md" },
|
||||
]),
|
||||
);
|
||||
return child;
|
||||
}
|
||||
if (args[0] === "update") {
|
||||
updateCalls += 1;
|
||||
const child = createMockChild({ autoClose: false });
|
||||
if (updateCalls === 1) {
|
||||
emitAndClose(
|
||||
child,
|
||||
"stderr",
|
||||
"SQLiteError: UNIQUE constraint failed: documents.collection, documents.path",
|
||||
1,
|
||||
);
|
||||
return child;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
child.closeWith(0);
|
||||
});
|
||||
return child;
|
||||
}
|
||||
return createMockChild();
|
||||
});
|
||||
|
||||
const { manager } = await createManager({ mode: "full" });
|
||||
await expect(manager.sync({ reason: "manual" })).resolves.toBeUndefined();
|
||||
|
||||
const removeCalls = spawnMock.mock.calls
|
||||
.map((call: unknown[]) => call[1] as string[])
|
||||
.filter((args: string[]) => args[0] === "collection" && args[1] === "remove")
|
||||
.map((args) => args[2]);
|
||||
const addCalls = spawnMock.mock.calls
|
||||
.map((call: unknown[]) => call[1] as string[])
|
||||
.filter((args: string[]) => args[0] === "collection" && args[1] === "add")
|
||||
.map((args) => args[args.indexOf("--name") + 1]);
|
||||
|
||||
expect(updateCalls).toBe(2);
|
||||
expect(removeCalls).toEqual(["memory-root-main", "memory-dir-main"]);
|
||||
expect(addCalls).toEqual(["memory-root-main", "memory-dir-main"]);
|
||||
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
it("does not rebuild collections for unrelated unique constraint failures", async () => {
|
||||
cfg = {
|
||||
...cfg,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user