Compare commits

..

2 Commits

Author SHA1 Message Date
Josh Lehman
eb2155b5ae docs: clarify typing indicator activity triggers 2026-06-23 07:26:55 -07:00
Josh Lehman
7cf8d07231 fix: bridge harness execution phases to typing 2026-06-22 13:11:45 -07:00
342 changed files with 2772 additions and 9047 deletions

View File

@@ -305,7 +305,6 @@ jobs:
shard_name: shard.shardName,
groups: shard.groups,
configs: shard.configs,
env: shard.env,
includePatterns: shard.includePatterns,
requires_dist: shard.requiresDist,
runner: shard.runner,
@@ -1238,7 +1237,6 @@ jobs:
NODE_OPTIONS: --max-old-space-size=8192
OPENCLAW_NODE_TEST_GROUPS_JSON: ${{ toJson(matrix.groups || null) }}
OPENCLAW_NODE_TEST_CONFIGS_JSON: ${{ toJson(matrix.configs) }}
OPENCLAW_NODE_TEST_ENV_JSON: ${{ toJson(matrix.env) }}
OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }}
OPENCLAW_VITEST_SHARD_NAME: ${{ matrix.shard_name }}
OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "300000"
@@ -1257,7 +1255,6 @@ jobs:
? groups
: [{
configs: JSON.parse(process.env.OPENCLAW_NODE_TEST_CONFIGS_JSON ?? "[]"),
env: JSON.parse(process.env.OPENCLAW_NODE_TEST_ENV_JSON ?? "null"),
includePatterns: JSON.parse(
process.env.OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON ?? "null",
),
@@ -1273,13 +1270,6 @@ jobs:
...process.env,
...(plan.shard_name ? { OPENCLAW_VITEST_SHARD_NAME: plan.shard_name } : {}),
};
if (plan.env && typeof plan.env === "object" && !Array.isArray(plan.env)) {
for (const [key, value] of Object.entries(plan.env)) {
if (typeof value === "string") {
childEnv[key] = value;
}
}
}
if (Array.isArray(plan.includePatterns) && plan.includePatterns.length > 0) {
const includeFile = join(
process.env.RUNNER_TEMP ?? ".",

View File

@@ -1,376 +0,0 @@
name: QA Profile Evidence
run-name: ${{ format('QA Profile Evidence {0} {1}', inputs.qa_profile, inputs.ref) }}
on:
workflow_dispatch:
inputs:
ref:
description: OpenClaw branch, tag, or SHA to run
required: true
default: main
type: string
expected_sha:
description: Optional full SHA that ref must resolve to
required: false
default: ""
type: string
qa_profile:
description: Taxonomy QA profile id to run
required: true
default: release
type: string
workflow_call:
inputs:
ref:
description: OpenClaw branch, tag, or SHA to run
required: true
type: string
expected_sha:
description: Optional full SHA that ref must resolve to
required: false
default: ""
type: string
qa_profile:
description: Taxonomy QA profile id to run
required: true
type: string
fail_on_qa_failure:
description: Fail the reusable workflow when the QA profile command exits non-zero
required: false
default: false
type: boolean
outputs:
artifact_name:
description: Uploaded QA profile evidence artifact name
value: ${{ jobs.run_qa_profile.outputs.artifact_name }}
qa_profile:
description: Taxonomy QA profile id that produced the evidence
value: ${{ jobs.run_qa_profile.outputs.qa_profile }}
qa_exit_code:
description: Exit code from the QA profile run; non-zero evidence is still uploaded
value: ${{ jobs.run_qa_profile.outputs.qa_exit_code }}
qa_passed:
description: Whether the QA profile command exited successfully
value: ${{ jobs.run_qa_profile.outputs.qa_passed }}
target_sha:
description: Resolved OpenClaw SHA that produced the evidence
value: ${{ jobs.run_qa_profile.outputs.target_sha }}
trusted_reason:
description: Trust reason accepted before the secret-bearing QA job
value: ${{ jobs.run_qa_profile.outputs.trusted_reason }}
qa_evidence_path:
description: Path to qa-evidence.json inside the uploaded artifact
value: ${{ jobs.run_qa_profile.outputs.qa_evidence_path }}
permissions:
contents: read
concurrency:
group: qa-profile-evidence-${{ inputs.qa_profile }}-${{ inputs.expected_sha || inputs.ref }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.x"
OPENCLAW_BUILD_PRIVATE_QA: "1"
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
jobs:
authorize_actor:
name: Authorize workflow actor
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
authorized: ${{ steps.permission.outputs.authorized }}
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
if (context.eventName !== "workflow_dispatch") {
core.info(`Skipping manual actor permission check for ${context.eventName}.`);
core.setOutput("authorized", "true");
return;
}
const allowed = new Set(["admin", "maintain", "write"]);
const { owner, repo } = context.repo;
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username: context.actor,
});
const permission = data.permission;
core.info(`Actor ${context.actor} permission: ${permission}`);
if (!allowed.has(permission)) {
core.notice(
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
);
core.setOutput("authorized", "false");
return;
}
core.setOutput("authorized", "true");
validate_selected_ref:
name: Validate selected ref
needs: authorize_actor
if: needs.authorize_actor.outputs.authorized == 'true'
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
selected_revision: ${{ steps.validate.outputs.selected_revision }}
trusted_reason: ${{ steps.validate.outputs.trusted_reason }}
steps:
- name: Checkout selected ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
ref: ${{ inputs.ref }}
fetch-depth: 0
- name: Validate selected ref
id: validate
env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
INPUT_REF: ${{ inputs.ref }}
shell: bash
run: |
set -euo pipefail
selected_revision="$(git rev-parse HEAD)"
expected_sha="${EXPECTED_SHA,,}"
trusted_reason=""
if [[ -n "${expected_sha// }" && ! "$expected_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "expected_sha must be a full 40-character SHA; got: ${EXPECTED_SHA}" >&2
exit 1
fi
if [[ -n "${expected_sha// }" && "${selected_revision,,}" != "$expected_sha" ]]; then
echo "Ref '${INPUT_REF}' resolved to ${selected_revision}, expected ${EXPECTED_SHA}." >&2
exit 1
fi
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
if git merge-base --is-ancestor "$selected_revision" refs/remotes/origin/main; then
trusted_reason="main-ancestor"
elif git tag --points-at "$selected_revision" | grep -Eq '^v'; then
trusted_reason="release-tag"
elif [[ "$INPUT_REF" =~ ^release/[0-9]{4}\.[0-9]+\.[0-9]+$ ]]; then
git fetch --no-tags origin "+refs/heads/${INPUT_REF}:refs/remotes/origin/${INPUT_REF}"
release_branch_sha="$(git rev-parse "refs/remotes/origin/${INPUT_REF}")"
if [[ "$selected_revision" == "$release_branch_sha" ]]; then
trusted_reason="release-branch-head"
fi
fi
if [[ -z "$trusted_reason" ]]; then
echo "Ref '${INPUT_REF}' resolved to $selected_revision, which is not trusted for this secret-bearing QA evidence run." >&2
echo "Allowed refs must be on main, point to a release tag, or match a release branch head." >&2
exit 1
fi
echo "selected_revision=$selected_revision" >> "$GITHUB_OUTPUT"
echo "trusted_reason=$trusted_reason" >> "$GITHUB_OUTPUT"
{
echo "### Target"
echo
echo "- Requested ref: \`${INPUT_REF}\`"
echo "- Resolved SHA: \`$selected_revision\`"
echo "- Trust reason: \`$trusted_reason\`"
} >> "$GITHUB_STEP_SUMMARY"
run_qa_profile:
name: Generate QA profile evidence
needs: validate_selected_ref
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
permissions:
contents: read
outputs:
artifact_name: ${{ steps.evidence.outputs.artifact_name }}
qa_profile: ${{ steps.profile.outputs.profile }}
qa_exit_code: ${{ steps.evidence.outputs.qa_exit_code }}
qa_passed: ${{ steps.evidence.outputs.qa_passed }}
target_sha: ${{ steps.evidence.outputs.target_sha }}
trusted_reason: ${{ steps.evidence.outputs.trusted_reason }}
qa_evidence_path: ${{ steps.evidence.outputs.qa_evidence_path }}
environment: qa-live-shared
steps:
- name: Checkout selected ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "true"
- name: Validate QA profile input
id: profile
env:
QA_PROFILE: ${{ inputs.qa_profile }}
shell: bash
run: |
set -euo pipefail
node --import tsx --input-type=module <<'NODE'
import fs from "node:fs";
import { readQaScorecardTaxonomyReport } from "./extensions/qa-lab/src/scorecard-taxonomy.ts";
const requested = process.env.QA_PROFILE?.trim() ?? "";
if (!/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/.test(requested)) {
throw new Error(`qa_profile must use a taxonomy profile id, got ${JSON.stringify(process.env.QA_PROFILE)}`);
}
const taxonomy = readQaScorecardTaxonomyReport([]);
const profile = taxonomy.profiles.find((entry) => entry.id === requested);
if (!profile) {
const available = taxonomy.profiles.map((entry) => entry.id).join(", ");
throw new Error(`Unknown QA profile ${requested}. Available profiles: ${available}`);
}
fs.appendFileSync(process.env.GITHUB_OUTPUT, `profile=${profile.id}\n`);
NODE
echo "QA profile: \`${QA_PROFILE}\`" >> "$GITHUB_STEP_SUMMARY"
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: node scripts/build-all.mjs qaRuntime
- name: Run QA profile
id: run_profile
env:
QA_PROFILE: ${{ steps.profile.outputs.profile }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
shell: bash
run: |
set -euo pipefail
output_dir=".artifacts/qa-e2e/profile-${QA_PROFILE}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT"
qa_exit_code=0
pnpm openclaw qa run \
--repo-root . \
--qa-profile "${QA_PROFILE}" \
--output-dir "${output_dir}" || qa_exit_code=$?
echo "qa_exit_code=${qa_exit_code}" >> "$GITHUB_OUTPUT"
- name: Validate QA profile evidence
id: evidence
if: always()
env:
ARTIFACT_NAME: qa-profile-evidence-${{ steps.profile.outputs.profile }}-${{ needs.validate_selected_ref.outputs.selected_revision }}
OUTPUT_DIR: ${{ steps.run_profile.outputs.output_dir }}
QA_EXIT_CODE: ${{ steps.run_profile.outputs.qa_exit_code }}
QA_PROFILE: ${{ steps.profile.outputs.profile }}
REQUESTED_REF: ${{ inputs.ref }}
TARGET_SHA: ${{ needs.validate_selected_ref.outputs.selected_revision }}
TRUSTED_REASON: ${{ needs.validate_selected_ref.outputs.trusted_reason }}
shell: bash
run: |
set -euo pipefail
node --input-type=module <<'NODE'
import fs from "node:fs";
import path from "node:path";
const outputDir = process.env.OUTPUT_DIR;
if (!outputDir) {
throw new Error("OUTPUT_DIR is required");
}
if (!process.env.QA_EXIT_CODE) {
throw new Error("QA_EXIT_CODE is required");
}
const evidencePath = path.join(outputDir, "qa-evidence.json");
const payload = JSON.parse(fs.readFileSync(evidencePath, "utf8"));
if (payload.profile !== process.env.QA_PROFILE) {
throw new Error(`qa-evidence.json profile must be ${process.env.QA_PROFILE}, got ${JSON.stringify(payload.profile)}`);
}
if (!payload.scorecard || !Array.isArray(payload.scorecard.categoryReports)) {
throw new Error("QA profile qa-evidence.json must include scorecard.categoryReports");
}
if (payload.scorecard.categoryReports.length === 0) {
throw new Error("QA profile qa-evidence.json scorecard has no category reports");
}
const manifest = {
artifactName: process.env.ARTIFACT_NAME,
generatedAt: new Date().toISOString(),
qaProfile: process.env.QA_PROFILE,
qaExitCode: Number(process.env.QA_EXIT_CODE),
qaPassed: process.env.QA_EXIT_CODE === "0",
requestedRef: process.env.REQUESTED_REF,
targetSha: process.env.TARGET_SHA,
trustedReason: process.env.TRUSTED_REASON,
evidenceMode: payload.evidenceMode,
qaEvidencePath: "qa-evidence.json",
scorecard: {
categories: payload.scorecard.categories,
features: payload.scorecard.features,
categoryReports: payload.scorecard.categoryReports.length,
},
};
fs.writeFileSync(
path.join(outputDir, "qa-profile-evidence-manifest.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
);
NODE
echo "artifact_name=${ARTIFACT_NAME}" >> "$GITHUB_OUTPUT"
echo "qa_profile=${QA_PROFILE}" >> "$GITHUB_OUTPUT"
echo "qa_exit_code=${QA_EXIT_CODE}" >> "$GITHUB_OUTPUT"
if [[ "$QA_EXIT_CODE" == "0" ]]; then
echo "qa_passed=true" >> "$GITHUB_OUTPUT"
else
echo "qa_passed=false" >> "$GITHUB_OUTPUT"
echo "::warning::QA profile '${QA_PROFILE}' completed with exit code ${QA_EXIT_CODE}; evidence was still validated and uploaded."
fi
echo "target_sha=${TARGET_SHA}" >> "$GITHUB_OUTPUT"
echo "trusted_reason=${TRUSTED_REASON}" >> "$GITHUB_OUTPUT"
echo "qa_evidence_path=qa-evidence.json" >> "$GITHUB_OUTPUT"
{
echo "### QA profile evidence"
echo
echo "- Artifact: \`${ARTIFACT_NAME}\`"
echo "- QA profile: \`${QA_PROFILE}\`"
echo "- QA exit code: \`${QA_EXIT_CODE}\`"
echo "- Target SHA: \`${TARGET_SHA}\`"
echo "- Evidence path: \`${OUTPUT_DIR}/qa-evidence.json\`"
echo "- Manifest: \`${OUTPUT_DIR}/qa-profile-evidence-manifest.json\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload QA profile evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: qa-profile-evidence-${{ steps.profile.outputs.profile }}-${{ needs.validate_selected_ref.outputs.selected_revision }}
path: ${{ steps.run_profile.outputs.output_dir }}
retention-days: 30
if-no-files-found: error
- name: Fail if configured QA gate failed
if: always() && (github.event_name == 'workflow_dispatch' || inputs.fail_on_qa_failure)
env:
QA_EXIT_CODE: ${{ steps.run_profile.outputs.qa_exit_code }}
QA_PROFILE: ${{ steps.profile.outputs.profile }}
shell: bash
run: |
set -euo pipefail
if [[ -z "${QA_EXIT_CODE:-}" ]]; then
echo "QA profile did not report an exit code." >&2
exit 1
fi
if [[ "$QA_EXIT_CODE" != "0" ]]; then
echo "QA profile '${QA_PROFILE}' failed with exit code ${QA_EXIT_CODE}." >&2
exit "$QA_EXIT_CODE"
fi

View File

@@ -24,9 +24,7 @@ jobs:
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
# Old PR events can carry a stale base SHA that predates current
# trusted checker scripts. Use the workflow revision instead.
ref: ${{ github.workflow_sha }}
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
id: app-token

42
.github/workflows/tui-pty.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
name: TUI PTY
on:
pull_request:
paths:
- "src/tui/**"
- "scripts/dev/tui-pty-test-watch.ts"
- "scripts/test-projects.test-support.mjs"
- "package.json"
- "pnpm-lock.yaml"
- "test/scripts/test-projects.test.ts"
- "test/vitest/vitest.test-shards.mjs"
- "test/vitest/vitest.tui-pty.config.ts"
- ".github/workflows/tui-pty.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
tui-pty:
runs-on: ubuntu-24.04
timeout-minutes: 8
env:
OPENCLAW_TUI_PTY_INCLUDE_LOCAL: "1"
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Run TUI PTY tests
run: timeout --kill-after=30s 240s node scripts/run-vitest.mjs run --config test/vitest/vitest.tui-pty.config.ts

View File

@@ -1,16 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<permission
android:name="${applicationId}.permission.RUN_VOICE_E2E"
android:protectionLevel="signature" />
<uses-permission android:name="${applicationId}.permission.RUN_VOICE_E2E" />
<application>
<receiver
android:name=".VoiceE2eReceiver"
android:permission="${applicationId}.permission.RUN_VOICE_E2E"
android:exported="false">
android:exported="true">
<intent-filter>
<action android:name="ai.openclaw.app.debug.RUN_VOICE_E2E" />
</intent-filter>

View File

@@ -1,160 +0,0 @@
package ai.openclaw.app
import ai.openclaw.app.node.asObjectOrNull
import ai.openclaw.app.node.asStringOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
data class GatewayExecApprovalSummary(
val id: String,
val commandText: String,
val commandPreview: String?,
val allowedDecisions: List<String>,
val host: String?,
val nodeId: String?,
val agentId: String?,
val createdAtMs: Long?,
val expiresAtMs: Long?,
val resolvingDecision: String? = null,
val errorText: String? = null,
)
internal fun parseGatewayExecApprovalListPayload(
payloadJson: String,
json: Json,
): List<GatewayExecApprovalSummary> =
try {
(json.parseToJsonElement(payloadJson) as? JsonArray)
?.mapNotNull(::parseGatewayExecApprovalListEntry)
?.sortedBy { it.createdAtMs ?: Long.MAX_VALUE }
.orEmpty()
} catch (_: Throwable) {
emptyList()
}
internal fun parseGatewayExecApprovalListEntry(item: JsonElement): GatewayExecApprovalSummary? {
val obj = item.asObjectOrNull() ?: return null
val id = obj["id"].asStringOrNull()?.trim().orEmpty()
if (id.isEmpty()) return null
val request = obj["request"].asObjectOrNull()
val commandText = gatewayExecApprovalListCommandText(obj, request)
return GatewayExecApprovalSummary(
id = id,
commandText = commandText,
commandPreview = gatewayExecApprovalListCommandPreview(obj, request, commandText),
allowedDecisions = emptyList(),
host =
request
?.get("host")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() },
nodeId =
request
?.get("nodeId")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() },
agentId =
request
?.get("agentId")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() },
createdAtMs = obj.long("createdAtMs"),
expiresAtMs = obj.long("expiresAtMs"),
)
}
internal fun parseGatewayExecApprovalDetail(
obj: JsonObject,
createdAtMs: Long?,
): GatewayExecApprovalSummary? {
val id = obj["id"].asStringOrNull()?.trim().orEmpty()
if (id.isEmpty()) return null
return GatewayExecApprovalSummary(
id = id,
commandText =
obj["commandText"]
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
?: "Command request",
commandPreview =
obj["commandPreview"]
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() },
allowedDecisions = gatewayExecApprovalAllowedDecisions(obj),
host = obj["host"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
nodeId = obj["nodeId"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
agentId = obj["agentId"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
createdAtMs = createdAtMs,
expiresAtMs = obj.long("expiresAtMs"),
)
}
private fun gatewayExecApprovalListCommandText(obj: JsonObject, request: JsonObject?): String =
obj["commandText"]
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
?: request
?.get("command")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
?: "Command request"
private fun gatewayExecApprovalListCommandPreview(
obj: JsonObject,
request: JsonObject?,
commandText: String,
): String? {
val preview =
obj["commandPreview"]
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
?: request
?.get("commandPreview")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
return preview?.takeIf { it != commandText }
}
private fun gatewayExecApprovalAllowedDecisions(request: JsonObject?): List<String> {
val explicit = parseGatewayExecApprovalDecisions(request?.get("allowedDecisions") as? JsonArray)
if (explicit.isNotEmpty()) return explicit
val allowed =
if (request
?.get("ask")
.asStringOrNull()
?.trim()
?.lowercase() == "always"
) {
listOf("allow-once", "deny")
} else {
listOf("allow-once", "allow-always", "deny")
}
val unavailable = parseGatewayExecApprovalDecisions(request?.get("unavailableDecisions") as? JsonArray).toSet()
return allowed.filterNot { it == "allow-always" && it in unavailable }
}
private fun parseGatewayExecApprovalDecisions(items: JsonArray?): List<String> =
items
?.mapNotNull { item ->
when (item.asStringOrNull()?.trim()) {
"allow-once" -> "allow-once"
"allow-always" -> "allow-always"
"deny" -> "deny"
else -> null
}
}?.distinct()
.orEmpty()
private fun JsonObject?.long(key: String): Long? = (this?.get(key) as? JsonPrimitive)?.content?.trim()?.toLongOrNull()

View File

@@ -204,9 +204,6 @@ class MainViewModel(
val chatPendingToolCalls: StateFlow<List<ChatPendingToolCall>> = runtimeState(initial = emptyList()) { it.chatPendingToolCalls }
val chatSessions: StateFlow<List<ChatSessionEntry>> = runtimeState(initial = emptyList()) { it.chatSessions }
val pendingRunCount: StateFlow<Int> = runtimeState(initial = 0) { it.pendingRunCount }
val execApprovals: StateFlow<List<GatewayExecApprovalSummary>> = runtimeState(initial = emptyList()) { it.execApprovals }
val execApprovalsRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.execApprovalsRefreshing }
val execApprovalsErrorText: StateFlow<String?> = runtimeState(initial = null) { it.execApprovalsErrorText }
val canvas: CanvasController
get() = ensureRuntime().canvas
@@ -540,17 +537,6 @@ class MainViewModel(
ensureRuntime().refreshNodesDevices()
}
fun refreshExecApprovals() {
ensureRuntime().refreshExecApprovals()
}
fun resolveExecApproval(
id: String,
decision: String,
) {
ensureRuntime().resolveExecApproval(id = id, decision = decision)
}
fun refreshChannels() {
ensureRuntime().refreshChannels()
}

View File

@@ -74,9 +74,7 @@ import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import java.util.Collections
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
/**
@@ -402,15 +400,6 @@ class NodeRuntime(
private val _nodesDevicesErrorText = MutableStateFlow<String?>(null)
val nodesDevicesErrorText: StateFlow<String?> = _nodesDevicesErrorText.asStateFlow()
private val nodeApprovalRefreshGuard = GatewayNodeApprovalRefreshGuard()
private val _execApprovals = MutableStateFlow<List<GatewayExecApprovalSummary>>(emptyList())
val execApprovals: StateFlow<List<GatewayExecApprovalSummary>> = _execApprovals.asStateFlow()
private val _execApprovalsRefreshing = MutableStateFlow(false)
val execApprovalsRefreshing: StateFlow<Boolean> = _execApprovalsRefreshing.asStateFlow()
private val _execApprovalsErrorText = MutableStateFlow<String?>(null)
val execApprovalsErrorText: StateFlow<String?> = _execApprovalsErrorText.asStateFlow()
private val execApprovalsRefreshSeq = AtomicLong(0)
private val execApprovalsStateLock = Any()
private val resolvedExecApprovalIds = Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())
private val _channelsSummary = MutableStateFlow(GatewayChannelsSummary(channels = emptyList()))
val channelsSummary: StateFlow<GatewayChannelsSummary> = _channelsSummary.asStateFlow()
private val _channelsRefreshing = MutableStateFlow(false)
@@ -460,7 +449,6 @@ class NodeRuntime(
micCapture.onGatewayConnectionChanged(true)
scope.launch {
subscribeOperatorSessionEvents()
refreshExecApprovalsFromGateway()
refreshHomeCanvasOverviewIfConnected()
if (voiceReplySpeakerLazy.isInitialized()) {
voiceReplySpeaker.refreshConfig()
@@ -490,11 +478,6 @@ class NodeRuntime(
pendingDevices = emptyList(),
pairedDevices = emptyList(),
)
invalidateExecApprovalRefreshes()
resolvedExecApprovalIds.clear()
_execApprovals.value = emptyList()
_execApprovalsRefreshing.value = false
_execApprovalsErrorText.value = null
_channelsSummary.value = GatewayChannelsSummary(channels = emptyList())
_dreamingSummary.value = GatewayDreamingSummary()
_healthLogsSummary.value = GatewayHealthLogsSummary()
@@ -842,24 +825,6 @@ class NodeRuntime(
}
}
fun refreshExecApprovals() {
scope.launch {
refreshExecApprovalsFromGateway()
}
}
fun resolveExecApproval(
id: String,
decision: String,
) {
val normalizedId = id.trim()
val normalizedDecision = decision.trim()
if (normalizedId.isEmpty() || normalizedDecision.isEmpty()) return
scope.launch {
resolveExecApprovalOnGateway(id = normalizedId, decision = normalizedDecision)
}
}
fun refreshChannels() {
scope.launch {
refreshChannelsFromGateway()
@@ -1035,9 +1000,6 @@ class NodeRuntime(
_isForeground.value = value
if (value) {
reconnectPreferredGatewayOnForeground()
scope.launch {
refreshExecApprovalsFromGateway()
}
} else {
stopManualVoiceSession()
publishNodePresenceAliveBeacon(NodePresenceAliveBeacon.Trigger.Background, throttleRecentSuccess = true)
@@ -1867,47 +1829,11 @@ class NodeRuntime(
if (event == "update.available") {
_gatewayUpdateAvailable.value = parseGatewayUpdateAvailable(payloadJson)
}
handleExecApprovalGatewayEvent(event = event, payloadJson = payloadJson)
micCapture.handleGatewayEvent(event, payloadJson)
talkMode.handleGatewayEvent(event, payloadJson)
chat.handleGatewayEvent(event, payloadJson)
}
private fun handleExecApprovalGatewayEvent(
event: String,
payloadJson: String?,
) {
when (event) {
"exec.approval.requested" -> {
val approvalId = parseExecApprovalEventId(payloadJson)
approvalId?.let(resolvedExecApprovalIds::remove)
scope.launch {
if (approvalId == null) {
refreshExecApprovalsFromGateway()
} else {
refreshExecApprovalFromGateway(approvalId)
}
}
}
"exec.approval.resolved" -> {
val approvalId = parseExecApprovalEventId(payloadJson) ?: return
markExecApprovalResolved(approvalId)
}
}
}
private fun parseExecApprovalEventId(payloadJson: String?): String? =
try {
payloadJson
?.let { json.parseToJsonElement(it).asObjectOrNull() }
?.get("id")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
} catch (_: Throwable) {
null
}
private fun parseGatewayUpdateAvailable(payloadJson: String?): GatewayUpdateAvailableSummary? {
return try {
val root = payloadJson?.let { json.parseToJsonElement(it).asObjectOrNull() }
@@ -2154,196 +2080,6 @@ class NodeRuntime(
}
}
private suspend fun refreshExecApprovalsFromGateway() {
val refreshGeneration = execApprovalsRefreshSeq.incrementAndGet()
_execApprovalsRefreshing.value = true
_execApprovalsErrorText.value = null
if (!operatorConnected) {
if (execApprovalsRefreshSeq.get() == refreshGeneration) {
_execApprovals.value = emptyList()
_execApprovalsRefreshing.value = false
}
return
}
try {
val res = operatorSession.request("exec.approval.list", "{}")
val existing = _execApprovals.value.associateBy { it.id }
val rows =
parseGatewayExecApprovalListPayload(res, json)
.filterNot { it.id in resolvedExecApprovalIds }
.map { row ->
val hydrated =
try {
fetchExecApprovalDetailFromGateway(
id = row.id,
createdAtMs = row.createdAtMs ?: System.currentTimeMillis(),
)
} catch (_: Throwable) {
null
} ?: row.copy(errorText = "Could not load approval details. Refresh and try again.")
val current = existing[row.id]
if (current == null) {
hydrated
} else {
hydrated.copy(
resolvingDecision = current.resolvingDecision,
errorText = current.errorText ?: hydrated.errorText,
)
}
}
publishExecApprovalsIfCurrent(refreshGeneration, rows)
} catch (_: Throwable) {
if (execApprovalsRefreshSeq.get() == refreshGeneration) {
_execApprovalsErrorText.value = "Could not load approvals."
}
} finally {
if (execApprovalsRefreshSeq.get() == refreshGeneration) {
_execApprovalsRefreshing.value = false
}
}
}
private suspend fun refreshExecApprovalFromGateway(id: String) {
if (!operatorConnected) return
if (id in resolvedExecApprovalIds) return
try {
val current = _execApprovals.value.firstOrNull { it.id == id }
val row =
fetchExecApprovalDetailFromGateway(
id = id,
createdAtMs = current?.createdAtMs ?: System.currentTimeMillis(),
) ?: return
if (id in resolvedExecApprovalIds) return
invalidateExecApprovalRefreshes()
upsertExecApproval(row)
} catch (_: Throwable) {
refreshExecApprovalsFromGateway()
}
}
private suspend fun fetchExecApprovalDetailFromGateway(
id: String,
createdAtMs: Long,
): GatewayExecApprovalSummary? {
val params = buildJsonObject { put("id", JsonPrimitive(id)) }.toString()
val res = operatorSession.request("exec.approval.get", params)
val root = json.parseToJsonElement(res).asObjectOrNull() ?: return null
return parseGatewayExecApprovalDetail(root, createdAtMs = createdAtMs)
}
private suspend fun resolveExecApprovalOnGateway(
id: String,
decision: String,
) {
synchronized(execApprovalsStateLock) {
if (!operatorConnected || id in resolvedExecApprovalIds) return
val currentRows = _execApprovals.value
if (currentRows.none { it.id == id }) return
invalidateExecApprovalRefreshes()
_execApprovals.value =
currentRows.map { row ->
if (row.id == id) row.copy(resolvingDecision = decision, errorText = null) else row
}
}
try {
val params =
buildJsonObject {
put("id", JsonPrimitive(id))
put("decision", JsonPrimitive(decision))
}.toString()
operatorSession.request("exec.approval.resolve", params)
markExecApprovalResolved(id)
} catch (_: Throwable) {
synchronized(execApprovalsStateLock) {
if (!operatorConnected || id in resolvedExecApprovalIds) return
_execApprovals.value =
_execApprovals.value.map { row ->
if (row.id == id) {
row.copy(resolvingDecision = null, errorText = "Could not resolve approval. Refresh and try again.")
} else {
row
}
}
}
}
}
private fun upsertExecApproval(row: GatewayExecApprovalSummary) {
synchronized(execApprovalsStateLock) {
if (!operatorConnected || row.id in resolvedExecApprovalIds) return
if (row.isExpiredExecApproval()) return
val rows = _execApprovals.value
val replaced = rows.any { it.id == row.id }
val nextRows =
(
if (replaced) {
rows.map { current ->
if (current.id == row.id) {
row.copy(
resolvingDecision = current.resolvingDecision,
errorText = current.errorText,
)
} else {
current
}
}
} else {
rows + row
}
).filterActiveExecApprovals()
.sortedBy { it.createdAtMs ?: Long.MAX_VALUE }
_execApprovals.value = nextRows
scheduleExecApprovalExpiryPrune(nextRows)
}
}
private fun invalidateExecApprovalRefreshes() {
execApprovalsRefreshSeq.incrementAndGet()
_execApprovalsRefreshing.value = false
}
private fun markExecApprovalResolved(id: String) {
synchronized(execApprovalsStateLock) {
resolvedExecApprovalIds.add(id)
invalidateExecApprovalRefreshes()
_execApprovals.value = _execApprovals.value.filterNot { it.id == id }
}
}
private fun publishExecApprovalsIfCurrent(
refreshGeneration: Long,
rows: List<GatewayExecApprovalSummary>,
) {
synchronized(execApprovalsStateLock) {
if (execApprovalsRefreshSeq.get() == refreshGeneration && operatorConnected) {
val nextRows = rows.filterNot { it.id in resolvedExecApprovalIds }.filterActiveExecApprovals()
_execApprovals.value = nextRows
scheduleExecApprovalExpiryPrune(nextRows)
}
}
}
private fun scheduleExecApprovalExpiryPrune(rows: List<GatewayExecApprovalSummary>) {
val now = System.currentTimeMillis()
val nextExpiry = rows.mapNotNull { it.expiresAtMs }.filter { it > now }.minOrNull() ?: return
scope.launch {
delay((nextExpiry - now + 250).coerceAtLeast(0))
pruneExpiredExecApprovals()
}
}
private fun pruneExpiredExecApprovals() {
synchronized(execApprovalsStateLock) {
_execApprovals.value = _execApprovals.value.filterActiveExecApprovals()
}
}
private fun GatewayExecApprovalSummary.isExpiredExecApproval(nowMs: Long = System.currentTimeMillis()): Boolean = expiresAtMs?.let { it <= nowMs } == true
private fun List<GatewayExecApprovalSummary>.filterActiveExecApprovals(
nowMs: Long = System.currentTimeMillis(),
): List<GatewayExecApprovalSummary> = filterNot { it.isExpiredExecApproval(nowMs) }
private fun invalidateNodeCapabilityApprovalState() {
val refreshGeneration = nodeApprovalRefreshGuard.begin()
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
@@ -2458,19 +2194,12 @@ class NodeRuntime(
}.orEmpty()
private fun parseGatewayLogEntry(line: String): GatewayLogEntry {
val sanitizedLine = sanitizeGatewayLogText(line)
val root =
try {
json.parseToJsonElement(line).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return GatewayLogEntry(
time = null,
level = null,
subsystem = null,
message = sanitizedLine.trim().ifEmpty { "Empty log entry" },
raw = sanitizedLine,
)
} ?: return GatewayLogEntry(time = null, level = null, subsystem = null, message = line.trim().ifEmpty { "Empty log entry" })
val meta = root["_meta"].asObjectOrNull()
val time = root["time"].asStringOrNull() ?: meta?.get("date").asStringOrNull()
val level = normalizeLogLevel(meta?.get("logLevelName").asStringOrNull() ?: meta?.get("level").asStringOrNull())
@@ -2488,7 +2217,7 @@ class NodeRuntime(
?: root["message"].asStringOrNull()
?: line
val normalizedMessage =
sanitizeGatewayLogText(message)
message
.trim()
.replace(Regex("\\s+"), " ")
.take(240)
@@ -2496,9 +2225,8 @@ class NodeRuntime(
return GatewayLogEntry(
time = time,
level = level,
subsystem = subsystem?.let(::sanitizeGatewayLogText)?.trim()?.takeIf { it.isNotEmpty() },
subsystem = subsystem?.trim()?.takeIf { it.isNotEmpty() },
message = normalizedMessage,
raw = sanitizedLine,
)
}
@@ -2587,7 +2315,6 @@ class NodeRuntime(
if (name.isEmpty()) return@mapNotNull null
val missing = obj["missing"].asObjectOrNull()
GatewaySkillSummary(
skillKey = obj["skillKey"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: name,
name = name,
description = obj["description"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
source = obj["source"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: "unknown",
@@ -3038,6 +2765,11 @@ internal fun resolveOperatorSessionConnectAuth(
)
}
internal fun shouldConnectOperatorSession(
auth: NodeRuntime.GatewayConnectAuth,
storedOperatorToken: String?,
): Boolean = resolveOperatorSessionConnectAuth(auth, storedOperatorToken) != null
private enum class HomeCanvasGatewayState {
Connected,
Connecting,
@@ -3110,7 +2842,6 @@ data class GatewaySkillsSummary(
)
data class GatewaySkillSummary(
val skillKey: String,
val name: String,
val description: String?,
val source: String,
@@ -3308,19 +3039,8 @@ data class GatewayLogEntry(
val level: String?,
val subsystem: String?,
val message: String,
val raw: String,
)
private val gatewayAnsiControlPattern = Regex("\\u001B\\[[0-?]*[ -/]*[@-~]")
private val gatewayEscapedAnsiControlPattern = Regex("""\\u001[Bb]\[[0-?]*[ -/]*[@-~]""")
private val gatewayVisibleSgrPattern = Regex("\\[(?:0|\\d{1,3}(?:;\\d{1,3})*)m(?!])")
internal fun sanitizeGatewayLogText(value: String): String =
value
.replace(gatewayAnsiControlPattern, "")
.replace(gatewayEscapedAnsiControlPattern, "")
.replace(gatewayVisibleSgrPattern, "")
private fun JsonObject?.long(key: String): Long? = (this?.get(key) as? JsonPrimitive)?.content?.trim()?.toLongOrNull()
private fun JsonObject?.double(key: String): Double? = (this?.get(key) as? JsonPrimitive)?.content?.trim()?.toDoubleOrNull()

View File

@@ -393,6 +393,12 @@ class SecurePrefs(
return stored?.takeIf { it.isNotEmpty() }
}
/** Saves the paired gateway token under the current Android instance id. */
fun saveGatewayToken(token: String) {
val key = "gateway.token.${_instanceId.value}"
securePrefs.edit { putString(key, token.trim()) }
}
/** Loads the bootstrap token used during gateway setup and device-token handoff. */
fun loadGatewayBootstrapToken(): String? {
val key = "gateway.bootstrapToken.${_instanceId.value}"

View File

@@ -6,6 +6,14 @@ internal fun normalizeMainKey(raw: String?): String {
return if (!trimmed.isNullOrEmpty()) trimmed else "main"
}
/** Accepts only gateway session keys that can represent the main chat stream. */
internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
val trimmed = raw?.trim().orEmpty()
if (trimmed.isEmpty()) return false
if (trimmed == "global") return true
return trimmed.startsWith("agent:")
}
/** Extracts the agent id from canonical agent-scoped main session keys. */
internal fun resolveAgentIdFromMainSessionKey(raw: String?): String? {
val trimmed = raw?.trim().orEmpty()

View File

@@ -538,7 +538,8 @@ class ChatController internal constructor(
}
}
private fun parseEventSessionEntry(payload: JsonObject): ChatSessionEntry? = payload["session"].asObjectOrNull()?.let(::parseSessionEntry) ?: parseSessionEntry(payload)
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
@@ -746,16 +747,9 @@ class ChatController internal constructor(
): ChatSessionEntry? {
if (obj == null) return null
val key =
obj["key"]
.asStringOrNull()
?.trim()
.orEmpty()
.ifEmpty {
obj["sessionKey"]
.asStringOrNull()
?.trim()
.orEmpty()
}.ifEmpty { fallbackKey?.trim().orEmpty() }
obj["key"].asStringOrNull()?.trim().orEmpty()
.ifEmpty { obj["sessionKey"].asStringOrNull()?.trim().orEmpty() }
.ifEmpty { fallbackKey?.trim().orEmpty() }
if (key.isEmpty()) return null
return ChatSessionEntry(
key = key,

View File

@@ -1,5 +1,6 @@
package ai.openclaw.app.gateway
import android.annotation.TargetApi
import android.content.Context
import android.net.ConnectivityManager
import android.net.DnsResolver
@@ -11,7 +12,6 @@ import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.os.CancellationSignal
import android.util.Log
import androidx.annotation.RequiresApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -56,7 +56,7 @@ private fun createDnsResolver(context: Context): DnsResolver =
createLegacyDnsResolver()
}
@RequiresApi(Build.VERSION_CODES.CINNAMON_BUN)
@TargetApi(Build.VERSION_CODES.CINNAMON_BUN)
private fun createContextDnsResolver(context: Context): DnsResolver = DnsResolver(context, null)
@Suppress("DEPRECATION")
@@ -166,6 +166,14 @@ class GatewayDiscovery(
}
}
private fun stopLocalDiscovery() {
try {
nsd.stopServiceDiscovery(discoveryListener)
} catch (_: Throwable) {
// ignore (best-effort)
}
}
private fun startUnicastDiscovery(domain: String) {
unicastJob =
scope.launch(Dispatchers.IO) {
@@ -189,7 +197,7 @@ class GatewayDiscovery(
}
}
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@TargetApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
private fun resolveWithServiceInfoCallback(serviceInfo: NsdServiceInfo) {
val serviceName = BonjourEscapes.decode(serviceInfo.serviceName)
val id = stableId(serviceName, "local.")

View File

@@ -260,6 +260,24 @@ class GatewaySession(
currentConnection?.closeQuietly()
}
fun currentCanvasHostUrl(): String? = pluginSurfaceUrls["canvas"]
/** Refreshes the canvas plugin surface URL and caches the normalized Android-reachable URL. */
suspend fun refreshCanvasHostUrl(timeoutMs: Long = 8_000): String? {
val refreshed =
refreshPluginSurfaceUrl(
method = "node.pluginSurface.refresh",
params = buildJsonObject { put("surface", JsonPrimitive("canvas")) },
timeoutMs = timeoutMs,
)
if (!refreshed.isNullOrBlank()) {
pluginSurfaceUrls = pluginSurfaceUrls + ("canvas" to refreshed)
}
return refreshed
}
fun currentMainSessionKey(): String? = mainSessionKey
/** Sends a best-effort node.event and returns false instead of throwing on failure. */
suspend fun sendNodeEvent(
event: String,
@@ -279,6 +297,28 @@ class GatewaySession(
}
}
private suspend fun refreshPluginSurfaceUrl(
method: String,
params: JsonElement?,
timeoutMs: Long,
): String? {
val conn = currentConnection ?: return null
return try {
val res = conn.request(method, params, timeoutMs)
if (!res.ok) return null
val obj = res.payloadJson?.let { json.parseToJsonElement(it).asObjectOrNull() } ?: return null
val raw =
obj["pluginSurfaceUrls"]
.asObjectOrNull()
?.get("canvas")
.asStringOrNull()
normalizeCanvasHostUrl(raw, conn.endpoint, isTlsConnection = conn.tls != null)
} catch (err: Throwable) {
Log.d("OpenClawGateway", "$method failed: ${err.message ?: err::class.java.simpleName}")
null
}
}
/** Sends node.event and preserves the gateway RPC error shape for callers that need diagnostics. */
suspend fun sendNodeEventDetailed(
event: String,

View File

@@ -97,6 +97,8 @@ class CanvasController {
fun currentUrl(): String? = url
fun isDefaultCanvas(): Boolean = url == null
fun setDebugStatusEnabled(enabled: Boolean) {
debugStatusEnabled = enabled
applyDebugStatus()
@@ -203,6 +205,24 @@ class CanvasController {
}
}
suspend fun snapshotPngBase64(maxWidth: Int?): String =
withContext(Dispatchers.Main) {
val wv = webView ?: throw IllegalStateException("no webview")
val bmp = wv.captureBitmap()
try {
val scaled = bmp.scaleForMaxWidth(maxWidth)
try {
val out = ByteArrayOutputStream()
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
} finally {
if (scaled !== bmp) scaled.recycle()
}
} finally {
bmp.recycle()
}
}
/** Captures the WebView as PNG/JPEG base64 with optional width and quality bounds. */
suspend fun snapshotBase64(
format: SnapshotFormat,

View File

@@ -4,7 +4,6 @@ import ai.openclaw.app.BuildConfig
import ai.openclaw.app.SensitiveFeatureConfig
import ai.openclaw.app.gateway.GatewaySession
import android.Manifest
import android.annotation.SuppressLint
import android.app.ActivityManager
import android.content.Context
import android.content.Intent
@@ -64,7 +63,7 @@ private class AndroidDeviceAppSource(
val appInfos =
if (includeNonLaunchable) {
visibleInstalledApplications(packageManager)
packageManager.getInstalledApplications(PackageManager.MATCH_ALL)
} else {
launchablePackages.mapNotNull { packageName ->
runCatching { packageManager.getApplicationInfo(packageName, 0) }.getOrNull()
@@ -91,13 +90,6 @@ private class AndroidDeviceAppSource(
.sortedWith(compareBy<DeviceAppEntry> { it.label.lowercase() }.thenBy { it.packageName })
.toList()
}
@SuppressLint("QueryPermissionsNeeded")
private fun visibleInstalledApplications(packageManager: PackageManager): List<ApplicationInfo> {
// Android package visibility intentionally bounds this result to packages the app can see.
// OpenClaw should not request QUERY_ALL_PACKAGES for this optional device-context surface.
return packageManager.getInstalledApplications(PackageManager.MATCH_ALL)
}
}
private data class DeviceAppsRequest(

View File

@@ -109,3 +109,6 @@ fun normalizeMainKey(raw: String?): String? {
val trimmed = raw?.trim().orEmpty()
return if (trimmed.isEmpty()) null else trimmed
}
/** Returns true only for the canonical main-session key understood by gateway UI. */
fun isCanonicalMainSessionKey(key: String): Boolean = key == "main"

View File

@@ -5,7 +5,6 @@ import ai.openclaw.app.GatewayModelSummary
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.design.ClawEmptyState
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawPlainIconButton
import ai.openclaw.app.ui.design.ClawScaffold
import ai.openclaw.app.ui.design.ClawSeparatedColumn
import ai.openclaw.app.ui.design.ClawTextField
@@ -95,11 +94,7 @@ internal fun CommandPalette(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
ClawPlainIconButton(
icon = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Close search",
onClick = onDismiss,
)
CommandIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close search", onClick = onDismiss)
Text(text = "Search", style = ClawTheme.type.title, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), textAlign = TextAlign.Center)
CommandAvatar(text = "OC")
}
@@ -267,6 +262,19 @@ private fun CommandSessionListRow(
}
}
@Composable
private fun CommandIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
) {
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
}
}
}
@Composable
private fun CommandAvatar(text: String) {
Surface(

View File

@@ -5,7 +5,8 @@ import ai.openclaw.app.GatewayDreamingSummary
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawStatusRow
import ai.openclaw.app.ui.design.ClawStatus
import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawTheme
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
@@ -91,19 +92,19 @@ private fun DreamingPanel(summary: GatewayDreamingSummary) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
Column {
ClawStatusRow(
DreamingHealthRow(
title = "Memory Store",
value = if (summary.storeHealthy) "Healthy" else "Needs attention",
healthy = summary.storeHealthy,
)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
ClawStatusRow(
DreamingHealthRow(
title = "Signal Index",
value = if (summary.phaseSignalHealthy) "Healthy" else "Needs attention",
healthy = summary.phaseSignalHealthy,
)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
ClawStatusRow(
DreamingHealthRow(
title = "Promoted",
value = "${summary.promotedToday} today · ${summary.promotedTotal} total",
healthy = true,
@@ -114,6 +115,23 @@ private fun DreamingPanel(summary: GatewayDreamingSummary) {
}
}
@Composable
private fun DreamingHealthRow(
title: String,
value: String,
healthy: Boolean,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Box(modifier = Modifier.size(7.dp))
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1)
ClawStatusPill(text = value, status = if (healthy) ClawStatus.Success else ClawStatus.Warning)
}
}
@Composable
private fun DreamDiaryPanel(summary: GatewayDreamingSummary) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {

View File

@@ -206,6 +206,9 @@ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
}
}
/** Extracts a setup code from QR scanner text when the embedded endpoint is valid. */
internal fun resolveScannedSetupCode(rawInput: String): String? = resolveScannedSetupCodeResult(rawInput).setupCode
/** Resolves QR scanner text to setup-code or validation error for UI copy. */
internal fun resolveScannedSetupCodeResult(rawInput: String): GatewayScannedSetupCodeResult {
val setupCode =

View File

@@ -7,10 +7,7 @@ import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawStatus
import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawStatusRow
import ai.openclaw.app.ui.design.ClawTheme
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -18,18 +15,13 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
@@ -51,7 +43,6 @@ internal fun HealthLogsSettingsScreen(
val logsSummary by viewModel.healthLogsSummary.collectAsState()
val logsRefreshing by viewModel.healthLogsRefreshing.collectAsState()
val logsErrorText by viewModel.healthLogsErrorText.collectAsState()
var selectedLogEntry by remember { mutableStateOf<GatewayLogEntry?>(null) }
LaunchedEffect(isConnected) {
if (isConnected) {
@@ -61,11 +52,6 @@ internal fun HealthLogsSettingsScreen(
}
}
selectedLogEntry?.let { entry ->
GatewayLogDetailSettingsScreen(entry = entry, onBack = { selectedLogEntry = null })
return
}
SettingsDetailFrame(
title = "Health",
subtitle = "Gateway status, phone node readiness, and recent log stream.",
@@ -107,46 +93,7 @@ internal fun HealthLogsSettingsScreen(
Text(text = error, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
}
}
GatewayLogsPanel(isConnected = isConnected, summary = logsSummary, onLogClick = { selectedLogEntry = it })
}
}
@Composable
private fun GatewayLogDetailSettingsScreen(
entry: GatewayLogEntry,
onBack: () -> Unit,
) {
BackHandler(onBack = onBack)
SettingsDetailFrame(
title = "Log Entry",
subtitle = "Readable gateway log detail.",
icon = Icons.Default.Settings,
onBack = onBack,
) {
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Time", compactLogTime(entry.time)),
SettingsMetric("Level", entry.level?.uppercase() ?: "LOG"),
SettingsMetric("Subsystem", entry.subsystem ?: "Unknown"),
),
)
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = "Message", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = entry.message, style = ClawTheme.type.body, color = ClawTheme.colors.text)
}
}
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = "Raw", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(
text = entry.raw.take(4_000),
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
)
}
}
GatewayLogsPanel(isConnected = isConnected, summary = logsSummary)
}
}
@@ -166,26 +113,41 @@ private fun HealthStatusPanel(
) {
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
Column {
ClawStatusRow(title = "Gateway", value = gateway, healthy = isConnected)
HealthStatusRow(title = "Gateway", value = gateway, healthy = isConnected)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
ClawStatusRow(title = "Phone Node", value = node, healthy = isNodeConnected)
HealthStatusRow(title = "Phone Node", value = node, healthy = isNodeConnected)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
ClawStatusRow(title = "Chat", value = chat, healthy = chatHealthOk)
HealthStatusRow(title = "Chat", value = chat, healthy = chatHealthOk)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
ClawStatusRow(title = "Models", value = models, healthy = modelsReady)
HealthStatusRow(title = "Models", value = models, healthy = modelsReady)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
ClawStatusRow(title = "Voice", value = voice, healthy = voiceReady)
HealthStatusRow(title = "Voice", value = voice, healthy = voiceReady)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
ClawStatusRow(title = "Runs", value = runs, healthy = true)
HealthStatusRow(title = "Runs", value = runs, healthy = true)
}
}
}
@Composable
private fun HealthStatusRow(
title: String,
value: String,
healthy: Boolean,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1)
ClawStatusPill(text = value, status = if (healthy) ClawStatus.Success else ClawStatus.Warning)
}
}
@Composable
private fun GatewayLogsPanel(
isConnected: Boolean,
summary: GatewayHealthLogsSummary,
onLogClick: (GatewayLogEntry) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
@@ -208,7 +170,7 @@ private fun GatewayLogsPanel(
val entries = summary.entries.takeLast(12)
Column {
entries.forEachIndexed { index, entry ->
GatewayLogRow(entry = entry, onClick = { onLogClick(entry) })
GatewayLogRow(entry = entry)
if (index != entries.lastIndex) {
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
}
@@ -223,16 +185,9 @@ private fun GatewayLogsPanel(
}
@Composable
private fun GatewayLogRow(
entry: GatewayLogEntry,
onClick: () -> Unit,
) {
private fun GatewayLogRow(entry: GatewayLogEntry) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClickLabel = "Open log entry", onClick = onClick)
.padding(horizontal = 10.dp, vertical = 7.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
@@ -244,11 +199,6 @@ private fun GatewayLogRow(
}
}
ClawStatusPill(text = entry.level?.uppercase() ?: "LOG", status = logLevelStatus(entry.level))
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = ClawTheme.colors.textSubtle,
)
}
}

View File

@@ -1378,12 +1378,7 @@ private fun rememberPermissionState(
photosGranted = permissions[photosPermission] ?: photosGranted
contactsGranted = permissions[Manifest.permission.READ_CONTACTS] ?: contactsGranted
calendarGranted = permissions[Manifest.permission.READ_CALENDAR] ?: calendarGranted
notificationsGranted =
if (Build.VERSION.SDK_INT >= 33) {
permissions[Manifest.permission.POST_NOTIFICATIONS] ?: notificationsGranted
} else {
true
}
notificationsGranted = permissions[Manifest.permission.POST_NOTIFICATIONS] ?: notificationsGranted
motionGranted = permissions[Manifest.permission.ACTIVITY_RECOGNITION] ?: motionGranted
smsGranted =
(permissions[Manifest.permission.SEND_SMS] ?: smsGranted) &&

View File

@@ -9,10 +9,14 @@ import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val LocalOpenClawDarkTheme = staticCompositionLocalOf { true }
/**
* App theme wrapper that installs dynamic Material colors and legacy mobile color tokens.
*/
@@ -30,6 +34,7 @@ fun OpenClawTheme(
CompositionLocalProvider(
LocalMobileColors provides mobileColors,
LocalOpenClawDarkTheme provides isDark,
) {
MaterialTheme(colorScheme = colorScheme, content = content)
}
@@ -50,3 +55,21 @@ internal fun OpenClawSystemBarAppearance(lightAppearance: Boolean) {
}
}
}
/**
* Overlay background token tuned for panels floating over the mobile canvas.
*/
@Composable
fun overlayContainerColor(): Color {
val scheme = MaterialTheme.colorScheme
val isDark = LocalOpenClawDarkTheme.current
val base = if (isDark) scheme.surfaceContainerLow else scheme.surfaceContainerHigh
// Light mode keeps overlays away from pure-white glare on the app canvas.
return if (isDark) base else base.copy(alpha = 0.88f)
}
/**
* Overlay icon token kept next to overlayContainerColor for callers outside the design package.
*/
@Composable
fun overlayIconColor(): Color = MaterialTheme.colorScheme.onSurfaceVariant

View File

@@ -2,7 +2,6 @@ package ai.openclaw.app.ui
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.design.ClawEmptyState
import ai.openclaw.app.ui.design.ClawPlainIconButton
import ai.openclaw.app.ui.design.ClawPrimaryButton
import ai.openclaw.app.ui.design.ClawScaffold
import ai.openclaw.app.ui.design.ClawTheme
@@ -56,7 +55,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/** Session browser for recent and current chat sessions. */
/** Session browser for recent and currently-live chat sessions. */
@Composable
internal fun SessionsScreen(
viewModel: MainViewModel,
@@ -74,7 +73,7 @@ internal fun SessionsScreen(
.let { rows ->
when (filter) {
SessionFilter.Recent -> rows
SessionFilter.Current -> rows.filter { it.key == chatSessionKey }
SessionFilter.Live -> rows.filter { it.key == chatSessionKey }
}
}.let { rows ->
if (recentFirst) {
@@ -93,12 +92,12 @@ internal fun SessionsScreen(
}
ClawScaffold(
contentPadding = PaddingValues(start = 16.dp, top = 10.dp, end = 16.dp, bottom = 4.dp),
contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 6.dp),
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(9.dp),
verticalArrangement = Arrangement.spacedBy(7.dp),
contentPadding = PaddingValues(bottom = 4.dp),
) {
item {
@@ -107,16 +106,16 @@ internal fun SessionsScreen(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = "Sessions", style = ClawTheme.type.display.copy(fontSize = 24.sp, lineHeight = 28.sp), color = ClawTheme.colors.text, modifier = Modifier.weight(1f))
ClawPlainIconButton(icon = Icons.Default.Search, contentDescription = "Search sessions", onClick = onOpenCommand)
ClawPlainIconButton(icon = Icons.Default.SwapVert, contentDescription = "Reverse session sort", onClick = { recentFirst = !recentFirst })
Text(text = "Sessions", style = ClawTheme.type.display.copy(fontSize = 17.4.sp, lineHeight = 21.sp), color = ClawTheme.colors.text, modifier = Modifier.weight(1f))
SessionPlainIconButton(icon = Icons.Default.Search, contentDescription = "Search sessions", onClick = onOpenCommand)
SessionPlainIconButton(icon = Icons.Default.SwapVert, contentDescription = "Reverse session sort", onClick = { recentFirst = !recentFirst })
}
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
FilterPill(text = "Recent", icon = Icons.Outlined.AccessTime, active = filter == SessionFilter.Recent, onClick = { filter = SessionFilter.Recent })
FilterPill(text = "Current", icon = Icons.Outlined.MicNone, active = filter == SessionFilter.Current, showDot = sessions.any { it.key == chatSessionKey }, onClick = { filter = SessionFilter.Current })
FilterPill(text = "Live", icon = Icons.Outlined.MicNone, active = filter == SessionFilter.Live, live = sessions.any { it.key == chatSessionKey }, onClick = { filter = SessionFilter.Live })
}
}
@@ -180,7 +179,7 @@ private fun FilterPill(
text: String,
icon: ImageVector? = null,
active: Boolean = false,
showDot: Boolean = false,
live: Boolean = false,
dropdown: Boolean = false,
onClick: (() -> Unit)? = null,
) {
@@ -199,7 +198,7 @@ private fun FilterPill(
) {
icon?.let { Icon(imageVector = it, contentDescription = null, modifier = Modifier.size(12.dp), tint = ClawTheme.colors.text) }
Text(text = text, style = ClawTheme.type.label, color = ClawTheme.colors.text, maxLines = 1)
if (showDot) {
if (live) {
Box(modifier = Modifier.size(4.dp).clip(CircleShape).background(ClawTheme.colors.success))
}
if (dropdown) {
@@ -259,7 +258,7 @@ private fun SessionRow(
Text(text = subtitle, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
SessionMiniTag(text = "Workspace")
SessionMiniTag(text = if (active) "Current" else "OpenClaw")
SessionMiniTag(text = if (active) "Active" else "OpenClaw")
}
}
}
@@ -274,6 +273,19 @@ private fun SessionRow(
}
}
@Composable
private fun SessionPlainIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
) {
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
}
}
}
@Composable
private fun SessionOutlineIconButton(
icon: ImageVector,
@@ -308,21 +320,21 @@ private fun SessionMiniTag(text: String) {
private enum class SessionFilter {
Recent,
Current,
Live,
}
/** Empty-state title selected by the active session browser filter. */
private fun emptySessionTitle(filter: SessionFilter): String =
when (filter) {
SessionFilter.Recent -> "No sessions yet"
SessionFilter.Current -> "No current session"
SessionFilter.Live -> "No live session"
}
/** Empty-state body selected by the active session browser filter. */
private fun emptySessionBody(filter: SessionFilter): String =
when (filter) {
SessionFilter.Recent -> "Start a new conversation and it will show up here."
SessionFilter.Current -> "Open Chat to start or resume the current session."
SessionFilter.Live -> "Open Chat to start or resume the current session."
}
/** Formats session timestamps for compact mobile metadata. */

View File

@@ -4,7 +4,6 @@ import ai.openclaw.app.AppearanceThemeMode
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.GatewayAgentSummary
import ai.openclaw.app.GatewayCronJobSummary
import ai.openclaw.app.GatewayExecApprovalSummary
import ai.openclaw.app.GatewayUsageProviderSummary
import ai.openclaw.app.LocationMode
import ai.openclaw.app.MainViewModel
@@ -15,7 +14,6 @@ import ai.openclaw.app.ui.design.ClawDetailRow
import ai.openclaw.app.ui.design.ClawIconBadge
import ai.openclaw.app.ui.design.ClawListPanel
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawPlainIconButton
import ai.openclaw.app.ui.design.ClawPrimaryButton
import ai.openclaw.app.ui.design.ClawScaffold
import ai.openclaw.app.ui.design.ClawSecondaryButton
@@ -92,6 +90,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
@@ -107,7 +106,6 @@ internal enum class SettingsRoute {
Profile,
Voice,
Agents,
ProvidersModels,
Approvals,
CronJobs,
Usage,
@@ -138,7 +136,6 @@ internal fun SettingsDetailScreen(
SettingsRoute.Profile -> ProfileSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Voice -> VoiceSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Agents -> AgentsSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.ProvidersModels -> ProvidersModelsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Approvals -> ApprovalsSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.CronJobs -> CronJobsSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Usage -> UsageSettingsScreen(viewModel = viewModel, onBack = onBack)
@@ -302,62 +299,29 @@ private fun ApprovalsSettingsScreen(
viewModel: MainViewModel,
onBack: () -> Unit,
) {
val isConnected by viewModel.isConnected.collectAsState()
val execApprovals by viewModel.execApprovals.collectAsState()
val execApprovalsRefreshing by viewModel.execApprovalsRefreshing.collectAsState()
val execApprovalsErrorText by viewModel.execApprovalsErrorText.collectAsState()
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
val issueCount = execApprovals.count { it.errorText != null } + pendingToolCalls.count { it.isError == true }
LaunchedEffect(isConnected) {
if (isConnected) {
viewModel.refreshExecApprovals()
}
}
val waitingCount = pendingToolCalls.count { it.isError != true }
val issueCount = pendingToolCalls.count { it.isError == true }
SettingsDetailFrame(title = "Approvals", subtitle = "Review actions that need your attention.", icon = Icons.Default.Lock, onBack = onBack) {
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Gateway Pending", execApprovals.size.toString()),
SettingsMetric("Session Activity", pendingToolCalls.size.toString()),
SettingsMetric("Pending", waitingCount.toString()),
SettingsMetric("Issues", issueCount.toString()),
SettingsMetric("Active Runs", pendingRunCount.toString()),
),
)
ClawSecondaryButton(
text = if (execApprovalsRefreshing) "Refreshing" else "Refresh",
onClick = viewModel::refreshExecApprovals,
enabled = isConnected && !execApprovalsRefreshing,
modifier = Modifier.fillMaxWidth(),
)
if (execApprovalsErrorText != null) {
ClawPanel {
Text(text = execApprovalsErrorText ?: "", style = ClawTheme.type.body, color = ClawTheme.colors.warning)
}
}
if (!isConnected) {
if (pendingToolCalls.isEmpty()) {
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "Gateway disconnected.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "Connect the gateway to load approval requests in the app.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
} else if (execApprovals.isEmpty()) {
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "No gateway approvals.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "Exec approval requests will appear here while this phone is connected.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
Text(text = "Nothing needs approval.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "OpenClaw will show action requests here when a session pauses for review.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
} else {
ExecApprovalsPanel(approvals = execApprovals, onResolve = viewModel::resolveExecApproval)
}
if (pendingToolCalls.isNotEmpty()) {
Text(text = "Session activity", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "Chat tool calls waiting in the active session remain visible here.", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
SessionToolCallsPanel(toolCalls = pendingToolCalls)
ApprovalsPanel(toolCalls = pendingToolCalls)
}
}
}
@@ -856,7 +820,6 @@ private fun GatewaySettingsScreen(
var bootstrapTokenInput by remember { mutableStateOf("") }
var passwordInput by remember { mutableStateOf("") }
var validationText by remember { mutableStateOf<String?>(null) }
var showSetupCodeHelp by remember { mutableStateOf(false) }
SettingsDetailFrame(title = "Gateway", subtitle = "Connection between this phone and OpenClaw.", icon = Icons.Default.Cloud, onBack = onBack) {
SettingsMetricPanel(
@@ -877,17 +840,7 @@ private fun GatewaySettingsScreen(
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(text = "Pair New Gateway", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "Clear this phone's saved gateway access and scan a fresh setup code.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ClawSecondaryButton(text = "Pair New Gateway", onClick = viewModel::pairNewGateway, modifier = Modifier.weight(1f), icon = Icons.Default.QrCode2)
ClawSecondaryButton(text = "Setup Code", onClick = { showSetupCodeHelp = !showSetupCodeHelp }, modifier = Modifier.weight(1f), icon = Icons.Default.Info)
}
if (showSetupCodeHelp) {
Text(
text = "Android can scan or paste an existing setup code, but this gateway does not expose setup-code generation to the app yet. Generate the QR/code on the gateway host with openclaw qr, then scan it here or paste the setup code below.",
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
)
}
ClawSecondaryButton(text = "Pair New Gateway", onClick = viewModel::pairNewGateway, modifier = Modifier.fillMaxWidth(), icon = Icons.Default.QrCode2)
}
}
ClawPanel {
@@ -1108,11 +1061,7 @@ internal fun SettingsDetailFrame(
LazyColumn(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(bottom = 4.dp)) {
item {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
ClawPlainIconButton(
icon = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
onClick = onBack,
)
SettingsBackButton(onClick = onBack)
Text(text = title, style = ClawTheme.type.title, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
SettingsIconMark(icon = icon)
}
@@ -1149,70 +1098,7 @@ internal data class SettingsMetric(
)
@Composable
private fun ExecApprovalsPanel(
approvals: List<GatewayExecApprovalSummary>,
onResolve: (String, String) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
approvals.forEach { approval ->
ExecApprovalCard(approval = approval, onResolve = onResolve)
}
}
}
@Composable
private fun ExecApprovalCard(
approval: GatewayExecApprovalSummary,
onResolve: (String, String) -> Unit,
) {
val resolving = approval.resolvingDecision != null
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(9.dp)) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = approval.commandText, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 2, overflow = TextOverflow.Ellipsis)
approval.commandPreview?.let { preview ->
Text(text = preview, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
ClawStatusPill(text = if (resolving) "Sending" else "Review", status = if (resolving) ClawStatus.Warning else ClawStatus.Success)
}
Text(text = execApprovalMetadata(approval), style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle, maxLines = 2, overflow = TextOverflow.Ellipsis)
approval.errorText?.let { errorText ->
Text(text = errorText, style = ClawTheme.type.caption, color = ClawTheme.colors.warning)
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if ("allow-once" in approval.allowedDecisions) {
ClawPrimaryButton(
text = if (approval.resolvingDecision == "allow-once") "Allowing" else "Allow Once",
onClick = { onResolve(approval.id, "allow-once") },
enabled = !resolving,
modifier = Modifier.weight(1f),
)
}
if ("allow-always" in approval.allowedDecisions) {
ClawSecondaryButton(
text = if (approval.resolvingDecision == "allow-always") "Saving" else "Always",
onClick = { onResolve(approval.id, "allow-always") },
enabled = !resolving,
modifier = Modifier.weight(1f),
)
}
if ("deny" in approval.allowedDecisions) {
ClawSecondaryButton(
text = if (approval.resolvingDecision == "deny") "Denying" else "Deny",
onClick = { onResolve(approval.id, "deny") },
enabled = !resolving,
modifier = Modifier.weight(1f),
)
}
}
}
}
}
@Composable
private fun SessionToolCallsPanel(toolCalls: List<ChatPendingToolCall>) {
private fun ApprovalsPanel(toolCalls: List<ChatPendingToolCall>) {
ClawListPanel(items = toolCalls) { toolCall ->
ApprovalListRow(toolCall = toolCall)
}
@@ -1345,30 +1231,6 @@ private fun approvalSubtitle(
return if (minutes < 1) "Waiting for review" else "Waiting ${minutes}m"
}
private fun execApprovalMetadata(approval: GatewayExecApprovalSummary): String {
val target =
when {
approval.host == "node" && approval.nodeId != null -> "Node ${approval.nodeId.take(8)}"
approval.host != null -> approval.host.replaceFirstChar { it.uppercaseChar() }
else -> "Gateway"
}
val agent = approval.agentId?.let { "Agent ${it.take(8)}" }
val age = approval.createdAtMs?.let { "Waiting ${formatApprovalDuration(System.currentTimeMillis() - it)}" }
val expires = approval.expiresAtMs?.let { "Expires ${formatApprovalDuration(it - System.currentTimeMillis())}" }
return listOfNotNull(target, agent, age, expires).joinToString(" · ")
}
private fun formatApprovalDuration(deltaMs: Long): String {
val safeDelta = deltaMs.coerceAtLeast(0L)
val minutes = safeDelta / 60_000L
val hours = minutes / 60L
return when {
minutes < 1 -> "soon"
hours < 1 -> "${minutes}m"
else -> "${hours}h"
}
}
/** Builds the dense cron-job subtitle from schedule, next wake, and prompt preview. */
private fun cronJobSubtitle(job: GatewayCronJobSummary): String = "${job.scheduleLabel} · ${formatCronWake(job.nextRunAtMs)} · ${job.promptPreview}"
@@ -1532,6 +1394,15 @@ internal fun SettingsMetricPanel(rows: List<SettingsMetric>) {
}
}
@Composable
private fun SettingsBackButton(onClick: () -> Unit) {
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", modifier = Modifier.size(18.dp))
}
}
}
@Composable
private fun SettingsIconMark(icon: ImageVector) {
Surface(

View File

@@ -1253,6 +1253,16 @@ private fun settingsPrimaryButtonColors() =
disabledContentColor = Color.White.copy(alpha = 0.9f),
)
/** Destructive button colors for permission and capability settings actions. */
@Composable
private fun settingsDangerButtonColors() =
ButtonDefaults.buttonColors(
containerColor = mobileDanger,
contentColor = Color.White,
disabledContainerColor = mobileDanger.copy(alpha = 0.45f),
disabledContentColor = Color.White.copy(alpha = 0.9f),
)
/** Opens this app's Android settings page for permissions that require system UI. */
private fun openAppSettings(context: Context) {
val intent =

View File

@@ -10,24 +10,17 @@ import ai.openclaw.app.ui.design.ClawStatus
import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawTextBadge
import ai.openclaw.app.ui.design.ClawTheme
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@@ -44,7 +37,6 @@ internal fun SkillsSettingsScreen(
val skills = skillsSummary.skills
val readyCount = skills.count { skillReady(it) }
val needsSetupCount = skills.count { skillNeedsSetup(it) }
var selectedSkillKey by remember { mutableStateOf<String?>(null) }
LaunchedEffect(isConnected) {
if (isConnected) {
@@ -52,17 +44,6 @@ internal fun SkillsSettingsScreen(
}
}
selectedSkillKey?.let { skillKey ->
val selectedSkill = skills.firstOrNull { it.skillKey == skillKey }
SkillDetailSettingsScreen(
skill = selectedSkill,
skillKey = skillKey,
isConnected = isConnected,
onBack = { selectedSkillKey = null },
)
return
}
SettingsDetailFrame(
title = "Skills",
subtitle = "Installed capabilities available to OpenClaw.",
@@ -102,117 +83,25 @@ internal fun SkillsSettingsScreen(
Text(text = "Skills installed on the gateway will appear here.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
else -> SkillsPanel(skills = skills, onSkillClick = { selectedSkillKey = it.skillKey })
else -> SkillsPanel(skills = skills)
}
}
}
@Composable
private fun SkillDetailSettingsScreen(
skill: GatewaySkillSummary?,
skillKey: String,
isConnected: Boolean,
onBack: () -> Unit,
) {
BackHandler(onBack = onBack)
SettingsDetailFrame(
title = skill?.name ?: skillKey,
subtitle = "Inspect installed skill capability and setup state.",
icon = Icons.Default.Settings,
onBack = onBack,
) {
skill?.let { summary ->
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Status", skillStatusText(summary)),
SettingsMetric("Source", skillSourceLabel(summary)),
SettingsMetric("Missing", summary.missingCount.toString()),
),
)
SkillSetupPanel(summary)
}
SkillDetailPanel(skill = skill, isConnected = isConnected)
}
}
@Composable
private fun SkillSetupPanel(skill: GatewaySkillSummary) {
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = "Setup", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = skillConfigurationText(skill), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
}
@Composable
private fun SkillDetailPanel(
skill: GatewaySkillSummary?,
isConnected: Boolean,
) {
if (!isConnected) {
ClawPanel {
Text(text = "Connect the gateway to load skill details.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
return
}
if (skill == null) {
ClawPanel {
Text(text = "Skill detail is not available in the current skills status.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
return
}
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Skill Key", skill.skillKey),
SettingsMetric("Display", skill.name),
SettingsMetric("Source", skillSourceLabel(skill)),
SettingsMetric("Install Options", skill.installCount.toString()),
),
)
skill.description?.let { description ->
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = "Description", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = description, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
}
}
@Composable
private fun SkillsPanel(
skills: List<GatewaySkillSummary>,
onSkillClick: (GatewaySkillSummary) -> Unit,
) {
private fun SkillsPanel(skills: List<GatewaySkillSummary>) {
ClawListPanel(items = skills) { skill ->
SkillListRow(skill = skill, onClick = { onSkillClick(skill) })
SkillListRow(skill = skill)
}
}
@Composable
private fun SkillListRow(
skill: GatewaySkillSummary,
onClick: () -> Unit,
) {
private fun SkillListRow(skill: GatewaySkillSummary) {
ClawDetailRow(
title = skill.name,
subtitle = skillSubtitle(skill),
modifier = Modifier.clickable(onClickLabel = "Open skill detail", onClick = onClick),
leading = { ClawTextBadge(text = skillBadge(skill)) },
trailing = {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
ClawStatusPill(text = skillStatusText(skill), status = skillStatus(skill))
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = ClawTheme.colors.textSubtle,
)
}
},
trailing = { ClawStatusPill(text = skillStatusText(skill), status = skillStatus(skill)) },
)
}
@@ -246,15 +135,6 @@ private fun skillSubtitle(skill: GatewaySkillSummary): String {
return listOfNotNull(skill.description, skillSourceLabel(skill), issue).joinToString(" · ")
}
private fun skillConfigurationText(skill: GatewaySkillSummary): String =
when {
skill.disabled -> "This skill is disabled on the gateway. Android shows detail only; enable or configure it from desktop or CLI."
skill.blockedByAllowlist -> "This skill is blocked by the gateway allowlist. Android can inspect it, but allowlist changes stay on desktop or CLI."
skill.missingCount > 0 -> "This skill needs ${skill.missingCount} setup item(s). Android shows what is installed; setup/config changes stay on desktop or CLI."
!skill.eligible -> "This skill is installed but not currently eligible to run. Use desktop or CLI for configuration changes."
else -> "Ready on this gateway. Android detail is read-only; install, update, and configuration changes stay on desktop or CLI."
}
private fun skillSourceLabel(skill: GatewaySkillSummary): String =
when (skill.source) {
"openclaw-bundled" -> if (skill.bundled) "Built-in" else "Bundled"

View File

@@ -1,10 +1,8 @@
package ai.openclaw.app.ui
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.R
import ai.openclaw.app.VoiceCaptureMode
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawPlainIconButton
import ai.openclaw.app.ui.design.ClawPrimaryButton
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawStatus
@@ -70,7 +68,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -180,8 +177,8 @@ fun VoiceScreen(
Modifier
.fillMaxSize()
.imePadding()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(9.dp),
.padding(horizontal = 20.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
VoiceHeader(
statusText = voiceAttentionStatus ?: if (voiceActive || !gatewayReady) activeStatus else "Your voice command center.",
@@ -270,12 +267,12 @@ private fun DictationScreen(
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
ClawPlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onCancel)
VoicePlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onCancel)
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(text = "Dictation", style = ClawTheme.type.title.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text)
Text(text = "Transcribe then send", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
ClawPlainIconButton(icon = Icons.Default.Settings, contentDescription = "Dictation settings", onClick = onOpenVoiceSettings)
VoicePlainIconButton(icon = Icons.Default.Settings, contentDescription = "Dictation settings", onClick = onOpenVoiceSettings)
}
Surface(
@@ -407,7 +404,7 @@ private fun TalkSessionScreen(
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
ClawPlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onEndTalk)
VoicePlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onEndTalk)
Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "Realtime Talk", style = ClawTheme.type.title.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
@@ -426,7 +423,7 @@ private fun TalkSessionScreen(
)
}
}
ClawPlainIconButton(icon = Icons.Default.Info, contentDescription = "Talk settings", onClick = onOpenVoiceSettings)
VoicePlainIconButton(icon = Icons.Default.Info, contentDescription = "Talk settings", onClick = onOpenVoiceSettings)
}
Surface(
@@ -550,19 +547,14 @@ private fun VoiceHeader(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(
painter = painterResource(id = R.drawable.openclaw_logo),
contentDescription = null,
modifier = Modifier.size(25.dp),
tint = ClawTheme.colors.text,
)
Text(
text = "OpenClaw",
style = ClawTheme.type.title.copy(fontSize = 17.sp, lineHeight = 21.sp),
text = "O P E N C L A W",
style = ClawTheme.type.title.copy(fontSize = 18.sp, lineHeight = 23.sp),
color = ClawTheme.colors.text,
modifier = Modifier.weight(1f),
)
ClawPlainIconButton(icon = Icons.Default.Search, contentDescription = "Search voice", onClick = onOpenCommand)
VoicePlainIconButton(icon = Icons.Default.Search, contentDescription = "Search voice", onClick = onOpenCommand)
VoiceAvatar(text = "OC")
}
Row(
modifier = Modifier.fillMaxWidth(),
@@ -570,7 +562,7 @@ private fun VoiceHeader(
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "Voice", style = ClawTheme.type.display.copy(fontSize = 24.sp, lineHeight = 28.sp), color = ClawTheme.colors.text)
Text(text = "Voice", style = ClawTheme.type.display.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text)
Text(
text = statusText,
style = ClawTheme.type.body,
@@ -579,7 +571,7 @@ private fun VoiceHeader(
overflow = TextOverflow.Ellipsis,
)
}
ClawPlainIconButton(
VoicePlainIconButton(
icon = if (speakerEnabled) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff,
contentDescription = if (speakerEnabled) "Mute speaker" else "Unmute speaker",
onClick = onToggleSpeaker,
@@ -588,6 +580,34 @@ private fun VoiceHeader(
}
}
@Composable
private fun VoiceAvatar(text: String) {
Surface(
modifier = Modifier.size(34.dp),
shape = CircleShape,
color = ClawTheme.colors.surfaceRaised,
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Box(contentAlignment = Alignment.Center) {
Text(text = text.take(2).uppercase(), style = ClawTheme.type.label)
}
}
}
@Composable
private fun VoicePlainIconButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
contentDescription: String,
onClick: () -> Unit,
) {
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
}
}
}
@Composable
private fun VoiceHero(
gatewayStatus: String,
@@ -841,10 +861,8 @@ private fun VoiceOrb(
Surface(
modifier = Modifier.size(112.dp),
shape = CircleShape,
color = if (active || listening || speaking) Color(0xFF1976D2) else Color(0xFF123B63),
contentColor = Color.White,
tonalElevation = 3.dp,
shadowElevation = 7.dp,
color = if (active) ClawTheme.colors.surfacePressed else ClawTheme.colors.surface,
border = BorderStroke(1.dp, if (active) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
) {
Box(contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -857,7 +875,7 @@ private fun VoiceOrb(
},
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = Color.White,
tint = ClawTheme.colors.text,
)
Waveform(active = active)
}
@@ -874,7 +892,7 @@ private fun Waveform(active: Boolean) {
Modifier
.size(width = 2.dp, height = (if (active) height else 6 + index % 3 * 3).dp)
.clip(RoundedCornerShape(999.dp))
.background(if (active) Color.White else Color.White.copy(alpha = 0.52f)),
.background(if (active) ClawTheme.colors.text else ClawTheme.colors.textSubtle),
)
}
}

View File

@@ -1,7 +1,6 @@
package ai.openclaw.app.ui.chat
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.R
import ai.openclaw.app.chat.ChatMessage
import ai.openclaw.app.chat.ChatMessageContent
import ai.openclaw.app.chat.ChatPendingToolCall
@@ -40,7 +39,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Mic
@@ -65,7 +63,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -156,11 +153,12 @@ fun ChatScreen(
modifier =
Modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
.padding(horizontal = 18.dp, vertical = 6.dp),
verticalArrangement = Arrangement.spacedBy(5.dp),
) {
ChatHeader(
sessionTitle = currentSessionTitle(sessionKey = sessionKey, sessions = sessions),
thinkingLevel = thinkingLevel,
healthOk = healthOk,
pendingRunCount = pendingRunCount,
onMore = {
@@ -263,11 +261,11 @@ private fun ChatSessionSwitcher(
if (sessions.size > choices.size) {
Surface(
onClick = onOpenSessions,
modifier = Modifier.heightIn(min = ClawTheme.spacing.touchTarget),
modifier = Modifier.heightIn(min = 36.dp),
shape = RoundedCornerShape(ClawTheme.radii.pill),
color = ClawTheme.colors.surfaceRaised.copy(alpha = 0.72f),
color = ClawTheme.colors.canvas,
contentColor = ClawTheme.colors.textMuted,
border = BorderStroke(1.dp, ClawTheme.colors.border.copy(alpha = 0.7f)),
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
@@ -290,11 +288,11 @@ private fun ChatSessionChip(
) {
Surface(
onClick = onClick,
modifier = Modifier.heightIn(min = ClawTheme.spacing.touchTarget),
modifier = Modifier.heightIn(min = 36.dp),
shape = RoundedCornerShape(ClawTheme.radii.pill),
color = if (active) ClawTheme.colors.surfacePressed.copy(alpha = 0.9f) else ClawTheme.colors.surfaceRaised.copy(alpha = 0.72f),
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, if (active) ClawTheme.colors.borderStrong else ClawTheme.colors.border.copy(alpha = 0.7f)),
color = if (active) ClawTheme.colors.primary else ClawTheme.colors.surfaceRaised,
contentColor = if (active) ClawTheme.colors.primaryText else ClawTheme.colors.text,
border = BorderStroke(1.dp, if (active) ClawTheme.colors.primary else ClawTheme.colors.border),
) {
Text(
text = text,
@@ -309,56 +307,48 @@ private fun ChatSessionChip(
@Composable
private fun ChatHeader(
sessionTitle: String,
thinkingLevel: String,
healthOk: Boolean,
pendingRunCount: Int,
onMore: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(modifier = Modifier.size(ClawTheme.spacing.touchTarget))
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Icon(
painter = painterResource(id = R.drawable.openclaw_logo),
contentDescription = null,
modifier = Modifier.size(25.dp),
tint = ClawTheme.colors.text,
)
Text(
text = "OpenClaw",
style = ClawTheme.type.title.copy(fontSize = 17.sp, lineHeight = 21.sp),
text = sessionTitle,
style = ClawTheme.type.title.copy(fontSize = 18.sp, lineHeight = 23.sp),
color = ClawTheme.colors.text,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
)
ModelPill(
text =
when {
pendingRunCount > 0 -> "Working"
healthOk -> "Ready"
else -> "Offline"
healthOk -> "auto"
else -> "offline"
},
status =
when {
pendingRunCount > 0 -> ClawStatus.Warning
healthOk -> ClawStatus.Success
healthOk -> ClawStatus.Neutral
else -> ClawStatus.Danger
},
)
HeaderIcon(icon = Icons.Default.Refresh, contentDescription = "Refresh chat", onClick = onMore)
}
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "Chat", style = ClawTheme.type.display.copy(fontSize = 24.sp, lineHeight = 28.sp), color = ClawTheme.colors.text, maxLines = 1)
Text(
text = sessionTitle,
style = ClawTheme.type.caption.copy(fontSize = 13.sp, lineHeight = 17.sp),
color = ClawTheme.colors.textMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
HeaderIcon(icon = Icons.Default.Refresh, contentDescription = "Refresh chat", onClick = onMore)
}
}
@@ -375,13 +365,7 @@ private fun ModelPill(
}
Surface(
shape = RoundedCornerShape(ClawTheme.radii.pill),
color =
when (status) {
ClawStatus.Success -> ClawTheme.colors.successSoft
ClawStatus.Warning -> ClawTheme.colors.warningSoft
ClawStatus.Danger -> ClawTheme.colors.dangerSoft
ClawStatus.Neutral -> ClawTheme.colors.surfaceRaised
},
color = ClawTheme.colors.surfaceRaised,
contentColor = ClawTheme.colors.textMuted,
border = BorderStroke(1.dp, borderColor),
) {
@@ -593,15 +577,13 @@ private fun ChatBubble(
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
) {
Surface(
modifier = Modifier.fillMaxWidth(if (isUser) 0.84f else 0.94f),
modifier = Modifier.fillMaxWidth(if (isUser) 0.64f else 0.56f),
shape = RoundedCornerShape(7.dp),
color = if (isUser) ClawTheme.colors.surfacePressed.copy(alpha = 0.86f) else ClawTheme.colors.surfaceRaised.copy(alpha = 0.84f),
color = ClawTheme.colors.surfaceRaised,
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, if (live) ClawTheme.colors.borderStrong else ClawTheme.colors.border.copy(alpha = 0.45f)),
tonalElevation = 1.dp,
shadowElevation = 2.dp,
border = BorderStroke(1.dp, if (live) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
) {
Column(modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Column(modifier = Modifier.padding(horizontal = 7.dp, vertical = 3.5.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text =
when {
@@ -782,7 +764,7 @@ private fun ChatContextMeter(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null, modifier = Modifier.size(13.dp), tint = ClawTheme.colors.textSubtle)
Icon(imageVector = Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(12.dp), tint = ClawTheme.colors.textSubtle)
Text(
text = contextMeterLabel(contextUsage, thinkingLevel),
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
@@ -954,7 +936,7 @@ internal fun resolveChatContextUsage(
sessionKey = sessionKey,
mainSessionKey = mainSessionKey,
)
}
}
return ChatContextUsage(
totalTokens = entry?.totalTokens,
totalTokensFresh = entry?.totalTokensFresh,
@@ -991,6 +973,24 @@ private fun userFacingChatError(error: String): String {
}
}
/** Normalizes persisted thinking values into compact UI labels. */
private fun thinkingDisplay(value: String): String =
when (value.lowercase(Locale.US)) {
"low" -> "Low"
"medium" -> "Medium"
"high" -> "High"
else -> "Off"
}
/** Converts displayed thinking labels back to gateway request values. */
private fun thinkingValue(display: String): String =
when (display.lowercase(Locale.US)) {
"low" -> "low"
"medium" -> "medium"
"high" -> "high"
else -> "off"
}
/** Cycles through context budget presets from the compact composer control. */
private fun nextThinkingValue(value: String): String =
when (value.lowercase(Locale.US)) {

View File

@@ -185,53 +185,6 @@ internal fun ClawIconButton(
}
}
/** Transparent circular icon button for low-emphasis toolbar actions. */
@Composable
internal fun ClawPlainIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
modifier = Modifier.size(ClawTheme.spacing.touchTarget),
shape = CircleShape,
color = Color.Transparent,
contentColor = ClawTheme.colors.text,
) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
}
}
}
/** Compact label/value row for health and readiness summaries. */
@Composable
internal fun ClawStatusRow(
title: String,
value: String,
healthy: Boolean,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Text(
text = title,
style = ClawTheme.type.body,
color = ClawTheme.colors.text,
modifier = Modifier.weight(1f),
maxLines = 1,
)
ClawStatusPill(
text = value,
status = if (healthy) ClawStatus.Success else ClawStatus.Warning,
)
}
}
/** Compact status chip with a semantic color dot. */
@Composable
internal fun ClawStatusPill(

View File

@@ -95,17 +95,15 @@ internal fun ClawBottomNav(
Box(modifier = modifier.fillMaxWidth().background(ClawTheme.colors.canvas)) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = ClawTheme.colors.surface.copy(alpha = 0.92f),
border = BorderStroke(1.dp, ClawTheme.colors.border.copy(alpha = 0.42f)),
color = ClawTheme.colors.surface.copy(alpha = 0.96f),
border = BorderStroke(1.dp, ClawTheme.colors.border),
shape = RoundedCornerShape(topStart = ClawTheme.radii.sheet, topEnd = ClawTheme.radii.sheet),
tonalElevation = 2.dp,
shadowElevation = 8.dp,
) {
Row(
modifier =
Modifier
.windowInsetsPadding(safeInsets)
.padding(horizontal = 8.dp, vertical = 6.dp),
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
@@ -133,13 +131,13 @@ private fun ClawBottomNavItem(
onClick = onClick,
modifier = modifier.heightIn(min = 48.dp),
shape = RoundedCornerShape(ClawTheme.radii.control),
color = if (selected) ClawTheme.colors.surfacePressed.copy(alpha = 0.72f) else Color.Transparent,
contentColor = if (selected) ClawTheme.colors.text else ClawTheme.colors.textMuted,
color = if (selected) ClawTheme.colors.primary else Color.Transparent,
contentColor = if (selected) ClawTheme.colors.primaryText else ClawTheme.colors.textMuted,
) {
Column(
modifier = Modifier.padding(horizontal = 5.dp, vertical = 5.dp),
modifier = Modifier.padding(horizontal = 5.dp, vertical = 6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Icon(imageVector = item.icon, contentDescription = item.label, modifier = Modifier.size(18.dp))
Text(text = item.label, style = ClawTheme.type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)

View File

@@ -27,11 +27,31 @@ internal fun ClawPanel(
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(ClawTheme.radii.panel),
color = ClawTheme.colors.surfaceRaised.copy(alpha = 0.82f),
color = ClawTheme.colors.surfaceRaised,
contentColor = ClawTheme.colors.text,
border = null,
tonalElevation = 2.dp,
shadowElevation = 4.dp,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Column(modifier = Modifier.padding(contentPadding)) {
content()
}
}
}
/**
* Bottom-sheet container with the app surface treatment and top-only rounding.
*/
@Composable
internal fun ClawSheetSurface(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(18.dp),
content: @Composable () -> Unit,
) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(topStart = ClawTheme.radii.sheet, topEnd = ClawTheme.radii.sheet),
color = ClawTheme.colors.surface,
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Column(modifier = Modifier.padding(contentPadding)) {
content()

View File

@@ -4,6 +4,7 @@ import ai.openclaw.app.ui.LocalMobileColors
import ai.openclaw.app.ui.darkMobileColors
import ai.openclaw.app.ui.lightMobileColors
import ai.openclaw.app.ui.mobileFontFamily
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.Typography
@@ -189,6 +190,12 @@ internal fun ClawDesignTheme(
}
}
/**
* Returns the system dark-mode preference for callers that expose theme selection.
*/
@Composable
internal fun rememberClawDarkPreference(): Boolean = isSystemInDarkTheme()
private fun clawTypography(fontFamily: FontFamily) =
ClawTypography(
display =

View File

@@ -111,6 +111,7 @@ class TalkModeManager internal constructor(
private const val tag = "TalkMode"
private const val realtimeSampleRateHz = 24_000
private const val realtimeAudioFrameMs = 100
private const val listenWatchdogMs = 12_000L
private const val chatFinalWaitMs = 45_000L
private const val maxCachedRunCompletions = 128
private const val maxConversationEntries = 40

View File

@@ -3,11 +3,13 @@
<uses-permission
android:name="android.permission.READ_MEDIA_IMAGES"
tools:node="remove" />
<uses-permission
android:name="android.permission.READ_MEDIA_VIDEO"
tools:node="remove" />
<uses-permission
android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"
tools:node="remove" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32"
tools:node="remove" />
</manifest>

View File

@@ -33,44 +33,44 @@ class GatewayBootstrapAuthTest {
@Test
fun doesNotConnectOperatorSessionWhenOnlyBootstrapAuthExists() {
assertFalse(
resolveOperatorSessionConnectAuth(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = "", bootstrapToken = "bootstrap-1", password = ""),
storedOperatorToken = "",
) != null,
),
)
assertFalse(
resolveOperatorSessionConnectAuth(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
storedOperatorToken = null,
) != null,
),
)
}
@Test
fun connectsOperatorSessionWhenSharedPasswordOrStoredAuthExists() {
assertTrue(
resolveOperatorSessionConnectAuth(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = "bootstrap-1", password = null),
storedOperatorToken = null,
) != null,
),
)
assertTrue(
resolveOperatorSessionConnectAuth(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = "shared-password"),
storedOperatorToken = null,
) != null,
),
)
assertTrue(
resolveOperatorSessionConnectAuth(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
storedOperatorToken = "stored-token",
) != null,
),
)
assertTrue(
resolveOperatorSessionConnectAuth(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "", password = null),
storedOperatorToken = null,
) != null,
),
)
}

View File

@@ -1,101 +0,0 @@
package ai.openclaw.app
import ai.openclaw.app.node.asObjectOrNull
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class GatewayExecApprovalParsingTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun parsesGatewayExecApprovalListPayload() {
val rows =
parseGatewayExecApprovalListPayload(
"""
[
{
"id": "approval-2",
"createdAtMs": 20,
"expiresAtMs": 120,
"request": {
"host": "node",
"nodeId": "node-1",
"agentId": "agent-1",
"command": "Sanitized command",
"commandPreview": "Sanitized preview",
"systemRunPlan": {
"commandText": "/bin/sh -lc 'echo secret'",
"commandPreview": "echo secret"
},
"allowedDecisions": ["allow-once", "deny"]
}
},
{
"id": "approval-1",
"createdAtMs": 10,
"expiresAtMs": 110,
"request": {
"host": "gateway",
"command": "pnpm test --token secret",
"commandPreview": "pnpm test",
"unavailableDecisions": ["allow-always"]
}
}
]
""".trimIndent(),
json,
)
assertEquals(listOf("approval-1", "approval-2"), rows.map { it.id })
assertEquals("pnpm test --token secret", rows[0].commandText)
assertEquals("pnpm test", rows[0].commandPreview)
assertEquals(emptyList<String>(), rows[0].allowedDecisions)
assertEquals("Sanitized command", rows[1].commandText)
assertEquals("Sanitized preview", rows[1].commandPreview)
assertEquals("node-1", rows[1].nodeId)
assertEquals("agent-1", rows[1].agentId)
}
@Test
fun parsesGatewayExecApprovalGetPayload() {
val root =
json
.parseToJsonElement(
"""
{
"id": "approval-1",
"commandText": "rm -rf build",
"commandPreview": "rm build",
"allowedDecisions": ["allow-once", "allow-always", "deny"],
"host": "gateway",
"nodeId": null,
"agentId": "agent-main",
"expiresAtMs": 200
}
""".trimIndent(),
).asObjectOrNull()
requireNotNull(root)
val row = parseGatewayExecApprovalDetail(root, createdAtMs = 100)
requireNotNull(row)
assertEquals("approval-1", row.id)
assertEquals("rm -rf build", row.commandText)
assertEquals("rm build", row.commandPreview)
assertEquals(listOf("allow-once", "allow-always", "deny"), row.allowedDecisions)
assertEquals("gateway", row.host)
assertNull(row.nodeId)
assertEquals("agent-main", row.agentId)
assertEquals(100L, row.createdAtMs)
assertEquals(200L, row.expiresAtMs)
}
@Test
fun ignoresMalformedGatewayExecApprovalListPayload() {
assertTrue(parseGatewayExecApprovalListPayload("""{"approvals":[]}""", json).isEmpty())
assertTrue(parseGatewayExecApprovalListPayload("not json", json).isEmpty())
}
}

View File

@@ -1,46 +0,0 @@
package ai.openclaw.app
import org.junit.Assert.assertEquals
import org.junit.Test
class GatewayLogTextTest {
@Test
fun sanitizeGatewayLogTextRemovesAnsiSgrSequences() {
assertEquals(
"hindsight: Skipping retain",
sanitizeGatewayLogText("\u001B[38;5;103mhindsight:\u001B[0m Skipping retain"),
)
}
@Test
fun sanitizeGatewayLogTextRemovesVisibleSgrFragments() {
assertEquals(
"hindsight: Skipping retain",
sanitizeGatewayLogText("[38;5;103mhindsight:[0m Skipping retain"),
)
}
@Test
fun sanitizeGatewayLogTextRemovesSingleParameterVisibleSgrFragments() {
assertEquals(
"error and bold",
sanitizeGatewayLogText("[31merror[0m and [1mbold[0m"),
)
}
@Test
fun sanitizeGatewayLogTextRemovesJsonEscapedAnsiSgrSequences() {
assertEquals(
"""{"1":"hindsight: Skipping retain"}""",
sanitizeGatewayLogText("""{"1":"\u001b[38;5;103mhindsight:\u001b[0m Skipping retain"}"""),
)
}
@Test
fun sanitizeGatewayLogTextKeepsPlainBracketedText() {
assertEquals(
"cache ttl [5m] expired",
sanitizeGatewayLogText("cache ttl [5m] expired"),
)
}
}

View File

@@ -204,18 +204,17 @@ class GatewayConfigResolverTest {
}
@Test
fun resolveScannedSetupCodeResultAcceptsRawSetupCode() {
fun resolveScannedSetupCodeAcceptsRawSetupCode() {
val setupCode =
encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
val resolved = resolveScannedSetupCodeResult(setupCode)
val resolved = resolveScannedSetupCode(setupCode)
assertEquals(setupCode, resolved.setupCode)
assertNull(resolved.error)
assertEquals(setupCode, resolved)
}
@Test
fun resolveScannedSetupCodeResultAcceptsQrJsonPayload() {
fun resolveScannedSetupCodeAcceptsQrJsonPayload() {
val setupCode =
encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
val qrJson =
@@ -228,55 +227,49 @@ class GatewayConfigResolverTest {
}
""".trimIndent()
val resolved = resolveScannedSetupCodeResult(qrJson)
val resolved = resolveScannedSetupCode(qrJson)
assertEquals(setupCode, resolved.setupCode)
assertNull(resolved.error)
assertEquals(setupCode, resolved)
}
@Test
fun resolveScannedSetupCodeResultRejectsInvalidInput() {
val resolved = resolveScannedSetupCodeResult("not-a-valid-setup-code")
assertNull(resolved.setupCode)
assertEquals(GatewayEndpointValidationError.INVALID_URL, resolved.error)
fun resolveScannedSetupCodeRejectsInvalidInput() {
val resolved = resolveScannedSetupCode("not-a-valid-setup-code")
assertNull(resolved)
}
@Test
fun resolveScannedSetupCodeResultRejectsJsonWithInvalidSetupCode() {
fun resolveScannedSetupCodeRejectsJsonWithInvalidSetupCode() {
val qrJson = """{"setupCode":"invalid"}"""
val resolved = resolveScannedSetupCodeResult(qrJson)
assertNull(resolved.setupCode)
assertEquals(GatewayEndpointValidationError.INVALID_URL, resolved.error)
val resolved = resolveScannedSetupCode(qrJson)
assertNull(resolved)
}
@Test
fun resolveScannedSetupCodeResultRejectsJsonWithNonStringSetupCode() {
fun resolveScannedSetupCodeRejectsJsonWithNonStringSetupCode() {
val qrJson = """{"setupCode":{"nested":"value"}}"""
val resolved = resolveScannedSetupCodeResult(qrJson)
assertNull(resolved.setupCode)
assertEquals(GatewayEndpointValidationError.INVALID_URL, resolved.error)
val resolved = resolveScannedSetupCode(qrJson)
assertNull(resolved)
}
@Test
fun resolveScannedSetupCodeResultRejectsNonLoopbackCleartextGateway() {
fun resolveScannedSetupCodeRejectsNonLoopbackCleartextGateway() {
val setupCode =
encodeSetupCode("""{"url":"ws://attacker.example:18789","bootstrapToken":"bootstrap-1"}""")
val resolved = resolveScannedSetupCodeResult(setupCode)
val resolved = resolveScannedSetupCode(setupCode)
assertNull(resolved.setupCode)
assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, resolved.error)
assertNull(resolved)
}
@Test
fun resolveScannedSetupCodeResultAcceptsPrivateLanCleartextGateway() {
fun resolveScannedSetupCodeAcceptsPrivateLanCleartextGateway() {
val setupCode =
encodeSetupCode("""{"url":"ws://192.168.31.100:18789","bootstrapToken":"bootstrap-1"}""")
val resolved = resolveScannedSetupCodeResult(setupCode)
val resolved = resolveScannedSetupCode(setupCode)
assertEquals(setupCode, resolved.setupCode)
assertNull(resolved.error)
assertEquals(setupCode, resolved)
}
@Test

View File

@@ -1,14 +1,12 @@
package ai.openclaw.app.ui
import ai.openclaw.app.AppearanceThemeMode
import ai.openclaw.app.GatewayAgentSummary
import ai.openclaw.app.GatewayChannelSummary
import ai.openclaw.app.GatewayChannelsSummary
import ai.openclaw.app.GatewayNodeApprovalState
import ai.openclaw.app.GatewayNodeSummary
import ai.openclaw.app.GatewayNodesDevicesSummary
import ai.openclaw.app.GatewayPendingDeviceSummary
import ai.openclaw.app.ui.design.ClawStatus
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import org.junit.Assert.assertEquals
@@ -107,7 +105,7 @@ class ShellScreenLogicTest {
assertEquals(listOf("Approvals", "Channels", "Nodes & Devices", "Providers"), rows.map { it.title })
val providersRow = rows.single { it.title == "Providers" }
assertEquals(Tab.Settings, providersRow.tab)
assertEquals(SettingsRoute.ProvidersModels, providersRow.settingsRoute)
assertEquals(SettingsRoute.Gateway, providersRow.settingsRoute)
}
@Test
@@ -159,206 +157,10 @@ class ShellScreenLogicTest {
assertEquals("Node approval pending", rows.single().subtitle)
}
@Test
fun overviewHeaderStateReflectsGatewayConnectionAndAttention() {
assertEquals(OverviewHeaderState("Offline", ClawStatus.Neutral), overviewHeaderState(isConnected = false, hasAttention = true))
assertEquals(OverviewHeaderState("Needs attention", ClawStatus.Warning), overviewHeaderState(isConnected = true, hasAttention = true))
assertEquals(OverviewHeaderState("Online", ClawStatus.Success), overviewHeaderState(isConnected = true, hasAttention = false))
}
@Test
fun overviewHeaderRouteUsesFirstAttentionDestination() {
assertEquals(SettingsRoute.Gateway, overviewHeaderRoute(emptyList()))
assertEquals(
SettingsRoute.Approvals,
overviewHeaderRoute(
listOf(
HomeAttentionRow("Approvals", "2 pending", Icons.Default.Settings, Tab.Settings, SettingsRoute.Approvals),
HomeAttentionRow("Nodes & Devices", "Review node access", Icons.Default.Settings, Tab.Settings, SettingsRoute.NodesDevices),
),
),
)
}
@Test
fun overviewMetricCardsUseRealGatewayNodeApprovalAndSessionCounts() {
val cards =
overviewMetricCardSpecs(
isConnected = true,
hasAttention = true,
nodesDevicesSummary =
GatewayNodesDevicesSummary(
nodes =
listOf(
GatewayNodeSummary(
id = "android-node",
displayName = "Android",
remoteIp = null,
version = null,
deviceFamily = "Android",
paired = true,
connected = true,
approvalState = GatewayNodeApprovalState.PendingReapproval,
pendingRequestId = "node-request",
capabilities = emptyList(),
commands = emptyList(),
),
),
pendingDevices = emptyList(),
pairedDevices = emptyList(),
),
pendingApprovals = 2,
sessionCount = 4,
)
assertEquals(listOf("Gateway", "Nodes", "Approvals", "Sessions"), cards.map { it.title })
assertEquals("Online", cards.single { it.title == "Gateway" }.value)
assertEquals("Review highlighted items", cards.single { it.title == "Gateway" }.subtitle)
assertEquals("1/1", cards.single { it.title == "Nodes" }.value)
assertEquals("Review node access", cards.single { it.title == "Nodes" }.subtitle)
assertEquals(ClawStatus.Warning, cards.single { it.title == "Nodes" }.status)
assertEquals(1f, cards.single { it.title == "Nodes" }.progressFraction ?: 0f, 0.001f)
assertEquals("2", cards.single { it.title == "Approvals" }.value)
assertEquals("4", cards.single { it.title == "Sessions" }.value)
}
@Test
fun overviewNodeCardShowsRoundedOnlinePercentWhenNoNodeApprovalIsPending() {
val cards =
overviewMetricCardSpecs(
isConnected = true,
hasAttention = false,
nodesDevicesSummary =
GatewayNodesDevicesSummary(
nodes =
(1..3).map { index ->
GatewayNodeSummary(
id = "node-$index",
displayName = "Node $index",
remoteIp = null,
version = null,
deviceFamily = null,
paired = true,
connected = index <= 2,
approvalState = GatewayNodeApprovalState.Approved,
pendingRequestId = null,
capabilities = emptyList(),
commands = emptyList(),
)
},
pendingDevices = emptyList(),
pairedDevices = emptyList(),
),
pendingApprovals = 0,
sessionCount = 0,
)
val nodes = cards.single { it.title == "Nodes" }
assertEquals("2/3", nodes.value)
assertEquals("67% online", nodes.subtitle)
assertEquals(2f / 3f, nodes.progressFraction ?: 0f, 0.001f)
}
@Test
fun overviewGatewayCardOnlyClaimsNominalWhenNoAttentionExists() {
val cards =
overviewMetricCardSpecs(
isConnected = true,
hasAttention = false,
nodesDevicesSummary = emptyNodesDevices(),
pendingApprovals = 0,
sessionCount = 0,
)
val gateway = cards.single { it.title == "Gateway" }
assertEquals("Healthy", gateway.value)
assertEquals("All systems nominal", gateway.subtitle)
assertEquals(ClawStatus.Success, gateway.status)
}
@Test
fun overviewAgentNameUsesDefaultAgentWhenPresent() {
val agents =
listOf(
GatewayAgentSummary(id = "main", name = "Main", emoji = null),
GatewayAgentSummary(id = "scout", name = "Scout", emoji = "🦾"),
)
assertEquals("Scout", overviewAgentName(agents = agents, defaultAgentId = "scout"))
assertEquals("Main", overviewAgentName(agents = agents, defaultAgentId = null))
assertEquals("OpenClaw", overviewAgentName(agents = emptyList(), defaultAgentId = null))
}
@Test
fun overviewAgentBadgeUsesEmojiBeforeInitials() {
val agents =
listOf(
GatewayAgentSummary(id = "main", name = "Main Agent", emoji = null),
GatewayAgentSummary(id = "scout", name = "Scout", emoji = "🦾"),
)
assertEquals("🦾", overviewAgentBadgeText(agents = agents, defaultAgentId = "scout"))
assertEquals("MA", overviewAgentBadgeText(agents = agents, defaultAgentId = "main"))
assertEquals("OC", overviewAgentBadgeText(agents = emptyList(), defaultAgentId = null))
}
@Test
fun overviewAgentActivityTextUsesRealRuntimeCounts() {
assertEquals(
"Working · 2 active runs",
overviewAgentActivityText(isConnected = true, pendingRunCount = 2, sessionCount = 50, cronJobCount = 19, statusText = "Online and ready"),
)
assertEquals(
"Monitoring · 50 sessions",
overviewAgentActivityText(isConnected = true, pendingRunCount = 0, sessionCount = 50, cronJobCount = 19, statusText = "Online and ready"),
)
assertEquals(
"Gateway offline",
overviewAgentActivityText(isConnected = false, pendingRunCount = 0, sessionCount = 50, cronJobCount = 19, statusText = "Gateway offline"),
)
}
@Test
fun sessionSourceLabelDerivesCompactSourceFromRealSessionKey() {
assertEquals("Telegram", sessionSourceLabel("telegram:8227096397"))
assertEquals("Discord", sessionSourceLabel("discord:1465779285020381361#daily-inf"))
assertEquals("Cron", sessionSourceLabel("Cron: nightly-reflection"))
assertEquals("Telegram", sessionSourceLabel("agent:main:telegram:direct:584667058"))
assertEquals("Discord", sessionSourceLabel("agent:main:discord:channel:1001"))
assertEquals("Slack", sessionSourceLabel("agent:main:slack:channel:C123"))
assertEquals("OpenClaw", sessionSourceLabel("agent:main:node-android"))
assertEquals("OpenClaw", sessionSourceLabel("agent:main:main"))
assertEquals("OpenClaw", sessionSourceLabel("Daily standup"))
}
@Test
fun sessionSourceLabelUsesGatewayChannelLabelsForFutureSources() {
val channels =
GatewayChannelsSummary(
channels =
listOf(
GatewayChannelSummary(
id = "matrix",
label = "Matrix",
accountCount = 1,
enabled = true,
configured = true,
linked = true,
running = true,
connected = true,
error = null,
),
),
)
assertEquals("Matrix", sessionSourceLabel("agent:main:matrix:room:abc", channels))
}
@Test
fun settingsSectionTitlesGroupPowerSettingsByMeaning() {
assertEquals("Connection", settingsSectionTitleForRoute(SettingsRoute.Gateway))
assertEquals("Connection", settingsSectionTitleForRoute(SettingsRoute.NodesDevices))
assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.ProvidersModels))
assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.Approvals))
assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.CronJobs))
assertEquals("Phone context & privacy", settingsSectionTitleForRoute(SettingsRoute.PhoneCapabilities))

View File

@@ -164,7 +164,7 @@ run_mode() {
no_connect_flag=false
fi
adb shell run-as "$PACKAGE_NAME" am broadcast --user 0 \
adb shell am broadcast \
-a "$RUN_ACTION" \
-n "$RECEIVER" \
--es mode "$test_mode" \
@@ -224,7 +224,7 @@ adb logcat -d -v time |
tail -250 >"$ARTIFACT_DIR/logcat.txt" || true
if [[ "$CLEANUP" -eq 1 ]]; then
adb shell run-as "$PACKAGE_NAME" am broadcast --user 0 -a "$RUN_ACTION" -n "$RECEIVER" --es mode stop >/dev/null
adb shell am broadcast -a "$RUN_ACTION" -n "$RECEIVER" --es mode stop >/dev/null
fi
echo "$ARTIFACT_DIR"

View File

@@ -2,24 +2,8 @@ parent_config: ../../config/swiftlint.yml
included:
- Sources
- ShareExtension
- ActivityWidget
- WatchApp
- ../shared/OpenClawKit/Sources/OpenClawChatUI
excluded:
- ../macos
- ../shared/ClawdisNodeKit/Sources
type_body_length:
warning: 900
error: 1300
custom_rules:
openclaw_design_colors:
name: "OpenClaw design colors"
excluded:
- Sources/Design/OpenClawBrand.swift
- ../shared/OpenClawKit/Sources/OpenClawChatUI/ChatTheme.swift
regex: '(Color\.accentColor|(^|[^A-Za-z0-9_])\.accentColor\b|Color\.(red|green|orange|cyan|blue|yellow|purple|pink)\b|\.(foregroundStyle|tint|fill|stroke|strokeBorder|background)\(\s*\.(red|green|orange|cyan|blue|yellow|purple|pink)\b|Color\(red:\s*0\s*/\s*255\.0,\s*green:\s*122\s*/\s*255\.0,\s*blue:\s*255\s*/\s*255\.0\))'
message: "Use OpenClawBrand or OpenClawChatTheme design tokens instead of raw accent/status colors."
severity: error

View File

@@ -74,30 +74,23 @@ struct OpenClawLiveActivity: Widget {
private func statusIcon(state: OpenClawActivityAttributes.ContentState) -> some View {
if state.isConnecting {
Image(systemName: "arrow.triangle.2.circlepath")
.foregroundStyle(OpenClawActivityStyle.info)
.foregroundStyle(.cyan)
} else if state.isDisconnected {
Image(systemName: "wifi.slash")
.foregroundStyle(OpenClawActivityStyle.danger)
.foregroundStyle(.red)
} else if state.isIdle {
Image(systemName: "checkmark")
.foregroundStyle(OpenClawActivityStyle.ok)
.foregroundStyle(.green)
} else {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(OpenClawActivityStyle.warn)
.foregroundStyle(.orange)
}
}
private func dotColor(state: OpenClawActivityAttributes.ContentState) -> Color {
if state.isDisconnected { return OpenClawActivityStyle.danger }
if state.isConnecting { return OpenClawActivityStyle.info }
if state.isIdle { return OpenClawActivityStyle.ok }
return OpenClawActivityStyle.warn
if state.isDisconnected { return .red }
if state.isConnecting { return .cyan }
if state.isIdle { return .green }
return .orange
}
}
private enum OpenClawActivityStyle {
static let info = Color(red: 0, green: 122 / 255.0, blue: 1)
static let danger = Color(red: 185 / 255.0, green: 28 / 255.0, blue: 28 / 255.0)
static let ok = Color(red: 34 / 255.0, green: 197 / 255.0, blue: 94 / 255.0)
static let warn = Color(red: 245 / 255.0, green: 158 / 255.0, blue: 11 / 255.0)
}

View File

@@ -12,7 +12,7 @@
"platform": "IOS",
"profileKey": "OPENCLAW_APP_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app",
"capabilities": ["PUSH_NOTIFICATIONS", "APP_GROUPS", "APP_ATTEST"],
"capabilities": ["PUSH_NOTIFICATIONS", "APP_GROUPS"],
"appGroups": ["group.ai.openclawfoundation.app.shared"]
},
{

View File

@@ -6,7 +6,6 @@ OPENCLAW_IOS_SELECTED_TEAM = $(OPENCLAW_IOS_DEFAULT_TEAM)
OPENCLAW_DEVELOPMENT_TEAM = $(OPENCLAW_IOS_SELECTED_TEAM)
OPENCLAW_CODE_SIGN_STYLE = Automatic
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
OPENCLAW_CODE_SIGN_ENTITLEMENTS = Sources/OpenClaw.entitlements
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
OPENCLAW_APP_GROUP_ID = group.ai.openclawfoundation.app.shared
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp

View File

@@ -67,7 +67,7 @@ Release behavior:
- 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`.
- Fastlane owns one-time Developer Portal setup, encrypted `match` signing sync to the repo/branch pinned in `apps/ios/Config/AppStoreSigning.json`, and release handling.
- App Store release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, `OpenClawPushAPNsEnvironment=production`, `OpenClawPushRelayProfile=production`, `OpenClawPushProofPolicy=appleStrict`, and the App-Attest-capable entitlement file.
- 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.
@@ -102,7 +102,6 @@ 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 `MATCH_PASSWORD`.
- The share sheet requires the Apple Developer App Group in `apps/ios/Config/AppStoreSigning.json` to be associated with both the app and share-extension bundle IDs before App Store profiles are regenerated.
- Relay registration requires the App Attest capability on the main app ID before App Store profiles are regenerated.
- 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 refreshing Fastlane `match` assets and pushing a fresh encrypted sync state.
@@ -158,7 +157,7 @@ This should create `apps/ios/fastlane/.env` with non-secret App Store Connect va
- `ai.openclawfoundation.app.activitywidget`
- `ai.openclawfoundation.app.watchkitapp`
The main app and share extension must both be associated with the App Group pinned in `apps/ios/Config/AppStoreSigning.json`. The main app must also have App Attest enabled.
The main app and share extension must both be associated with the App Group pinned in `apps/ios/Config/AppStoreSigning.json`.
Use `pnpm ios:release:signing:setup` for the initial portal setup, then `MATCH_PASSWORD=... pnpm ios:release:signing:sync:push` to publish encrypted Fastlane match assets to the shared private repo.
@@ -244,7 +243,6 @@ See `apps/ios/VERSIONING.md` for the detailed spec.
- The app calls `registerForRemoteNotifications()` at launch.
- `apps/ios/Sources/OpenClaw.entitlements` derives `aps-environment` from the active build configuration/signing override.
- App Attest relay builds use `apps/ios/Sources/OpenClawAppAttest.entitlements`; local/direct builds do not require App Attest provisioning.
- APNs token registration to gateway happens only after gateway connection (`push.apns.register`).
- 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.
@@ -261,7 +259,7 @@ See `apps/ios/VERSIONING.md` for the detailed spec.
- The relay registration is bound to the gateway identity fetched from `gateway.identity.get`, so another gateway cannot reuse that stored registration.
- The app persists the relay handle metadata locally so reconnects can republish the gateway registration without re-registering on every connect.
- If the relay base URL changes in a later build, the app refreshes the relay registration instead of reusing the old relay origin.
- Production relay mode uses the `production` relay profile, production APNs, App Attest, and a StoreKit app transaction JWS during registration.
- Relay mode requires a reachable relay base URL and uses App Attest plus a StoreKit app transaction JWS during registration.
- Gateway-side relay sending is configured through `gateway.push.apns.relay.baseUrl` in `openclaw.json`. `OPENCLAW_APNS_RELAY_BASE_URL` remains a temporary env override only.
## Official Build Relay Trust Model

View File

@@ -6,7 +6,6 @@
OPENCLAW_CODE_SIGN_STYLE = Manual
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
OPENCLAW_CODE_SIGN_ENTITLEMENTS = Sources/OpenClaw.entitlements
OPENCLAW_DEVELOPMENT_TEAM = FWJYW4S8P8
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app

View File

@@ -506,7 +506,7 @@ extension AgentProTab {
func skillEditorSwitchIndicator(isOn: Bool) -> some View {
Capsule()
.fill(isOn ? OpenClawBrand.accent : Color.secondary.opacity(0.35))
.fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35))
.frame(width: 52, height: 32)
.overlay(alignment: isOn ? .trailing : .leading) {
Circle()

View File

@@ -105,7 +105,7 @@ struct AgentProTab: View {
var color: Color {
switch self {
case .online: OpenClawBrand.ok
case .ready: OpenClawBrand.info
case .ready: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0)
}
}
}

View File

@@ -277,7 +277,7 @@ struct ChatProTab: View {
}
private var chatUserAccent: Color {
self.colorScheme == .light ? OpenClawBrand.info : OpenClawBrand.accent
self.colorScheme == .light ? Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0) : OpenClawBrand.accent
}
private var activeAgent: AgentSummary? {

View File

@@ -1036,7 +1036,7 @@ struct IPadSkillProposalRow: View {
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
self.isSelected ? OpenClawBrand.danger.opacity(0.08) : Color.clear,
self.isSelected ? Color.red.opacity(0.08) : Color.clear,
in: RoundedRectangle(cornerRadius: 8, style: .continuous))
}
}

View File

@@ -47,13 +47,11 @@ enum AppAppearancePreference: String, CaseIterable, Identifiable {
}
enum OpenClawBrand {
static let uiAccent = UIColor { traits in
static let accent = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 198 / 255.0, green: 62 / 255.0, blue: 56 / 255.0, alpha: 1)
: UIColor(red: 183 / 255.0, green: 56 / 255.0, blue: 51 / 255.0, alpha: 1)
}
static let accent = Color(uiColor: Self.uiAccent)
})
static let accentHot = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 232 / 255.0, green: 92 / 255.0, blue: 86 / 255.0, alpha: 1)
@@ -66,7 +64,6 @@ enum OpenClawBrand {
})
static let ok = Color(red: 34 / 255.0, green: 197 / 255.0, blue: 94 / 255.0)
static let warn = Color(red: 245 / 255.0, green: 158 / 255.0, blue: 11 / 255.0)
static let info = Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0)
static let graphite = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 20 / 255.0, green: 22 / 255.0, blue: 24 / 255.0, alpha: 1)

View File

@@ -819,11 +819,8 @@ extension SettingsProTab {
var notificationRelayDetail: String {
if PushBuildConfig.current.usesOpenClawHostedRelay {
let host = PushBuildConfig.current.relayBaseURL.flatMap {
URLComponents(url: $0, resolvingAgainstBaseURL: false)?.host
} ?? "ios-push-relay.openclaw.ai"
return """
This build uses OpenClaw's hosted push relay at \(host) for notification \
This build uses OpenClaw's hosted push relay at ios-push-relay.openclaw.ai for notification \
delivery data.
"""
}

View File

@@ -119,7 +119,7 @@ extension SettingsProTab {
self.gatewayActionButton(
title: "Diagnose",
icon: "cross.case",
color: OpenClawBrand.info,
color: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0),
isBusy: self.isRefreshingGateway)
{
Task { await self.runDiagnostics() }
@@ -476,7 +476,7 @@ extension SettingsProTab {
self.gatewayActionButton(
title: "Run Diagnostics",
icon: "cross.case",
color: OpenClawBrand.info,
color: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0),
isBusy: self.isRefreshingGateway)
{
Task { await self.runDiagnostics() }
@@ -1040,7 +1040,7 @@ extension SettingsProTab {
func settingsSwitchIndicator(isOn: Bool) -> some View {
Capsule()
.fill(isOn ? OpenClawBrand.accent : Color.secondary.opacity(0.35))
.fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35))
.frame(width: 52, height: 32)
.overlay(alignment: isOn ? .trailing : .leading) {
Circle()

View File

@@ -90,7 +90,7 @@ private struct ExecApprovalPromptCard: View {
if let errorText = self.normalized(self.errorText) {
Text(errorText)
.font(.footnote)
.foregroundStyle(OpenClawBrand.danger)
.foregroundStyle(.red)
}
if self.isResolving {

View File

@@ -86,12 +86,8 @@
<string>$(OPENCLAW_PUSH_APNS_ENVIRONMENT)</string>
<key>OpenClawPushDistribution</key>
<string>$(OPENCLAW_PUSH_DISTRIBUTION)</string>
<key>OpenClawPushProofPolicy</key>
<string>$(OPENCLAW_PUSH_PROOF_POLICY)</string>
<key>OpenClawPushRelayBaseURL</key>
<string>$(OPENCLAW_PUSH_RELAY_BASE_URL)</string>
<key>OpenClawPushRelayProfile</key>
<string>$(OPENCLAW_PUSH_RELAY_PROFILE)</string>
<key>OpenClawPushTransport</key>
<string>$(OPENCLAW_PUSH_TRANSPORT)</string>
<key>UIApplicationSceneManifest</key>

View File

@@ -40,7 +40,7 @@ struct OnboardingIntroStep: View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.title3.weight(.semibold))
.foregroundStyle(OpenClawBrand.warn)
.foregroundStyle(.orange)
.frame(width: 24)
.padding(.top, 2)
@@ -177,7 +177,7 @@ struct OnboardingModeRow: View {
}
Spacer()
Image(systemName: self.selected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(self.selected ? OpenClawBrand.accent : Color.secondary)
.foregroundStyle(self.selected ? Color.accentColor : Color.secondary)
}
.contentShape(Rectangle())
}

View File

@@ -378,7 +378,7 @@ struct OnboardingWizardView: View {
private func onboardingSwitchIndicator(isOn: Bool) -> some View {
Capsule()
.fill(isOn ? OpenClawBrand.accent : Color.secondary.opacity(0.35))
.fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35))
.frame(width: 52, height: 32)
.overlay(alignment: isOn ? .trailing : .leading) {
Circle()
@@ -575,7 +575,7 @@ struct OnboardingWizardView: View {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 64))
.foregroundStyle(OpenClawBrand.ok)
.foregroundStyle(.green)
.padding(.bottom, 20)
Text("Connected")

View File

@@ -632,7 +632,6 @@ struct OpenClawApp: App {
var body: some Scene {
WindowGroup {
RootTabs()
.tint(OpenClawBrand.accent)
.preferredColorScheme(self.appearancePreference.colorScheme)
.environment(self.appModel)
.environment(self.appModel.voiceWake)
@@ -687,7 +686,6 @@ struct OpenClawApp: App {
.flatMap(\.windows)
.forEach { window in
window.overrideUserInterfaceStyle = style
window.tintColor = OpenClawBrand.uiAccent
}
}
}

View File

@@ -1,14 +0,0 @@
<?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>aps-environment</key>
<string>$(OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT)</string>
<key>com.apple.developer.devicecheck.appattest-environment</key>
<string>$(OPENCLAW_APP_ATTEST_ENVIRONMENT)</string>
<key>com.apple.security.application-groups</key>
<array>
<string>$(OPENCLAW_APP_GROUP_ID)</string>
</array>
</dict>
</plist>

View File

@@ -15,29 +15,14 @@ enum PushAPNsEnvironment: String {
case production
}
enum PushRelayProfile: String {
case production
case deviceSandbox
case simulatorSandbox
}
enum PushProofPolicy: String {
case appleStrict
case appleDevelopment
case internalSimulator
}
struct PushBuildConfig {
let transport: PushTransportMode
let distribution: PushDistributionMode
let relayBaseURL: URL?
let apnsEnvironment: PushAPNsEnvironment
let relayProfile: PushRelayProfile
let proofPolicy: PushProofPolicy
static let current = PushBuildConfig()
static let openClawHostedRelayHost = "ios-push-relay.openclaw.ai"
static let openClawSandboxRelayHost = "ios-push-relay-sandbox.openclaw.ai"
var usesOpenClawHostedRelay: Bool {
guard self.transport == .relay, self.distribution == .official else { return false }
@@ -47,8 +32,7 @@ struct PushBuildConfig {
return false
}
return components.scheme?.lowercased() == "https"
&& [Self.openClawHostedRelayHost, Self.openClawSandboxRelayHost]
.contains(components.host?.lowercased() ?? "")
&& components.host?.lowercased() == Self.openClawHostedRelayHost
&& components.user == nil
&& components.password == nil
}
@@ -66,14 +50,6 @@ struct PushBuildConfig {
bundle: bundle,
key: "OpenClawPushAPNsEnvironment",
fallback: Self.defaultAPNsEnvironment)
self.relayProfile = Self.readEnum(
bundle: bundle,
key: "OpenClawPushRelayProfile",
fallback: Self.defaultRelayProfile(apnsEnvironment: self.apnsEnvironment))
self.proofPolicy = Self.readEnum(
bundle: bundle,
key: "OpenClawPushProofPolicy",
fallback: Self.defaultProofPolicy(relayProfile: self.relayProfile))
self.relayBaseURL = Self.readURL(bundle: bundle, key: "OpenClawPushRelayBaseURL")
}
@@ -101,24 +77,9 @@ struct PushBuildConfig {
fallback: T)
-> T where T.RawValue == String {
guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return fallback }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return T(rawValue: trimmed) ?? T(rawValue: trimmed.lowercased()) ?? fallback
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return T(rawValue: trimmed) ?? fallback
}
private static let defaultAPNsEnvironment: PushAPNsEnvironment = .sandbox
private static func defaultRelayProfile(apnsEnvironment: PushAPNsEnvironment) -> PushRelayProfile {
apnsEnvironment == .production ? .production : .deviceSandbox
}
private static func defaultProofPolicy(relayProfile: PushRelayProfile) -> PushProofPolicy {
switch relayProfile {
case .production:
.appleStrict
case .deviceSandbox:
.appleDevelopment
case .simulatorSandbox:
.internalSimulator
}
}
}

View File

@@ -71,10 +71,10 @@ actor PushRegistrationManager {
throw PushRelayError.relayMisconfigured(
"Relay transport requires OpenClawPushDistribution=official")
}
try Self.validateRelayContract(
relayProfile: self.buildConfig.relayProfile,
apnsEnvironment: self.buildConfig.apnsEnvironment,
proofPolicy: self.buildConfig.proofPolicy)
guard self.buildConfig.apnsEnvironment == .production else {
throw PushRelayError.relayMisconfigured(
"Relay transport requires OpenClawPushAPNsEnvironment=production")
}
guard let relayClient = self.relayClient else {
throw PushRelayError.relayBaseURLMissing
}
@@ -96,9 +96,6 @@ actor PushRegistrationManager {
stored.installationId == installationId,
stored.gatewayDeviceId == gatewayIdentity.deviceId,
stored.relayOrigin == relayOrigin,
stored.apnsEnvironment == self.buildConfig.apnsEnvironment.rawValue,
stored.relayProfile == self.buildConfig.relayProfile.rawValue,
stored.proofPolicy == self.buildConfig.proofPolicy.rawValue,
stored.lastAPNsTokenHashHex == tokenHashHex,
!Self.isExpired(stored.relayHandleExpiresAtMs)
{
@@ -115,16 +112,14 @@ actor PushRegistrationManager {
tokenDebugSuffix: stored.tokenDebugSuffix))
}
let response = try await relayClient.register(PushRelayRegistrationInput(
let response = try await relayClient.register(
installationId: installationId,
bundleId: bundleId,
appVersion: DeviceInfoHelper.appVersion(),
environment: self.buildConfig.apnsEnvironment,
relayProfile: self.buildConfig.relayProfile,
proofPolicy: self.buildConfig.proofPolicy,
distribution: self.buildConfig.distribution,
apnsTokenHex: apnsTokenHex,
gatewayIdentity: gatewayIdentity))
gatewayIdentity: gatewayIdentity)
let registrationState = PushRelayRegistrationStore.RegistrationState(
relayHandle: response.relayHandle,
sendGrant: response.sendGrant,
@@ -134,10 +129,7 @@ actor PushRegistrationManager {
tokenDebugSuffix: Self.normalizeTokenSuffix(response.tokenSuffix),
lastAPNsTokenHashHex: tokenHashHex,
installationId: installationId,
lastTransport: self.buildConfig.transport.rawValue,
apnsEnvironment: self.buildConfig.apnsEnvironment.rawValue,
relayProfile: self.buildConfig.relayProfile.rawValue,
proofPolicy: self.buildConfig.proofPolicy.rawValue)
lastTransport: self.buildConfig.transport.rawValue)
_ = PushRelayRegistrationStore.saveRegistrationState(registrationState)
return try Self.encodePayload(
RelayGatewayPushRegistrationPayload(
@@ -159,30 +151,6 @@ actor PushRegistrationManager {
return expiresAtMs <= nowMs + 60000
}
private static func validateRelayContract(
relayProfile: PushRelayProfile,
apnsEnvironment: PushAPNsEnvironment,
proofPolicy: PushProofPolicy)
throws {
switch relayProfile {
case .production:
guard apnsEnvironment == .production, proofPolicy == .appleStrict else {
throw PushRelayError.relayMisconfigured(
"production relay profile requires production APNs and appleStrict proof")
}
case .deviceSandbox:
guard apnsEnvironment == .sandbox, proofPolicy == .appleDevelopment else {
throw PushRelayError.relayMisconfigured(
"deviceSandbox relay profile requires sandbox APNs and appleDevelopment proof")
}
case .simulatorSandbox:
guard apnsEnvironment == .sandbox, proofPolicy == .internalSimulator else {
throw PushRelayError.relayMisconfigured(
"simulatorSandbox relay profile requires sandbox APNs and internalSimulator proof")
}
}
}
private static func sha256Hex(_ value: String) -> String {
let digest = SHA256.hash(data: Data(value.utf8))
return digest.map { String(format: "%02x", $0) }.joined()

View File

@@ -40,9 +40,6 @@ private struct PushRelayRegisterSignedPayload: Encodable {
var installationId: String
var bundleId: String
var environment: String
var relayProfile: String
var apnsEnvironment: String
var proofPolicy: String
var distribution: String
var gateway: PushRelayGatewayIdentity
var appVersion: String
@@ -66,16 +63,12 @@ private struct PushRelayRegisterRequest: Encodable {
var installationId: String
var bundleId: String
var environment: String
var relayProfile: String
var apnsEnvironment: String
var proofPolicy: String
var distribution: String
var gateway: PushRelayGatewayIdentity
var appVersion: String
var apnsToken: String
var appAttest: PushRelayAppAttestPayload?
var receipt: PushRelayReceiptPayload?
var simulatorProof: PushRelaySimulatorProofPayload?
var appAttest: PushRelayAppAttestPayload
var receipt: PushRelayReceiptPayload
}
struct PushRelayRegisterResponse: Decodable {
@@ -100,34 +93,23 @@ private struct PushRelayAppAttestProof {
var signedPayloadBase64: String
}
private struct PushRelaySimulatorProofPayload: Encodable {
var signedPayloadBase64: String
var hmacSha256Base64Url: String
}
private final class PushRelayAppAttestService {
func createProof(
challenge: String,
signedPayload: Data,
scope: PushRelayRegistrationStore.AppAttestScope)
async throws -> PushRelayAppAttestProof {
func createProof(challenge: String, signedPayload: Data) async throws -> PushRelayAppAttestProof {
let service = DCAppAttestService.shared
guard service.isSupported else {
throw PushRelayError.unsupportedAppAttest
}
let keyID = try await self.loadOrCreateKeyID(using: service, scope: scope)
let keyID = try await self.loadOrCreateKeyID(using: service)
let attestationObject = try await self.attestKeyIfNeeded(
service: service,
keyID: keyID,
challenge: challenge,
scope: scope)
challenge: challenge)
let signedPayloadHash = Data(SHA256.hash(data: signedPayload))
let assertion = try await self.generateAssertion(
service: service,
keyID: keyID,
signedPayloadHash: signedPayloadHash,
scope: scope)
signedPayloadHash: signedPayloadHash)
return PushRelayAppAttestProof(
keyId: keyID,
@@ -137,27 +119,21 @@ private final class PushRelayAppAttestService {
signedPayloadBase64: signedPayload.base64EncodedString())
}
private func loadOrCreateKeyID(
using service: DCAppAttestService,
scope: PushRelayRegistrationStore.AppAttestScope)
async throws -> String {
if let existing = PushRelayRegistrationStore.loadAppAttestKeyID(scope: scope),
!existing.isEmpty
{
private func loadOrCreateKeyID(using service: DCAppAttestService) async throws -> String {
if let existing = PushRelayRegistrationStore.loadAppAttestKeyID(), !existing.isEmpty {
return existing
}
let keyID = try await service.generateKey()
_ = PushRelayRegistrationStore.saveAppAttestKeyID(keyID, scope: scope)
_ = PushRelayRegistrationStore.saveAppAttestKeyID(keyID)
return keyID
}
private func attestKeyIfNeeded(
service: DCAppAttestService,
keyID: String,
challenge: String,
scope: PushRelayRegistrationStore.AppAttestScope)
challenge: String)
async throws -> String? {
if PushRelayRegistrationStore.loadAttestedKeyID(scope: scope) == keyID {
if PushRelayRegistrationStore.loadAttestedKeyID() == keyID {
return nil
}
let challengeData = Data(challenge.utf8)
@@ -166,21 +142,20 @@ private final class PushRelayAppAttestService {
// Apple treats App Attest key attestation as a one-time operation. Save the
// attested marker immediately so later receipt/network failures do not cause a
// permanently broken re-attestation loop on the same key.
_ = PushRelayRegistrationStore.saveAttestedKeyID(keyID, scope: scope)
_ = PushRelayRegistrationStore.saveAttestedKeyID(keyID)
return attestation.base64EncodedString()
}
private func generateAssertion(
service: DCAppAttestService,
keyID: String,
signedPayloadHash: Data,
scope: PushRelayRegistrationStore.AppAttestScope)
signedPayloadHash: Data)
async throws -> Data {
do {
return try await service.generateAssertion(keyID, clientDataHash: signedPayloadHash)
} catch {
_ = PushRelayRegistrationStore.clearAppAttestKeyID(scope: scope)
_ = PushRelayRegistrationStore.clearAttestedKeyID(scope: scope)
_ = PushRelayRegistrationStore.clearAppAttestKeyID()
_ = PushRelayRegistrationStore.clearAttestedKeyID()
throw error
}
}
@@ -215,47 +190,6 @@ private final class PushRelayReceiptProvider {
}
}
private final class PushRelaySimulatorProofProvider {
func createProof(signedPayload: Data) throws -> PushRelaySimulatorProofPayload {
#if targetEnvironment(simulator)
guard let secret = ProcessInfo.processInfo.environment["OPENCLAW_SIMULATOR_PUSH_PROOF_SECRET"]?
.trimmingCharacters(in: .whitespacesAndNewlines),
!secret.isEmpty
else {
throw PushRelayError.relayMisconfigured("Simulator push proof secret missing")
}
let signedPayloadBase64 = signedPayload.base64EncodedString()
let signature = HMAC<SHA256>.authenticationCode(
for: Data(signedPayloadBase64.utf8),
using: SymmetricKey(data: Data(secret.utf8)))
return PushRelaySimulatorProofPayload(
signedPayloadBase64: signedPayloadBase64,
hmacSha256Base64Url: Self.base64URL(Data(signature)))
#else
throw PushRelayError.relayMisconfigured("Simulator proof is only available in iOS Simulator")
#endif
}
private static func base64URL(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
struct PushRelayRegistrationInput {
var installationId: String
var bundleId: String
var appVersion: String
var environment: PushAPNsEnvironment
var relayProfile: PushRelayProfile
var proofPolicy: PushProofPolicy
var distribution: PushDistributionMode
var apnsTokenHex: String
var gatewayIdentity: PushRelayGatewayIdentity
}
/// The client is constructed once and used behind PushRegistrationManager actor isolation.
final class PushRelayClient: @unchecked Sendable {
private let baseURL: URL
@@ -264,7 +198,6 @@ final class PushRelayClient: @unchecked Sendable {
private let jsonEncoder = JSONEncoder()
private let appAttest = PushRelayAppAttestService()
private let receiptProvider = PushRelayReceiptProvider()
private let simulatorProofProvider = PushRelaySimulatorProofProvider()
init(baseURL: URL, session: URLSession = .shared) {
self.baseURL = baseURL
@@ -275,57 +208,46 @@ final class PushRelayClient: @unchecked Sendable {
Self.normalizeBaseURLString(self.baseURL)
}
func register(_ input: PushRelayRegistrationInput) async throws -> PushRelayRegisterResponse {
func register(
installationId: String,
bundleId: String,
appVersion: String,
environment: PushAPNsEnvironment,
distribution: PushDistributionMode,
apnsTokenHex: String,
gatewayIdentity: PushRelayGatewayIdentity)
async throws -> PushRelayRegisterResponse {
let challenge = try await self.fetchChallenge()
let signedPayload = PushRelayRegisterSignedPayload(
challengeId: challenge.challengeId,
installationId: input.installationId,
bundleId: input.bundleId,
environment: input.environment.rawValue,
relayProfile: input.relayProfile.rawValue,
apnsEnvironment: input.environment.rawValue,
proofPolicy: input.proofPolicy.rawValue,
distribution: input.distribution.rawValue,
gateway: input.gatewayIdentity,
appVersion: input.appVersion,
apnsToken: input.apnsTokenHex)
installationId: installationId,
bundleId: bundleId,
environment: environment.rawValue,
distribution: distribution.rawValue,
gateway: gatewayIdentity,
appVersion: appVersion,
apnsToken: apnsTokenHex)
let signedPayloadData = try self.jsonEncoder.encode(signedPayload)
let appAttestScope = PushRelayRegistrationStore.AppAttestScope(
relayOrigin: self.normalizedBaseURLString,
apnsEnvironment: input.environment.rawValue,
relayProfile: input.relayProfile.rawValue,
proofPolicy: input.proofPolicy.rawValue)
let appAttest = try await self.createAppAttestProofIfNeeded(
proofPolicy: input.proofPolicy,
let appAttest = try await self.appAttest.createProof(
challenge: challenge.challenge,
signedPayloadData: signedPayloadData,
scope: appAttestScope)
let receipt = try await self.createReceiptIfNeeded(proofPolicy: input.proofPolicy)
let simulatorProof = try self.createSimulatorProofIfNeeded(
proofPolicy: input.proofPolicy,
signedPayloadData: signedPayloadData)
signedPayload: signedPayloadData)
let receiptBase64 = try await self.receiptProvider.loadReceiptBase64()
let requestBody = PushRelayRegisterRequest(
challengeId: signedPayload.challengeId,
installationId: signedPayload.installationId,
bundleId: signedPayload.bundleId,
environment: signedPayload.environment,
relayProfile: signedPayload.relayProfile,
apnsEnvironment: signedPayload.apnsEnvironment,
proofPolicy: signedPayload.proofPolicy,
distribution: signedPayload.distribution,
gateway: signedPayload.gateway,
appVersion: signedPayload.appVersion,
apnsToken: signedPayload.apnsToken,
appAttest: appAttest.map {
PushRelayAppAttestPayload(
keyId: $0.keyId,
attestationObject: $0.attestationObject,
assertion: $0.assertion,
clientDataHash: $0.clientDataHash,
signedPayloadBase64: $0.signedPayloadBase64)
},
receipt: receipt,
simulatorProof: simulatorProof)
appAttest: PushRelayAppAttestPayload(
keyId: appAttest.keyId,
attestationObject: appAttest.attestationObject,
assertion: appAttest.assertion,
clientDataHash: appAttest.clientDataHash,
signedPayloadBase64: appAttest.signedPayloadBase64),
receipt: PushRelayReceiptPayload(base64: receiptBase64))
let endpoint = self.baseURL.appending(path: "v1/push/register")
var request = URLRequest(url: endpoint)
@@ -340,8 +262,8 @@ final class PushRelayClient: @unchecked Sendable {
if status == 401 {
// If the relay rejects registration, drop local App Attest state so the next
// attempt re-attests instead of getting stuck without an attestation object.
_ = PushRelayRegistrationStore.clearAppAttestKeyID(scope: appAttestScope)
_ = PushRelayRegistrationStore.clearAttestedKeyID(scope: appAttestScope)
_ = PushRelayRegistrationStore.clearAppAttestKeyID()
_ = PushRelayRegistrationStore.clearAttestedKeyID()
}
throw PushRelayError.requestFailed(
status: status,
@@ -350,43 +272,6 @@ final class PushRelayClient: @unchecked Sendable {
return try self.decode(PushRelayRegisterResponse.self, from: data)
}
private func createAppAttestProofIfNeeded(
proofPolicy: PushProofPolicy,
challenge: String,
signedPayloadData: Data,
scope: PushRelayRegistrationStore.AppAttestScope)
async throws -> PushRelayAppAttestProof? {
guard proofPolicy != .internalSimulator else { return nil }
return try await self.appAttest.createProof(
challenge: challenge,
signedPayload: signedPayloadData,
scope: scope)
}
private func createReceiptIfNeeded(
proofPolicy: PushProofPolicy)
async throws -> PushRelayReceiptPayload? {
switch proofPolicy {
case .appleStrict:
return try await PushRelayReceiptPayload(base64: self.receiptProvider.loadReceiptBase64())
case .appleDevelopment:
guard let receiptBase64 = try? await self.receiptProvider.loadReceiptBase64() else {
return nil
}
return PushRelayReceiptPayload(base64: receiptBase64)
case .internalSimulator:
return nil
}
}
private func createSimulatorProofIfNeeded(
proofPolicy: PushProofPolicy,
signedPayloadData: Data)
throws -> PushRelaySimulatorProofPayload? {
guard proofPolicy == .internalSimulator else { return nil }
return try self.simulatorProofProvider.createProof(signedPayload: signedPayloadData)
}
private func fetchChallenge() async throws -> PushRelayChallengeResponse {
let endpoint = self.baseURL.appending(path: "v1/push/challenge")
var request = URLRequest(url: endpoint)

View File

@@ -1,4 +1,3 @@
import CryptoKit
import Foundation
private struct StoredPushRelayRegistrationState: Codable {
@@ -11,9 +10,6 @@ private struct StoredPushRelayRegistrationState: Codable {
var lastAPNsTokenHashHex: String
var installationId: String
var lastTransport: String
var apnsEnvironment: String?
var relayProfile: String?
var proofPolicy: String?
}
enum PushRelayRegistrationStore {
@@ -22,13 +18,6 @@ enum PushRelayRegistrationStore {
private static let appAttestKeyIDAccount = "app-attest-key-id"
private static let appAttestedKeyIDAccount = "app-attested-key-id"
struct AppAttestScope {
var relayOrigin: String
var apnsEnvironment: String
var relayProfile: String
var proofPolicy: String
}
struct RegistrationState: Codable {
var relayHandle: String
var sendGrant: String
@@ -39,9 +28,6 @@ enum PushRelayRegistrationStore {
var lastAPNsTokenHashHex: String
var installationId: String
var lastTransport: String
var apnsEnvironment: String
var relayProfile: String
var proofPolicy: String
}
static func loadRegistrationState() -> RegistrationState? {
@@ -62,10 +48,7 @@ enum PushRelayRegistrationStore {
tokenDebugSuffix: decoded.tokenDebugSuffix,
lastAPNsTokenHashHex: decoded.lastAPNsTokenHashHex,
installationId: decoded.installationId,
lastTransport: decoded.lastTransport,
apnsEnvironment: decoded.apnsEnvironment ?? "production",
relayProfile: decoded.relayProfile ?? "production",
proofPolicy: decoded.proofPolicy ?? "appleStrict")
lastTransport: decoded.lastTransport)
}
@discardableResult
@@ -79,10 +62,7 @@ enum PushRelayRegistrationStore {
tokenDebugSuffix: state.tokenDebugSuffix,
lastAPNsTokenHashHex: state.lastAPNsTokenHashHex,
installationId: state.installationId,
lastTransport: state.lastTransport,
apnsEnvironment: state.apnsEnvironment,
relayProfile: state.relayProfile,
proofPolicy: state.proofPolicy)
lastTransport: state.lastTransport)
guard let data = try? JSONEncoder().encode(stored),
let raw = String(data: data, encoding: .utf8)
else {
@@ -91,66 +71,37 @@ enum PushRelayRegistrationStore {
return KeychainStore.saveString(raw, service: self.service, account: self.registrationStateAccount)
}
static func loadAppAttestKeyID(scope: AppAttestScope) -> String? {
let value = KeychainStore.loadString(
service: self.service,
account: self.scopedAccount(self.appAttestKeyIDAccount, scope: scope))?
static func loadAppAttestKeyID() -> String? {
let value = KeychainStore.loadString(service: self.service, account: self.appAttestKeyIDAccount)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if value?.isEmpty == false { return value }
return nil
}
@discardableResult
static func saveAppAttestKeyID(_ keyID: String, scope: AppAttestScope) -> Bool {
KeychainStore.saveString(
keyID,
service: self.service,
account: self.scopedAccount(self.appAttestKeyIDAccount, scope: scope))
static func saveAppAttestKeyID(_ keyID: String) -> Bool {
KeychainStore.saveString(keyID, service: self.service, account: self.appAttestKeyIDAccount)
}
@discardableResult
static func clearAppAttestKeyID(scope: AppAttestScope) -> Bool {
KeychainStore.delete(
service: self.service,
account: self.scopedAccount(self.appAttestKeyIDAccount, scope: scope))
static func clearAppAttestKeyID() -> Bool {
KeychainStore.delete(service: self.service, account: self.appAttestKeyIDAccount)
}
static func loadAttestedKeyID(scope: AppAttestScope) -> String? {
let value = KeychainStore.loadString(
service: self.service,
account: self.scopedAccount(self.appAttestedKeyIDAccount, scope: scope))?
static func loadAttestedKeyID() -> String? {
let value = KeychainStore.loadString(service: self.service, account: self.appAttestedKeyIDAccount)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if value?.isEmpty == false { return value }
return nil
}
@discardableResult
static func saveAttestedKeyID(_ keyID: String, scope: AppAttestScope) -> Bool {
KeychainStore.saveString(
keyID,
service: self.service,
account: self.scopedAccount(self.appAttestedKeyIDAccount, scope: scope))
static func saveAttestedKeyID(_ keyID: String) -> Bool {
KeychainStore.saveString(keyID, service: self.service, account: self.appAttestedKeyIDAccount)
}
@discardableResult
static func clearAttestedKeyID(scope: AppAttestScope) -> Bool {
KeychainStore.delete(
service: self.service,
account: self.scopedAccount(self.appAttestedKeyIDAccount, scope: scope))
}
private static func scopedAccount(_ baseAccount: String, scope: AppAttestScope) -> String {
let raw = [
scope.relayOrigin,
scope.apnsEnvironment,
scope.relayProfile,
scope.proofPolicy,
].joined(separator: "\n")
let digest = SHA256.hash(data: Data(raw.utf8))
.map { String(format: "%02x", $0) }
.joined()
// A relay sees an App Attest key as attested only after receiving that
// key's attestation object, so keep key state isolated per relay context.
return "\(baseAccount)-\(digest)"
static func clearAttestedKeyID() -> Bool {
KeychainStore.delete(service: self.service, account: self.appAttestedKeyIDAccount)
}
}

View File

@@ -35,7 +35,7 @@ struct TalkPermissionPromptView: View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: self.iconSystemName)
.font(.title3.weight(.semibold))
.foregroundStyle(self.requestIsPending ? OpenClawBrand.warn : OpenClawBrand.accent)
.foregroundStyle(self.requestIsPending ? Color.orange : Color.accentColor)
.frame(width: 28, height: 28)
VStack(alignment: .leading, spacing: 6) {
@@ -51,7 +51,7 @@ struct TalkPermissionPromptView: View {
if let failureMessage = self.state.failureMessage {
Label(failureMessage, systemImage: "exclamationmark.triangle.fill")
.font(.footnote)
.foregroundStyle(OpenClawBrand.danger)
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}
@@ -99,7 +99,7 @@ struct TalkPermissionPromptView: View {
.overlay {
if self.style == .card || self.style == .sheet {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(OpenClawBrand.accent.opacity(0.20), lineWidth: 1)
.stroke(Color.accentColor.opacity(0.20), lineWidth: 1)
}
}
.task(id: self.pollTaskKey) {

View File

@@ -495,9 +495,6 @@ def produce_services_for_target(target)
if target.fetch("capabilities").include?("APP_GROUPS")
services[:app_group] = "on"
end
if target.fetch("capabilities").include?("APP_ATTEST")
services[:app_attest] = "on"
end
services
end
@@ -608,15 +605,6 @@ def validate_match_profile_capabilities!(target)
)
end
end
if capabilities.include?("APP_ATTEST")
app_attest_environments = profile_plist_array_values(profile_path, "Entitlements:com.apple.developer.devicecheck.appattest-environment")
unless app_attest_environments.include?("production")
UI.user_error!(
"Provisioning profile #{target.fetch("profileName")} for #{target.fetch("bundleId")} is missing production App Attest entitlement; actual environments: #{app_attest_environments.empty? ? "missing" : app_attest_environments.join(", ")}."
)
end
end
end
def sync_app_store_signing!(readonly:)

View File

@@ -65,7 +65,7 @@ pnpm ios:release:signing:check
pnpm ios:release:signing:setup
```
`signing:setup` uses Fastlane `produce` and `modify_services` to create Developer Portal bundle IDs and enable required services before running `match`. The main app also requires App Attest, and the main app and share extension both require the shared App Group from `apps/ios/Config/AppStoreSigning.json`; associate that group with both bundle IDs in the Apple Developer Portal before regenerating profiles. If Fastlane does not already have a valid Apple Developer Portal session, run `fastlane spaceauth` for a release-owner Apple ID and export the resulting `FASTLANE_SESSION`.
`signing:setup` uses Fastlane `produce` and `modify_services` to create Developer Portal bundle IDs and enable required services before running `match`. The main app and share extension also require the shared App Group from `apps/ios/Config/AppStoreSigning.json`; associate that group with both bundle IDs in the Apple Developer Portal before regenerating profiles. If Fastlane does not already have a valid Apple Developer Portal session, run `fastlane spaceauth` for a release-owner Apple ID and export the resulting `FASTLANE_SESSION`.
Shared encrypted signing storage:

View File

@@ -107,7 +107,7 @@ targets:
settings:
base:
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
CODE_SIGN_ENTITLEMENTS: "$(OPENCLAW_CODE_SIGN_ENTITLEMENTS)"
CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_APP_BUNDLE_ID)"
@@ -120,23 +120,17 @@ targets:
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
configs:
Debug:
OPENCLAW_CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT: development
OPENCLAW_PUSH_TRANSPORT: direct
OPENCLAW_PUSH_DISTRIBUTION: local
OPENCLAW_PUSH_RELAY_BASE_URL: ""
OPENCLAW_PUSH_APNS_ENVIRONMENT: sandbox
OPENCLAW_PUSH_RELAY_PROFILE: deviceSandbox
OPENCLAW_PUSH_PROOF_POLICY: appleDevelopment
Release:
OPENCLAW_CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT: production
OPENCLAW_PUSH_TRANSPORT: direct
OPENCLAW_PUSH_DISTRIBUTION: local
OPENCLAW_PUSH_RELAY_BASE_URL: ""
OPENCLAW_PUSH_APNS_ENVIRONMENT: production
OPENCLAW_PUSH_RELAY_PROFILE: production
OPENCLAW_PUSH_PROOF_POLICY: appleStrict
info:
path: Sources/Info.plist
properties:
@@ -182,8 +176,6 @@ targets:
OpenClawPushDistribution: "$(OPENCLAW_PUSH_DISTRIBUTION)"
OpenClawPushRelayBaseURL: "$(OPENCLAW_PUSH_RELAY_BASE_URL)"
OpenClawPushAPNsEnvironment: "$(OPENCLAW_PUSH_APNS_ENVIRONMENT)"
OpenClawPushRelayProfile: "$(OPENCLAW_PUSH_RELAY_PROFILE)"
OpenClawPushProofPolicy: "$(OPENCLAW_PUSH_PROOF_POLICY)"
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown

View File

@@ -680,6 +680,83 @@ struct GeneralSettings: View {
case .missingNode, .missingGateway, .incompatible, .error: .orange
}
}
private var healthCard: some View {
let snapshot = self.healthStore.snapshot
return VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Circle()
.fill(self.healthStore.state.tint)
.frame(width: 10, height: 10)
Text(self.healthStore.summaryLine)
.font(.callout.weight(.semibold))
}
if let snap = snapshot {
let linkId = snap.channelOrder?.first(where: {
if let summary = snap.channels[$0] { return summary.linked != nil }
return false
}) ?? snap.channels.keys.first(where: {
if let summary = snap.channels[$0] { return summary.linked != nil }
return false
})
let linkLabel =
linkId.flatMap { snap.channelLabels?[$0] } ??
linkId?.capitalized ??
"Link channel"
let linkAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }
Text("\(linkLabel) auth age: \(healthAgeString(linkAge))")
.font(.caption)
.foregroundStyle(.secondary)
Text("Session store: \(snap.sessions.path) (\(snap.sessions.count) entries)")
.font(.caption)
.foregroundStyle(.secondary)
if let recent = snap.sessions.recent.first {
let lastActivity = recent.updatedAt != nil
? relativeAge(from: Date(timeIntervalSince1970: (recent.updatedAt ?? 0) / 1000))
: "unknown"
Text("Last activity: \(recent.key) \(lastActivity)")
.font(.caption)
.foregroundStyle(.secondary)
}
Text("Last check: \(relativeAge(from: self.healthStore.lastSuccess))")
.font(.caption)
.foregroundStyle(.secondary)
} else if let error = self.healthStore.lastError {
Text(error)
.font(.caption)
.foregroundStyle(.red)
} else {
Text("Health check pending…")
.font(.caption)
.foregroundStyle(.secondary)
}
HStack(spacing: 12) {
Button {
Task { await self.healthStore.refresh(onDemand: true) }
} label: {
if self.healthStore.isRefreshing {
ProgressView().controlSize(.small)
} else {
Label("Run Health Check", systemImage: "arrow.clockwise")
}
}
.disabled(self.healthStore.isRefreshing)
Divider().frame(height: 18)
Button {
self.revealLogs()
} label: {
Label("Reveal Logs", systemImage: "doc.text.magnifyingglass")
}
}
}
.padding(12)
.background(Color.gray.opacity(0.08))
.cornerRadius(10)
}
}
private enum RemoteStatus: Equatable {
@@ -762,6 +839,11 @@ extension GeneralSettings {
}
}
private func healthAgeString(_ ms: Double?) -> String {
guard let ms else { return "unknown" }
return msToAge(ms)
}
#if DEBUG
struct GeneralSettings_Previews: PreviewProvider {
static var previews: some View {

View File

@@ -221,6 +221,16 @@ final class InstancesStore {
}
}
private func decodeAndApplyPresenceData(_ data: Data) {
do {
let decoded = try JSONDecoder().decode([PresenceEntry].self, from: data)
self.applyPresence(decoded)
} catch {
self.logger.error("presence decode from event failed: \(error.localizedDescription, privacy: .public)")
self.lastError = error.localizedDescription
}
}
func handlePresenceEventPayload(_ payload: OpenClawProtocol.AnyCodable) {
do {
let wrapper = try GatewayPayloadDecoding.decode(payload, as: PresenceEventPayload.self)

View File

@@ -16,6 +16,7 @@ struct OpenClawApp: App {
private let gatewayManager = GatewayProcessManager.shared
private let controlChannel = ControlChannel.shared
private let activityStore = WorkActivityStore.shared
private let connectivityCoordinator = GatewayConnectivityCoordinator.shared
@State private var statusItem: NSStatusItem?
@State private var isMenuPresented = false
@State private var isPanelVisible = false
@@ -33,7 +34,6 @@ struct OpenClawApp: App {
init() {
OpenClawLogging.bootstrapIfNeeded()
GatewayConnectivityCoordinator.shared.start()
Self.applyAttachOnlyOverrideIfNeeded()
_state = State(initialValue: AppStateStore.shared)

View File

@@ -1045,6 +1045,16 @@ extension MenuSessionsInjector {
return item
}
private func formatVersionLabel(_ version: String) -> String {
let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return version }
if trimmed.hasPrefix("v") { return trimmed }
if let first = trimmed.unicodeScalars.first, CharacterSet.decimalDigits.contains(first) {
return "v\(trimmed)"
}
return trimmed
}
@objc
private func patchThinking(_ sender: NSMenuItem) {
guard let dict = sender.representedObject as? [String: Any],

View File

@@ -217,6 +217,18 @@ extension String? {
}
}
extension [String] {
fileprivate func dedupedPreserveOrder() -> [String] {
var seen = Set<String>()
var result: [String] = []
for item in self where !seen.contains(item) {
seen.insert(item)
result.append(item)
}
return result
}
}
enum SessionLoadError: LocalizedError {
case gatewayUnavailable(String)
case decodeFailed(String)

View File

@@ -23,6 +23,7 @@ struct VoiceWakeSettings: View {
@State private var meterStartupTask: Task<Void, Never>?
@State private var availableLocales: [Locale] = []
@State private var triggerEntries: [TriggerEntry] = []
private let fieldLabelWidth: CGFloat = 140
private let controlWidth: CGFloat = 240
private let isPreview = ProcessInfo.processInfo.isPreview

View File

@@ -368,6 +368,31 @@ final class VoiceWakeTester {
}
}
private func holdUntilSilence(onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) {
Task { [weak self] in
guard let self else { return }
let detectedAt = Date()
let hardStop = detectedAt.addingTimeInterval(6) // cap overall listen after trigger
while !self.isStopping {
let now = Date()
if now >= hardStop { break }
if let last = self.lastHeard, now.timeIntervalSince(last) >= silenceWindow {
break
}
try? await Task.sleep(nanoseconds: 200_000_000)
}
if !self.isStopping {
self.stop()
await MainActor.run { AppStateStore.shared.stopVoiceEars() }
if let detectedText {
self.logger.info("voice wake hold finished; len=\(detectedText.count)")
Task { @MainActor in onUpdate(.detected(detectedText)) }
}
}
}
}
private func scheduleSilenceCheck(
triggers: [String],
onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void)

View File

@@ -283,7 +283,7 @@ struct OpenClawChatComposer: View {
}
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(OpenClawChatTheme.accent.opacity(0.08))
.background(Color.accentColor.opacity(0.08))
.clipShape(Capsule())
}
}
@@ -550,7 +550,7 @@ struct OpenClawChatComposer: View {
.frame(width: self.sendButtonSize, height: self.sendButtonSize)
.background(
RoundedRectangle(cornerRadius: self.sendButtonCornerRadius, style: .continuous)
.fill(OpenClawChatTheme.danger))
.fill(Color.red))
.contentShape(RoundedRectangle(cornerRadius: self.sendButtonCornerRadius, style: .continuous))
.accessibilityLabel("Stop response")
.disabled(self.viewModel.isAborting)

View File

@@ -57,7 +57,7 @@ private struct ChatMarkdownStyle: ViewModifier {
}
private var inlineStyle: InlineStyle {
let linkColor: Color = self.context == .user ? self.textColor : OpenClawChatTheme.accent
let linkColor: Color = self.context == .user ? self.textColor : .accentColor
let codeScale: CGFloat = self.variant == .compact ? 0.85 : 0.9
return InlineStyle()
.code(.monospaced, .fontScale(codeScale))

View File

@@ -25,7 +25,7 @@ struct ChatAgentAvatar: View {
.fill(
LinearGradient(
colors: [
(self.tint ?? OpenClawChatTheme.accent).opacity(0.95),
(self.tint ?? Color.accentColor).opacity(0.95),
Color(red: 38 / 255.0, green: 40 / 255.0, blue: 43 / 255.0),
],
startPoint: .topLeading,
@@ -33,7 +33,7 @@ struct ChatAgentAvatar: View {
.overlay(
Circle()
.strokeBorder(Color.white.opacity(0.18), lineWidth: 1))
.shadow(color: (self.tint ?? OpenClawChatTheme.accent).opacity(0.18), radius: 8, y: 4)
.shadow(color: (self.tint ?? Color.accentColor).opacity(0.18), radius: 8, y: 4)
.accessibilityLabel(self.name.map { "\($0) avatar" } ?? "Agent avatar")
}

View File

@@ -152,18 +152,6 @@ enum OpenClawChatTheme {
#endif
}
static var accent: Color {
self.userBubble
}
static var danger: Color {
#if os(macOS)
Color(nsColor: .systemRed)
#else
Color(uiColor: .systemRed)
#endif
}
static var assistantBubble: Color {
#if os(macOS)
Color(nsColor: self.assistantBubbleDynamicNSColor)

View File

@@ -235,7 +235,7 @@ private struct OpenClawChatPreviewTransport: OpenClawChatTransport {
showsSessionSwitcher: false,
style: .onboarding,
markdownVariant: .standard,
userAccent: OpenClawChatTheme.accent)
userAccent: .blue)
}
private struct OpenClawChatPreview: View {
@@ -250,7 +250,7 @@ private struct OpenClawChatPreview: View {
showsSessionSwitcher: true,
style: .standard,
markdownVariant: .standard,
userAccent: OpenClawChatTheme.accent,
userAccent: .blue,
showsAssistantTrace: true)
}
}

View File

@@ -372,7 +372,7 @@ public struct OpenClawChatView: View {
systemImage: "bubble.left.and.bubble.right.fill",
title: self.emptyStateTitle,
message: self.emptyStateMessage,
tint: OpenClawChatTheme.accent,
tint: .accentColor,
actionTitle: nil,
action: nil)
.padding(.horizontal, 24)

View File

@@ -1,2 +1,2 @@
8d38dc64627e6bfcebc25215d499a30841953799133dea16ffce902cf301273f plugin-sdk-api-baseline.json
d7927d51588fd006d743fc56cc22d779141b487f82655d88054d2be12f3093ff plugin-sdk-api-baseline.jsonl
05ce13ad6d2ef72af943a61a023e26f58d01e37a04f76e279a933df9b6aed05b plugin-sdk-api-baseline.json
628a6ac85acd5ed71236b07d5760e211b9c0698ea529d5b3101c20579926b0ea plugin-sdk-api-baseline.jsonl

View File

@@ -83,7 +83,7 @@ Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests
- **CI workflow edits** validate the Node CI graph plus workflow linting, but do not force Windows, Android, or macOS native builds by themselves; those platform lanes stay scoped to platform source changes.
- **Workflow Sanity** runs `actionlint`, `zizmor` over all workflow YAML files, the composite-action interpolation guard, and the conflict-marker guard. The PR-scoped `security-fast` job also runs `zizmor` over changed workflow files so workflow security findings fail early in the main CI graph.
- **Docs on `main` pushes** are checked by the standalone `Docs` workflow with the same ClawHub docs mirror used by CI, so mixed code+docs pushes do not also queue the CI `check-docs` shard. Pull requests and manual CI still run `check-docs` from CI when docs changed.
- **TUI PTY** runs in the `checks-node-core-runtime-tui-pty` Linux Node shard for TUI changes. The shard runs `test/vitest/vitest.tui-pty.config.ts` with `OPENCLAW_TUI_PTY_INCLUDE_LOCAL=1`, so it covers both the deterministic `TuiBackend` fixture lane and the slower `tui --local` smoke that mocks only the external model endpoint.
- **TUI PTY** is a focused workflow for TUI changes. It runs `node scripts/run-vitest.mjs run --config test/vitest/vitest.tui-pty.config.ts` on Linux Node 24 for `src/tui/**`, the watch harness, package script, lockfile, and workflow edits. The required lane uses a deterministic `TuiBackend` fixture; the slower `tui --local` smoke is opt-in with `OPENCLAW_TUI_PTY_INCLUDE_LOCAL=1` and mocks only the external model endpoint.
- **CI routing-only edits, selected cheap core-test fixture edits, and narrow plugin contract helper/test-routing edits** use a fast Node-only manifest path: `preflight`, security, and a single `checks-fast-core` task. That path skips build artifacts, Node 22 compatibility, channel contracts, full core shards, bundled-plugin shards, and additional guard matrices when the change is limited to the routing or helper surfaces the fast task exercises directly.
- **Windows Node checks** are scoped to Windows-specific process/path wrappers, npm/pnpm/UI runner helpers, package manager config, and the CI workflow surfaces that execute that lane; unrelated source, plugin, install-smoke, and test-only changes stay on the Linux Node lanes.
@@ -155,7 +155,7 @@ pnpm check:timed # same gate with per-stage timings
pnpm build:strict-smoke
pnpm check:architecture
pnpm test:gateway:watch-regression
OPENCLAW_TUI_PTY_INCLUDE_LOCAL=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.tui-pty.config.ts
node scripts/run-vitest.mjs run --config test/vitest/vitest.tui-pty.config.ts
pnpm test # vitest tests
pnpm test:changed # cheap smart changed Vitest targets
pnpm test:channels

View File

@@ -181,20 +181,12 @@ Fetch usage-cost summaries from session logs.
```bash
openclaw gateway usage-cost
openclaw gateway usage-cost --days 7
openclaw gateway usage-cost --agent work --json
openclaw gateway usage-cost --all-agents
openclaw gateway usage-cost --json
```
<ParamField path="--days <days>" type="number" default="30">
Number of days to include.
</ParamField>
<ParamField path="--agent <id>" type="string">
Scope the cost summary to one configured agent id.
</ParamField>
<ParamField path="--all-agents" type="boolean">
Aggregate the cost summary across all configured agents. Cannot be combined with `--agent`.
</ParamField>
### `gateway stability`

View File

@@ -167,7 +167,7 @@ surfaces, while Codex native hooks remain a separate lower-level Codex mechanism
- 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`; 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 cloud model runs with no explicit model or agent timeout use the same default idle watchdog; cron-triggered local or self-hosted model runs disable the implicit watchdog unless an explicit timeout is configured, so slow local providers should set `models.providers.<id>.timeoutSeconds`.
- 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.
## Where things can end early

View File

@@ -73,10 +73,9 @@ pnpm openclaw qa run \
--output-dir .artifacts/qa-e2e/smoke-ci-profile-dispatch
```
Use `smoke-ci` for deterministic profile proof with mock model providers and
Crabline fake provider servers. Use `release` for Stable/LTS proof against live
channels. When a command also needs an OpenClaw root profile, put the root
profile before the QA command:
Use `smoke-ci` for deterministic no-live-service proof and `release` for the
Stable/LTS proof lane. When a command also needs an OpenClaw root profile, put
the root profile before the QA command:
```bash
pnpm openclaw --profile work qa run --qa-profile smoke-ci
@@ -198,10 +197,7 @@ witness video when `MANTIS_DISCORD_VIEWER_CHROME_PROFILE_DIR` or
environment. That viewer profile is only for visual capture; the pass/fail
decision still comes from the Discord REST oracle.
CI uses the same command surface in `.github/workflows/qa-live-transports-convex.yml`.
Scheduled and default manual runs execute the fast Matrix profile with live
frontier credentials, `--fast`, and `OPENCLAW_QA_MATRIX_NO_REPLY_WINDOW_MS=3000`.
Manual `matrix_profile=all` fans out into the five profile shards.
CI uses the same command surface in `.github/workflows/qa-live-transports-convex.yml`. Scheduled and default manual runs execute the fast Matrix profile with live frontier credentials, `--fast`, and `OPENCLAW_QA_MATRIX_NO_REPLY_WINDOW_MS=3000`. Manual `matrix_profile=all` fans out into the five profile shards so the exhaustive catalog can run in parallel while keeping one artifact directory per shard.
For transport-real Telegram, Discord, Slack, and WhatsApp smoke lanes:
@@ -861,10 +857,7 @@ provider names.
## Transport adapters
`qa-lab` owns a generic transport seam for YAML QA scenarios. `qa-channel` is
the synthetic default. `crabline` starts local provider-shaped servers and runs
OpenClaw's normal channel plugins against them. `live` is reserved for real
provider credentials and external channels.
`qa-lab` owns a generic transport seam for YAML QA scenarios. `qa-channel` is the first adapter on that seam, but the design target is wider: future real or synthetic channels should plug into the same suite runner instead of adding a transport-specific QA runner.
At the architecture level, the split is:
@@ -874,10 +867,10 @@ At the architecture level, the split is:
### Adding a channel
Adding a channel to the YAML QA system requires the channel implementation plus
a scenario pack that exercises the channel contract. For smoke CI coverage, add
the matching Crabline fake provider server and expose it through the `crabline`
driver.
Adding a channel to the YAML QA system requires exactly two things:
1. A transport adapter for the channel.
2. A scenario pack that exercises the channel contract.
Do not add a new top-level QA command root when the shared `qa-lab` host can own the flow.

View File

@@ -15,7 +15,8 @@ When `agents.defaults.typingMode` is **unset**, OpenClaw keeps the legacy behavi
- **Direct chats**: typing starts immediately once the model loop begins.
- **Group chats with a mention**: typing starts immediately.
- **Group chats without a mention**: typing starts only when message text begins streaming.
- **Group chats without a mention**: typing starts when the admitted run has
user-visible activity, such as harness execution activity or message text.
- **Heartbeat runs**: typing starts when the heartbeat run begins if the
resolved heartbeat target is a typing-capable chat and typing is not disabled.
@@ -26,13 +27,14 @@ Set `agents.defaults.typingMode` to one of:
- `never` - no typing indicator, ever.
- `instant` - start typing **as soon as the model loop begins**, even if the run
later returns only the silent reply token.
- `thinking` - start typing on the **first reasoning delta** (requires
`reasoningLevel: "stream"` for the run).
- `message` - start typing on the **first non-silent text delta** (ignores
the `NO_REPLY` silent token).
- `thinking` - start typing on the **first reasoning delta** or on active
harness execution after the turn is accepted.
- `message` - start typing on the **first user-visible reply activity**, such as
active harness execution or a non-silent text delta. Silent reply tokens such
as `NO_REPLY` do not count as text activity.
Order of "how early it fires":
`never``message``thinking``instant`
`never``message`/`thinking``instant`
## Configuration
@@ -62,11 +64,10 @@ Override mode or cadence per session:
## Notes
- `message` mode won't show typing for silent-only replies when the whole
payload is the exact silent token (for example `NO_REPLY` / `no_reply`,
matched case-insensitively).
- `thinking` only fires if the run streams reasoning (`reasoningLevel: "stream"`).
If the model doesn't emit reasoning deltas, typing won't start.
- `message` mode does not start from silent reply tokens, but active execution
can still show typing before any assistant text is available.
- `thinking` still reacts to streamed reasoning (`reasoningLevel: "stream"`),
and it can also start from active execution before reasoning deltas arrive.
- Heartbeat typing is a liveness signal for the resolved delivery target. It
starts at heartbeat run start instead of following `message` or `thinking`
stream timing. Set `typingMode: "never"` to disable it.

View File

@@ -162,7 +162,6 @@ Rules of thumb:
- **Reset** (`/new`, `/reset`) creates a new `sessionId` for that `sessionKey`.
- **Daily reset** (default 4:00 AM local time on the gateway host) creates a new `sessionId` on the next message after the reset boundary.
- **Idle expiry** (`session.reset.idleMinutes` or legacy `session.idleMinutes`) creates a new `sessionId` when a message arrives after the idle window. When daily + idle are both configured, whichever expires first wins.
- **Control UI reconnect resume** can preserve the currently visible session for one reconnect send when the Gateway receives the matching `sessionId` from an operator UI client. Ordinary stale sends still create a new `sessionId`.
- **System events** (heartbeat, cron wakeups, exec notifications, gateway bookkeeping) may mutate the session row but do not extend daily/idle reset freshness. Reset rollover discards queued system-event notices for the previous session before the fresh prompt is built.
- **Parent fork policy** uses OpenClaw's active branch when creating a thread or subagent fork. If that branch is too large, OpenClaw starts the child with isolated context instead of failing or inheriting unusable history. The sizing policy is automatic; legacy `session.parentForkMaxTokens` config is removed by `openclaw doctor --fix`.

View File

@@ -6,7 +6,6 @@ import path from "node:path";
import type { MemoryEmbeddingProbeResult } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import {
resolveMemoryDreamingConfig,
resolveMemoryLightDreamingConfig,
resolveMemoryRemDreamingConfig,
} from "openclaw/plugin-sdk/memory-core-host-status";
import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing";
@@ -224,23 +223,12 @@ async function createHistoricalRemHarnessWorkspace(params: {
function formatDreamingSummary(cfg: OpenClawConfig): string {
const pluginConfig = resolveMemoryPluginConfig(cfg);
const light = resolveMemoryLightDreamingConfig({ pluginConfig, cfg });
const deep = resolveShortTermPromotionDreamingConfig({ pluginConfig, cfg });
const rem = resolveMemoryRemDreamingConfig({ pluginConfig, cfg });
const timezone = deep.timezone ?? light.timezone ?? rem.timezone;
const formatCron = (cron: string) => (timezone ? `${cron} (${timezone})` : cron);
const lightSummary = light.enabled
? `light=${formatCron(light.cron)} · limit=${light.limit} · lookbackDays=${light.lookbackDays}`
: null;
const remSummary = rem.enabled
? `rem=${formatCron(rem.cron)} · limit=${rem.limit} · lookbackDays=${rem.lookbackDays} · minPatternStrength=${rem.minPatternStrength}`
: null;
const hasLighterPhase = light.enabled || rem.enabled;
const deepLabel = hasLighterPhase ? "deep=" : "";
const deepDetails = `${formatCron(deep.cron)} · limit=${deep.limit} · minScore=${deep.minScore} · minRecallCount=${deep.minRecallCount} · minUniqueQueries=${deep.minUniqueQueries} · recencyHalfLifeDays=${deep.recencyHalfLifeDays} · maxAgeDays=${deep.maxAgeDays ?? "none"} · maxPromotedSnippetTokens=${deep.maxPromotedSnippetTokens}`;
const deepSummary = deep.enabled ? `${deepLabel}${deepDetails}` : null;
const phases = [lightSummary, remSummary, deepSummary].filter(Boolean);
return phases.length > 0 ? phases.join(" · ") : "off";
const dreaming = resolveShortTermPromotionDreamingConfig({ pluginConfig, cfg });
if (!dreaming.enabled) {
return "off";
}
const timezone = dreaming.timezone ? ` (${dreaming.timezone})` : "";
return `${dreaming.cron}${timezone} · limit=${dreaming.limit} · minScore=${dreaming.minScore} · minRecallCount=${dreaming.minRecallCount} · minUniqueQueries=${dreaming.minUniqueQueries} · recencyHalfLifeDays=${dreaming.recencyHalfLifeDays} · maxAgeDays=${dreaming.maxAgeDays ?? "none"} · maxPromotedSnippetTokens=${dreaming.maxPromotedSnippetTokens}`;
}
function formatAuditCounts(audit: ShortTermAuditSummary): string {

View File

@@ -746,206 +746,6 @@ describe("memory cli", () => {
});
});
it("reports light-only dreaming as active during status", async () => {
getRuntimeConfig.mockReturnValue({
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
frequency: "5 * * * *",
timezone: "UTC",
phases: {
light: {
enabled: true,
limit: 4,
lookbackDays: 2,
},
deep: {
enabled: false,
},
rem: {
enabled: false,
},
},
},
},
},
},
},
});
const close = vi.fn(async () => {});
mockManager({
probeVectorAvailability: vi.fn(async () => true),
status: () => makeMemoryStatus(),
close,
});
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["status"]);
expectLogged(log, "Dreaming: light=5 * * * * (UTC) · limit=4 · lookbackDays=2");
expect(close).toHaveBeenCalled();
});
it("reports rem-only dreaming as active during status", async () => {
getRuntimeConfig.mockReturnValue({
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
frequency: "0 6 * * 0",
timezone: "UTC",
phases: {
light: {
enabled: false,
},
deep: {
enabled: false,
},
rem: {
enabled: true,
limit: 3,
lookbackDays: 9,
minPatternStrength: 0.81,
},
},
},
},
},
},
},
});
const close = vi.fn(async () => {});
mockManager({
probeVectorAvailability: vi.fn(async () => true),
status: () => makeMemoryStatus(),
close,
});
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["status"]);
expectLogged(
log,
"Dreaming: rem=0 6 * * 0 (UTC) · limit=3 · lookbackDays=9 · minPatternStrength=0.81",
);
expect(close).toHaveBeenCalled();
});
it("labels deep dreaming when multiple phases are active during status", async () => {
getRuntimeConfig.mockReturnValue({
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
frequency: "15 2 * * *",
timezone: "UTC",
phases: {
light: {
enabled: true,
limit: 5,
lookbackDays: 1,
},
deep: {
enabled: true,
limit: 7,
minScore: 0.72,
minRecallCount: 4,
minUniqueQueries: 2,
recencyHalfLifeDays: 10,
maxAgeDays: 45,
maxPromotedSnippetTokens: 512,
},
rem: {
enabled: true,
limit: 2,
lookbackDays: 14,
minPatternStrength: 0.67,
},
},
},
},
},
},
},
});
const close = vi.fn(async () => {});
mockManager({
probeVectorAvailability: vi.fn(async () => true),
status: () => makeMemoryStatus(),
close,
});
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["status"]);
expectLogged(log, "Dreaming: light=15 2 * * * (UTC) · limit=5 · lookbackDays=1");
expectLogged(log, "rem=15 2 * * * (UTC) · limit=2 · lookbackDays=14 · minPatternStrength=0.67");
expectLogged(log, "deep=15 2 * * * (UTC) · limit=7 · minScore=0.72");
expectLogged(log, "minRecallCount=4");
expectLogged(log, "maxPromotedSnippetTokens=512");
expect(close).toHaveBeenCalled();
});
it("preserves deep dreaming diagnostics during status", async () => {
getRuntimeConfig.mockReturnValue({
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
frequency: "0 4 * * *",
timezone: "UTC",
phases: {
light: {
enabled: false,
},
deep: {
enabled: true,
limit: 6,
minScore: 0.88,
minRecallCount: 5,
minUniqueQueries: 3,
recencyHalfLifeDays: 12,
maxAgeDays: 30,
maxPromotedSnippetTokens: 640,
},
rem: {
enabled: false,
},
},
},
},
},
},
},
});
const close = vi.fn(async () => {});
mockManager({
probeVectorAvailability: vi.fn(async () => true),
status: () => makeMemoryStatus(),
close,
});
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["status"]);
expectLogged(log, "Dreaming: 0 4 * * * (UTC) · limit=6 · minScore=0.88");
expectLogged(log, "minRecallCount=5");
expectLogged(log, "minUniqueQueries=3");
expectLogged(log, "recencyHalfLifeDays=12");
expectLogged(log, "maxAgeDays=30");
expectLogged(log, "maxPromotedSnippetTokens=640");
expect(close).toHaveBeenCalled();
});
it("repairs invalid recall metadata and stale locks with status --fix", async () => {
await withTempWorkspace(async (workspaceDir) => {
await shortTermTesting.writeRawRecallStore(workspaceDir, {

View File

@@ -1,195 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { ingestMemoryWikiSource } from "./ingest.js";
import { createMemoryWikiTestHarness } from "./test-helpers.js";
const { createTempDir, createVault } = createMemoryWikiTestHarness();
describe("ingestMemoryWikiSource human notes", () => {
it("preserves user notes when the same source is re-ingested", async () => {
const rootDir = await createTempDir("memory-wiki-reingest-");
const inputPath = path.join(rootDir, "roadmap.txt");
const { config } = await createVault({ rootDir: path.join(rootDir, "vault") });
await fs.writeFile(inputPath, "v1 content\n", "utf8");
await ingestMemoryWikiSource({
config,
inputPath,
nowMs: Date.UTC(2026, 3, 5, 12, 0, 0),
});
const pagePath = path.join(config.vault.path, "sources", "roadmap.md");
const userNote = "KEY INSIGHT: covers $1 of the Q2 roadmap";
const edited = (await fs.readFile(pagePath, "utf8")).replace(
"<!-- openclaw:human:start -->\n<!-- openclaw:human:end -->",
`<!-- openclaw:human:start -->\n${userNote}\n<!-- openclaw:human:end -->`,
);
await fs.writeFile(pagePath, edited, "utf8");
await fs.writeFile(inputPath, "v2 content updated\n", "utf8");
await ingestMemoryWikiSource({
config,
inputPath,
nowMs: Date.UTC(2026, 3, 6, 12, 0, 0),
});
const after = await fs.readFile(pagePath, "utf8");
expect(after).toContain("v2 content updated");
expect(after).toContain(userNote);
});
it("preserves notes without corrupting source content that contains human markers", async () => {
const rootDir = await createTempDir("memory-wiki-markers-");
const inputPath = path.join(rootDir, "notes.txt");
const { config } = await createVault({ rootDir: path.join(rootDir, "vault") });
await fs.writeFile(inputPath, "first body\n", "utf8");
await ingestMemoryWikiSource({
config,
inputPath,
nowMs: Date.UTC(2026, 3, 5, 12, 0, 0),
});
const pagePath = path.join(config.vault.path, "sources", "notes.md");
const userNote = "MY PRIVATE NOTE";
const edited = (await fs.readFile(pagePath, "utf8")).replace(
"<!-- openclaw:human:start -->\n<!-- openclaw:human:end -->",
`<!-- openclaw:human:start -->\n${userNote}\n<!-- openclaw:human:end -->`,
);
await fs.writeFile(pagePath, edited, "utf8");
const sourceWithMarkers = [
"second body",
"<!-- openclaw:human:start -->",
"INJECTED FROM SOURCE",
"<!-- openclaw:human:end -->",
"",
].join("\n");
await fs.writeFile(inputPath, sourceWithMarkers, "utf8");
await ingestMemoryWikiSource({
config,
inputPath,
nowMs: Date.UTC(2026, 3, 6, 12, 0, 0),
});
const after = await fs.readFile(pagePath, "utf8");
const notesBlock = after.slice(after.indexOf("## Notes"));
expect(after).toContain("INJECTED FROM SOURCE");
expect(notesBlock).toContain(userNote);
expect(notesBlock).not.toContain("INJECTED FROM SOURCE");
});
it("preserves CRLF notes without copying marker comments from existing source content", async () => {
const rootDir = await createTempDir("memory-wiki-crlf-markers-");
const inputPath = path.join(rootDir, "windows-notes.txt");
const { config } = await createVault({ rootDir: path.join(rootDir, "vault") });
const sourceWithMarkers = [
"first body",
"<!-- openclaw:human:start -->",
"OLD SOURCE MARKER PAYLOAD",
"<!-- openclaw:human:end -->",
"",
].join("\n");
await fs.writeFile(inputPath, sourceWithMarkers, "utf8");
await ingestMemoryWikiSource({
config,
inputPath,
nowMs: Date.UTC(2026, 3, 5, 12, 0, 0),
});
const pagePath = path.join(config.vault.path, "sources", "windows-notes.md");
const userNote = "CRLF USER NOTE";
const edited = (await fs.readFile(pagePath, "utf8")).replace(
"<!-- openclaw:human:start -->\n<!-- openclaw:human:end -->",
`<!-- openclaw:human:start -->\n${userNote}\n<!-- openclaw:human:end -->`,
);
await fs.writeFile(pagePath, edited.replace(/\n/g, "\r\n"), "utf8");
await fs.writeFile(inputPath, "second body without marker comments\n", "utf8");
await ingestMemoryWikiSource({
config,
inputPath,
nowMs: Date.UTC(2026, 3, 6, 12, 0, 0),
});
const after = await fs.readFile(pagePath, "utf8");
const notesBlock = after.slice(after.indexOf("## Notes"));
expect(after).toContain("second body without marker comments");
expect(notesBlock).toContain(userNote);
expect(notesBlock).not.toContain("OLD SOURCE MARKER PAYLOAD");
});
it("preserves the whole note when the note text itself contains a marker comment", async () => {
const rootDir = await createTempDir("memory-wiki-innermarker-");
const inputPath = path.join(rootDir, "diary.txt");
const { config } = await createVault({ rootDir: path.join(rootDir, "vault") });
await fs.writeFile(inputPath, "first body\n", "utf8");
await ingestMemoryWikiSource({
config,
inputPath,
nowMs: Date.UTC(2026, 3, 5, 12, 0, 0),
});
const pagePath = path.join(config.vault.path, "sources", "diary.md");
const noteWithMarker = [
"EARLY NOTE before any quoted marker",
"<!-- openclaw:human:start -->",
"LATE NOTE after a pasted marker",
].join("\n");
const edited = (await fs.readFile(pagePath, "utf8")).replace(
"<!-- openclaw:human:start -->\n<!-- openclaw:human:end -->",
`<!-- openclaw:human:start -->\n${noteWithMarker}\n<!-- openclaw:human:end -->`,
);
await fs.writeFile(pagePath, edited, "utf8");
await fs.writeFile(inputPath, "second body\n", "utf8");
await ingestMemoryWikiSource({
config,
inputPath,
nowMs: Date.UTC(2026, 3, 6, 12, 0, 0),
});
const after = await fs.readFile(pagePath, "utf8");
expect(after).toContain("second body");
expect(after).toContain("EARLY NOTE before any quoted marker");
expect(after).toContain("LATE NOTE after a pasted marker");
});
it("preserves the note when the note text contains a Markdown heading", async () => {
const rootDir = await createTempDir("memory-wiki-heading-");
const inputPath = path.join(rootDir, "log.txt");
const { config } = await createVault({ rootDir: path.join(rootDir, "vault") });
await fs.writeFile(inputPath, "first body\n", "utf8");
await ingestMemoryWikiSource({
config,
inputPath,
nowMs: Date.UTC(2026, 3, 5, 12, 0, 0),
});
const pagePath = path.join(config.vault.path, "sources", "log.md");
const noteWithHeading = ["NOTE TOP", "## Notes", "NOTE BOTTOM under a pasted heading"].join(
"\n",
);
const edited = (await fs.readFile(pagePath, "utf8")).replace(
"<!-- openclaw:human:start -->\n<!-- openclaw:human:end -->",
`<!-- openclaw:human:start -->\n${noteWithHeading}\n<!-- openclaw:human:end -->`,
);
await fs.writeFile(pagePath, edited, "utf8");
await fs.writeFile(inputPath, "second body\n", "utf8");
await ingestMemoryWikiSource({
config,
inputPath,
nowMs: Date.UTC(2026, 3, 6, 12, 0, 0),
});
const after = await fs.readFile(pagePath, "utf8");
expect(after).toContain("second body");
expect(after).toContain("NOTE TOP");
expect(after).toContain("NOTE BOTTOM under a pasted heading");
});
});

View File

@@ -5,12 +5,7 @@ import { pathExists } from "openclaw/plugin-sdk/security-runtime";
import { compileMemoryWikiVault } from "./compile.js";
import type { ResolvedMemoryWikiConfig } from "./config.js";
import { appendMemoryWikiLog } from "./log.js";
import {
preserveHumanNotesBlock,
renderMarkdownFence,
renderWikiMarkdown,
slugifyWikiSegment,
} from "./markdown.js";
import { renderMarkdownFence, renderWikiMarkdown, slugifyWikiSegment } from "./markdown.js";
import { resolveMemoryWikiTimestamp } from "./time.js";
import { initializeMemoryWikiVault } from "./vault.js";
@@ -87,12 +82,7 @@ export async function ingestMemoryWikiSource(params: {
].join("\n"),
});
const existing = created ? "" : await fs.readFile(pagePath, "utf8").catch(() => "");
await fs.writeFile(
pagePath,
existing ? preserveHumanNotesBlock(markdown, existing) : markdown,
"utf8",
);
await fs.writeFile(pagePath, markdown, "utf8");
await appendMemoryWikiLog(params.config.vault.path, {
type: "ingest",
timestamp,

View File

@@ -446,52 +446,6 @@ function hasHumanNotesBlock(markdown: string): boolean {
return markdown.includes(HUMAN_START_MARKER) && markdown.includes(HUMAN_END_MARKER);
}
const SOURCE_CONTENT_HEADING = /(?:^|\r?\n)## Content\r?\n/u;
function afterSourceContentFence(page: string): number {
const heading = SOURCE_CONTENT_HEADING.exec(page);
if (!heading) {
return 0;
}
const fenceLineStart = heading.index + heading[0].length;
const fence = /^`+/.exec(page.slice(fenceLineStart))?.[0];
if (!fence) {
return fenceLineStart;
}
const closingFence = new RegExp(`\\r?\\n${fence}(?=\\r?\\n|$)`, "u");
const close = closingFence.exec(page.slice(fenceLineStart + fence.length));
if (!close) {
return fenceLineStart;
}
return fenceLineStart + fence.length + close.index + close[0].length;
}
function findNotesHumanBlock(page: string): { start: number; end: number } | null {
const searchFrom = afterSourceContentFence(page);
const start = page.indexOf(HUMAN_START_MARKER, searchFrom);
if (start === -1) {
return null;
}
const endMarker = page.lastIndexOf(HUMAN_END_MARKER);
if (endMarker < start) {
return null;
}
return { start, end: endMarker + HUMAN_END_MARKER.length };
}
export function preserveHumanNotesBlock(rendered: string, existing: string): string {
const existingBlock = findNotesHumanBlock(existing);
const renderedBlock = findNotesHumanBlock(rendered);
if (!existingBlock || !renderedBlock) {
return rendered;
}
return (
rendered.slice(0, renderedBlock.start) +
existing.slice(existingBlock.start, existingBlock.end) +
rendered.slice(renderedBlock.end)
);
}
function detectGeneratedSourceBody(markdown: string): GeneratedSourceBody | undefined {
const lines = normalizeMarkdownLines(markdown);
const normalized = lines.join("\n");

View File

@@ -3,33 +3,8 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderMarkdownFence, renderWikiMarkdown } from "./markdown.js";
import { writeImportedSourcePage } from "./source-page-shared.js";
function buildSourcePage(raw: string, updatedAt: string): string {
return renderWikiMarkdown({
frontmatter: {
pageType: "source",
id: "source.imported",
title: "imported",
sourceType: "memory-unsafe-local",
status: "active",
updatedAt,
},
body: [
"# imported",
"",
"## Content",
renderMarkdownFence(raw, "text"),
"",
"## Notes",
"<!-- openclaw:human:start -->",
"<!-- openclaw:human:end -->",
"",
].join("\n"),
});
}
describe("writeImportedSourcePage", () => {
let suiteRoot: string;
@@ -71,113 +46,4 @@ describe("writeImportedSourcePage", () => {
expect(result).toEqual({ pagePath: "pages/source.md", changed: true, created: true });
expect(state.entries["unsafe:source"]?.sourceUpdatedAtMs).toBe(8_700_000_000_000_000);
});
it("preserves the human Notes block when an imported source page is updated", async () => {
const sourcePath = path.join(suiteRoot, "imported.txt");
const pagePath = "sources/imported.md";
const state: Parameters<typeof writeImportedSourcePage>[0]["state"] = {
entries: {},
version: 1,
};
await fs.writeFile(sourcePath, "first body", "utf8");
await writeImportedSourcePage({
vaultRoot: suiteRoot,
syncKey: "bridge:imported",
sourcePath,
sourceUpdatedAtMs: Date.UTC(2026, 4, 1),
sourceSize: 10,
renderFingerprint: "fp-1",
pagePath,
group: "bridge",
state,
buildRendered: buildSourcePage,
});
const absPage = path.join(suiteRoot, pagePath);
const userNote = "IMPORTED PAGE NOTE";
const edited = (await fs.readFile(absPage, "utf8")).replace(
"<!-- openclaw:human:start -->\n<!-- openclaw:human:end -->",
`<!-- openclaw:human:start -->\n${userNote}\n<!-- openclaw:human:end -->`,
);
await fs.writeFile(absPage, edited, "utf8");
await fs.writeFile(sourcePath, "second body changed", "utf8");
const result = await writeImportedSourcePage({
vaultRoot: suiteRoot,
syncKey: "bridge:imported",
sourcePath,
sourceUpdatedAtMs: Date.UTC(2026, 4, 2),
sourceSize: 19,
renderFingerprint: "fp-2",
pagePath,
group: "bridge",
state,
buildRendered: buildSourcePage,
});
const after = await fs.readFile(absPage, "utf8");
expect(result.changed).toBe(true);
expect(after).toContain("second body changed");
expect(after).toContain(userNote);
});
it("preserves CRLF human notes without copying marker comments from existing imported content", async () => {
const sourcePath = path.join(suiteRoot, "imported-crlf.txt");
const pagePath = "sources/imported-crlf.md";
const state: Parameters<typeof writeImportedSourcePage>[0]["state"] = {
entries: {},
version: 1,
};
const sourceWithMarkers = [
"first imported body",
"<!-- openclaw:human:start -->",
"OLD IMPORTED SOURCE MARKER PAYLOAD",
"<!-- openclaw:human:end -->",
"",
].join("\n");
await fs.writeFile(sourcePath, sourceWithMarkers, "utf8");
await writeImportedSourcePage({
vaultRoot: suiteRoot,
syncKey: "bridge:imported-crlf",
sourcePath,
sourceUpdatedAtMs: Date.UTC(2026, 4, 1),
sourceSize: sourceWithMarkers.length,
renderFingerprint: "fp-1",
pagePath,
group: "bridge",
state,
buildRendered: buildSourcePage,
});
const absPage = path.join(suiteRoot, pagePath);
const userNote = "CRLF IMPORTED PAGE NOTE";
const edited = (await fs.readFile(absPage, "utf8")).replace(
"<!-- openclaw:human:start -->\n<!-- openclaw:human:end -->",
`<!-- openclaw:human:start -->\n${userNote}\n<!-- openclaw:human:end -->`,
);
await fs.writeFile(absPage, edited.replace(/\n/g, "\r\n"), "utf8");
await fs.writeFile(sourcePath, "second imported body without marker comments", "utf8");
const result = await writeImportedSourcePage({
vaultRoot: suiteRoot,
syncKey: "bridge:imported-crlf",
sourcePath,
sourceUpdatedAtMs: Date.UTC(2026, 4, 2),
sourceSize: 44,
renderFingerprint: "fp-2",
pagePath,
group: "bridge",
state,
buildRendered: buildSourcePage,
});
const after = await fs.readFile(absPage, "utf8");
const notesBlock = after.slice(after.indexOf("## Notes"));
expect(result.changed).toBe(true);
expect(after).toContain("second imported body without marker comments");
expect(notesBlock).toContain(userNote);
expect(notesBlock).not.toContain("OLD IMPORTED SOURCE MARKER PAYLOAD");
});
});

View File

@@ -2,7 +2,6 @@
import fs from "node:fs/promises";
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
import { FsSafeError, root as fsRoot } from "openclaw/plugin-sdk/security-runtime";
import { preserveHumanNotesBlock } from "./markdown.js";
import {
setImportedSourceEntry,
shouldSkipImportedSourceWrite,
@@ -53,12 +52,11 @@ export async function writeImportedSourcePage(params: {
const raw = await fs.readFile(params.sourcePath, "utf8");
const rendered = params.buildRendered(raw, updatedAt);
const existing = pageStat ? await vault.readText(params.pagePath).catch(() => "") : "";
const nextRendered = existing ? preserveHumanNotesBlock(rendered, existing) : rendered;
if (existing !== nextRendered) {
if (existing !== rendered) {
await writeGuardedVaultPage({
vault,
pagePath: params.pagePath,
content: nextRendered,
content: rendered,
pageStat,
pageLabel: "imported source page",
});
@@ -76,5 +74,5 @@ export async function writeImportedSourcePage(params: {
renderFingerprint: params.renderFingerprint,
},
});
return { pagePath: params.pagePath, changed: existing !== nextRendered, created };
return { pagePath: params.pagePath, changed: existing !== rendered, created };
}

View File

@@ -408,7 +408,7 @@ describe("buildMinimaxSpeechProvider", () => {
return JSON.parse(init.body) as Record<string, unknown>;
}
it("requests non-streaming hex audio and decodes the hex response", async () => {
it("makes correct API call and decodes hex response", async () => {
const hexAudio = Buffer.from("fake-audio-data").toString("hex");
const mockFetch = vi.mocked(globalThis.fetch);
mockFetch.mockResolvedValueOnce(
@@ -437,8 +437,6 @@ describe("buildMinimaxSpeechProvider", () => {
const body = firstFetchBody();
expect(body.model).toBe("speech-2.8-hd");
expect(body.text).toBe("Hello world");
expect(body.stream).toBe(false);
expect(body.output_format).toBe("hex");
expect((body.voice_setting as Record<string, unknown>).voice_id).toBe(
"English_expressive_narrator",
);

View File

@@ -83,8 +83,6 @@ export async function minimaxTTS(params: {
body: JSON.stringify({
model,
text,
stream: false,
output_format: "hex",
voice_setting: {
voice_id: voiceId,
speed,

View File

@@ -1,716 +0,0 @@
// Opencode Go stream termination wrapper tests cover provider-owned raw SSE
// boundary behavior for stalled OpenAI-compatible streams.
import type {
AssistantMessageEvent,
AssistantMessageEventStreamContract,
} from "openclaw/plugin-sdk/llm";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createOpencodeGoStalledStreamWrapper } from "./stream-termination.js";
type AnyEvent = AssistantMessageEvent;
type StreamLike = AssistantMessageEventStreamContract;
interface FakeStreamController {
emit(event: AnyEvent): void;
end(): void;
}
function createFakeBaseStream(): {
stream: StreamLike;
controller: FakeStreamController;
getReturnCalls: () => number;
} {
const queued: IteratorResult<AnyEvent>[] = [];
const waiters: ((result: IteratorResult<AnyEvent>) => void)[] = [];
let finished = false;
let returnCalls = 0;
const iterator: AsyncIterator<AnyEvent> = {
next(): Promise<IteratorResult<AnyEvent>> {
if (queued.length > 0) {
return Promise.resolve(queued.shift()!);
}
if (finished) {
return Promise.resolve({ value: undefined, done: true });
}
return new Promise((resolve) => {
waiters.push(resolve);
});
},
return(): Promise<IteratorResult<AnyEvent>> {
returnCalls += 1;
finished = true;
while (waiters.length > 0) {
waiters.shift()!({ value: undefined, done: true });
}
return Promise.resolve({ value: undefined, done: true });
},
};
const stream: StreamLike = {
[Symbol.asyncIterator]() {
return iterator;
},
push() {
// unused: the wrapper pushes its own events into a separate stream.
},
end() {
// unused: the wrapper ends its own stream.
},
result() {
return Promise.reject(new Error("fake base stream result not used"));
},
};
const controller: FakeStreamController = {
emit(event: AnyEvent) {
const waiter = waiters.shift();
if (waiter) {
waiter({ value: event, done: false });
} else {
queued.push({ value: event, done: false });
}
},
end() {
finished = true;
while (waiters.length > 0) {
waiters.shift()!({ value: undefined, done: true });
}
},
};
return { stream, controller, getReturnCalls: () => returnCalls };
}
function disableAbortSignalAny(): PropertyDescriptor | undefined {
const descriptor = Object.getOwnPropertyDescriptor(AbortSignal, "any");
Object.defineProperty(AbortSignal, "any", {
configurable: true,
value: undefined,
});
return descriptor;
}
function restoreAbortSignalAny(descriptor: PropertyDescriptor | undefined): void {
if (descriptor) {
Object.defineProperty(AbortSignal, "any", descriptor);
} else {
Reflect.deleteProperty(AbortSignal, "any");
}
}
describe("createOpencodeGoStalledStreamWrapper", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("aborts underlying stream when progress stalls after first delta (raw SSE boundary)", async () => {
// Arrange: a fake base stream that emits a start + one text_delta, then stalls.
const { stream: baseStream, controller } = createFakeBaseStream();
void baseStream;
let abortCalled = false;
const capturedSignals: AbortSignal[] = [];
const underlying = vi.fn((_model, _context, options) => {
if (options?.signal) {
capturedSignals.push(options.signal);
options.signal.addEventListener("abort", () => {
abortCalled = true;
});
}
return baseStream;
});
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
});
const downstream = await Promise.resolve(
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
);
expect(downstream).toBeDefined();
if (!downstream) {
return;
}
// Drain wrapper events in the background.
const received: AnyEvent[] = [];
const consumer = (async () => {
for await (const event of downstream) {
received.push(event);
}
})();
// Emit a start + one text delta — that proves the provider side has produced tokens.
const partial = {
role: "assistant",
content: [{ type: "text", text: "hi" }],
stopReason: undefined,
};
controller.emit({ type: "start", partial } as any);
controller.emit({
type: "text_delta",
contentIndex: 0,
delta: "hi",
partial,
} as any);
// Advance wall clock beyond idleTimeoutMs without any new progress.
await vi.advanceTimersByTimeAsync(6_000);
// Assert: wrapper called abort on its injected AbortController (forwarded as options.signal).
expect(capturedSignals).toHaveLength(1);
expect(abortCalled).toBe(true);
// And it pushed a terminal error event to the downstream consumer.
const terminal = received.find(
(event) => event.type === "error" && (event as any).reason === "error",
);
expect(terminal).toBeDefined();
expect((terminal as any)?.error).toMatchObject({
stopReason: "error",
errorMessage: "opencode-go stream timed out after provider-owned SSE boundary stalled",
});
// Cleanup: end base stream so consumer promise resolves.
controller.end();
await consumer;
});
it("uses a longer first-event timeout than the inter-event idle timeout", async () => {
const { stream: baseStream } = createFakeBaseStream();
let abortCalled = false;
const underlying = vi.fn((_model, _context, options) => {
if (options?.signal) {
options.signal.addEventListener("abort", () => {
abortCalled = true;
});
}
return baseStream;
});
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
firstEventTimeoutMs: 10_000,
});
const downstream = await Promise.resolve(
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
);
expect(downstream).toBeDefined();
if (!downstream) {
return;
}
const consumer = (async () => {
for await (const event of downstream) {
void event;
}
})();
await vi.advanceTimersByTimeAsync(6_000);
expect(abortCalled).toBe(false);
await vi.advanceTimersByTimeAsync(5_000);
expect(abortCalled).toBe(true);
await consumer;
});
it("keeps the first-event window after an openai-completions synthetic start", async () => {
const { stream: baseStream, controller } = createFakeBaseStream();
let abortCalled = false;
const underlying = vi.fn((_model, _context, options) => {
if (options?.signal) {
options.signal.addEventListener("abort", () => {
abortCalled = true;
});
}
return baseStream;
});
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
firstEventTimeoutMs: 10_000,
});
const downstream = await Promise.resolve(
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
);
expect(downstream).toBeDefined();
if (!downstream) {
return;
}
const received: AnyEvent[] = [];
const consumer = (async () => {
for await (const event of downstream) {
received.push(event);
}
})();
const partial = {
role: "assistant",
content: [],
stopReason: undefined,
};
controller.emit({ type: "start", partial } as any);
await vi.advanceTimersByTimeAsync(6_000);
expect(abortCalled).toBe(false);
controller.emit({
type: "text_delta",
contentIndex: 0,
delta: "hello",
partial: {
...partial,
content: [{ type: "text", text: "hello" }],
},
} as any);
controller.emit({
type: "done",
reason: "stop",
message: {
...partial,
content: [{ type: "text", text: "hello" }],
stopReason: "stop",
},
} as any);
await consumer;
expect(abortCalled).toBe(false);
expect(received.some((event) => event.type === "text_delta")).toBe(true);
expect(received.some((event) => event.type === "done")).toBe(true);
});
it("keeps the first-event window after synthetic block-start events until a provider delta", async () => {
const { stream: baseStream, controller } = createFakeBaseStream();
let abortCalled = false;
const underlying = vi.fn((_model, _context, options) => {
if (options?.signal) {
options.signal.addEventListener("abort", () => {
abortCalled = true;
});
}
return baseStream;
});
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
firstEventTimeoutMs: 10_000,
});
const downstream = await Promise.resolve(
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
);
expect(downstream).toBeDefined();
if (!downstream) {
return;
}
const received: AnyEvent[] = [];
const consumer = (async () => {
for await (const event of downstream) {
received.push(event);
}
})();
const partial = {
role: "assistant",
content: [{ type: "text", text: "" }],
stopReason: undefined,
};
controller.emit({ type: "start", partial } as any);
controller.emit({ type: "text_start", contentIndex: 0, partial } as any);
await vi.advanceTimersByTimeAsync(6_000);
expect(abortCalled).toBe(false);
const message = {
...partial,
content: [{ type: "text", text: "hello" }],
stopReason: "stop",
};
controller.emit({
type: "text_delta",
contentIndex: 0,
delta: "hello",
partial: message,
} as any);
controller.emit({ type: "done", reason: "stop", message } as any);
await consumer;
expect(abortCalled).toBe(false);
expect(received.some((event) => event.type === "text_delta")).toBe(true);
expect(received.some((event) => event.type === "done")).toBe(true);
});
it("honors explicit opencode-go provider request timeout above the wrapper idle default", async () => {
const { stream: baseStream, controller } = createFakeBaseStream();
let abortCalled = false;
const underlying = vi.fn((_model, _context, options) => {
if (options?.signal) {
options.signal.addEventListener("abort", () => {
abortCalled = true;
});
}
return baseStream;
});
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
firstEventTimeoutMs: 5_000,
});
const downstream = await Promise.resolve(
wrapper(
{ provider: "opencode-go", id: "deepseek-v4-flash", requestTimeoutMs: 10_000 } as any,
{} as any,
{} as any,
),
);
expect(downstream).toBeDefined();
if (!downstream) {
return;
}
const consumer = (async () => {
for await (const event of downstream) {
void event;
}
})();
const partial = {
role: "assistant",
content: [{ type: "text", text: "slow" }],
stopReason: undefined,
};
controller.emit({ type: "start", partial } as any);
await vi.advanceTimersByTimeAsync(6_000);
expect(abortCalled).toBe(false);
await vi.advanceTimersByTimeAsync(5_000);
expect(abortCalled).toBe(true);
await consumer;
});
it("honors explicit opencode-go provider request timeout below wrapper defaults", async () => {
const { stream: baseStream } = createFakeBaseStream();
let abortCalled = false;
const underlying = vi.fn((_model, _context, options) => {
if (options?.signal) {
options.signal.addEventListener("abort", () => {
abortCalled = true;
});
}
return baseStream;
});
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
firstEventTimeoutMs: 10_000,
});
const downstream = await Promise.resolve(
wrapper(
{ provider: "opencode-go", id: "deepseek-v4-flash", requestTimeoutMs: 2_000 } as any,
{} as any,
{} as any,
),
);
expect(downstream).toBeDefined();
if (!downstream) {
return;
}
const consumer = (async () => {
for await (const event of downstream) {
void event;
}
})();
await vi.advanceTimersByTimeAsync(2_500);
expect(abortCalled).toBe(true);
await consumer;
});
it("aborts and releases the underlying stream when no first event arrives", async () => {
const { stream: baseStream, getReturnCalls } = createFakeBaseStream();
let abortCalled = false;
const capturedSignals: AbortSignal[] = [];
const underlying = vi.fn((_model, _context, options) => {
if (options?.signal) {
capturedSignals.push(options.signal);
options.signal.addEventListener("abort", () => {
abortCalled = true;
});
}
return baseStream;
});
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
});
const downstream = await Promise.resolve(
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
);
expect(downstream).toBeDefined();
if (!downstream) {
return;
}
const received: AnyEvent[] = [];
const consumer = (async () => {
for await (const event of downstream) {
received.push(event);
}
})();
await vi.advanceTimersByTimeAsync(6_000);
expect(capturedSignals).toHaveLength(1);
expect(abortCalled).toBe(true);
expect(getReturnCalls()).toBe(1);
expect(
received.some((event) => event.type === "error" && (event as any).reason === "error"),
).toBe(true);
await consumer;
});
it("aborts stream creation when the upstream stream promise never resolves", async () => {
let abortCalled = false;
const underlying = vi.fn((_model, _context, options) => {
if (options?.signal) {
options.signal.addEventListener("abort", () => {
abortCalled = true;
});
}
return new Promise<StreamLike>(() => {
// keep pending
});
});
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
});
const downstream = await Promise.resolve(
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
);
expect(downstream).toBeDefined();
if (!downstream) {
return;
}
const received: AnyEvent[] = [];
const consumer = (async () => {
for await (const event of downstream) {
received.push(event);
}
})();
await vi.advanceTimersByTimeAsync(6_000);
expect(abortCalled).toBe(true);
expect(
received.some((event) => event.type === "error" && (event as any).reason === "error"),
).toBe(true);
await consumer;
});
it("aborts through the fallback combined signal when no first event arrives", async () => {
const abortSignalAnyDescriptor = disableAbortSignalAny();
const { stream: baseStream } = createFakeBaseStream();
let abortCalled = false;
try {
const underlying = vi.fn((_model, _context, options) => {
if (options?.signal) {
options.signal.addEventListener("abort", () => {
abortCalled = true;
});
}
return baseStream;
});
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
});
const downstream = await Promise.resolve(
wrapper(
{ provider: "opencode-go", id: "deepseek-v4-flash" } as any,
{} as any,
{ signal: new AbortController().signal } as any,
),
);
expect(downstream).toBeDefined();
if (!downstream) {
return;
}
const consumer = (async () => {
for await (const event of downstream) {
void event;
}
})();
await vi.advanceTimersByTimeAsync(6_000);
expect(abortCalled).toBe(true);
await consumer;
} finally {
restoreAbortSignalAny(abortSignalAnyDescriptor);
}
});
it("cleans up fallback AbortSignal listeners after natural completion", async () => {
const abortSignalAnyDescriptor = disableAbortSignalAny();
const sourceController = new AbortController();
const addEventListener = vi.spyOn(sourceController.signal, "addEventListener");
const removeEventListener = vi.spyOn(sourceController.signal, "removeEventListener");
const { stream: baseStream, controller } = createFakeBaseStream();
try {
const wrapper = createOpencodeGoStalledStreamWrapper(vi.fn(() => baseStream) as any, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
});
const downstream = await Promise.resolve(
wrapper(
{ provider: "opencode-go", id: "deepseek-v4-flash" } as any,
{} as any,
{ signal: sourceController.signal } as any,
),
);
expect(downstream).toBeDefined();
if (!downstream) {
return;
}
const received: AnyEvent[] = [];
const consumer = (async () => {
for await (const event of downstream) {
received.push(event);
}
})();
const partial = {
role: "assistant",
content: [{ type: "text", text: "done" }],
stopReason: "stop",
};
controller.emit({ type: "start", partial } as any);
controller.emit({ type: "done", reason: "stop", message: partial } as any);
await consumer;
expect(received.some((event) => event.type === "done")).toBe(true);
expect(addEventListener).toHaveBeenCalledWith("abort", expect.any(Function), { once: true });
expect(removeEventListener).toHaveBeenCalledWith("abort", expect.any(Function));
} finally {
restoreAbortSignalAny(abortSignalAnyDescriptor);
addEventListener.mockRestore();
removeEventListener.mockRestore();
}
});
it("preserves normal delayed usage-only completion without aborting", async () => {
// Arrange: a fake base stream that streams a normal completion, including
// a long quiet gap before the final usage-only delta — but well within the
// idle timeout. The wrapper must not abort.
const { stream: baseStream, controller } = createFakeBaseStream();
void baseStream;
let abortCalled = false;
const capturedSignals: AbortSignal[] = [];
const underlying = vi.fn((_model, _context, options) => {
if (options?.signal) {
capturedSignals.push(options.signal);
options.signal.addEventListener("abort", () => {
abortCalled = true;
});
}
return baseStream;
});
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
});
const downstream = await Promise.resolve(
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
);
expect(downstream).toBeDefined();
if (!downstream) {
return;
}
const received: AnyEvent[] = [];
const consumer = (async () => {
for await (const event of downstream) {
received.push(event);
}
})();
const partial = {
role: "assistant",
content: [{ type: "text", text: "hello" }],
stopReason: "stop",
};
controller.emit({ type: "start", partial } as any);
controller.emit({
type: "text_delta",
contentIndex: 0,
delta: "hello",
partial,
} as any);
// Simulate a delayed final chunk after a short (sub-timeout) quiet gap.
await vi.advanceTimersByTimeAsync(2_000);
// Final completion event arrives before idle timeout fires.
controller.emit({
type: "done",
reason: "stop",
message: partial,
} as any);
// Advance well past the idle timeout — wrapper should NOT have fired.
await vi.advanceTimersByTimeAsync(10_000);
expect(abortCalled).toBe(false);
// Downstream must contain all forwarded events including the done event.
const doneEvent = received.find((event) => event.type === "done");
expect(doneEvent).toBeDefined();
// Cleanup
controller.end();
await consumer;
});
});

View File

@@ -1,371 +0,0 @@
// Opencode Go stream termination wrapper aborts stalled OpenAI-compatible
// SSE streams at the provider-owned raw boundary, before the shared runtime
// stuck-session recovery kicks in.
import type { AssistantMessage, AssistantMessageEvent } from "openclaw/plugin-sdk/llm";
import { createAssistantMessageEventStream } from "openclaw/plugin-sdk/llm";
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
type ProviderStreamFn = NonNullable<ProviderWrapStreamFnContext["streamFn"]>;
export interface OpencodeGoStalledStreamWrapperOptions {
/**
* Provider id this wrapper applies to. Calls whose model.provider does not
* match are forwarded untouched so the wrapper stays provider-scoped.
*/
provider: string;
/**
* Maximum idle window between two stream events before the wrapper treats
* the underlying SSE as stalled and aborts it. Must be > 0.
*/
idleTimeoutMs: number;
/**
* Maximum window for stream creation and first event delivery. Must be > 0.
*/
firstEventTimeoutMs?: number;
}
/**
* Default idle window used in production. Matches the runtime's shared
* `DEFAULT_LLM_IDLE_TIMEOUT_MS` (120s) so non-cron interactive runs see
* no behavior change versus the existing watchdog, while cron runs — for
* which the runtime disables its idle watchdog entirely
* (`resolveLlmIdleTimeoutMs` returns 0 when `trigger === "cron"` and no
* explicit timeout is set) — finally get a provider-owned termination
* well before the ~622s stuck-session recovery kicks in.
*/
export const OPENCODE_GO_STREAM_IDLE_TIMEOUT_MS_DEFAULT = 120_000;
export const OPENCODE_GO_STREAM_FIRST_EVENT_TIMEOUT_MS_DEFAULT = 300_000;
function isOpencodeGoModel(model: unknown, providerId: string): boolean {
return Boolean(model) && typeof model === "object"
? (model as { provider?: unknown }).provider === providerId
: false;
}
function validTimeoutMs(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
}
function resolveTimeoutMs(model: unknown, fallbackMs: number): number {
return validTimeoutMs((model as { requestTimeoutMs?: unknown })?.requestTimeoutMs) ?? fallbackMs;
}
function isProviderProgressEvent(event: AssistantMessageEvent): boolean {
return (
event.type === "text_delta" ||
event.type === "thinking_delta" ||
event.type === "toolcall_delta"
);
}
function combineAbortSignals(signals: (AbortSignal | undefined)[]): {
signal: AbortSignal;
cleanup(): void;
} {
const present = signals.filter((signal): signal is AbortSignal => Boolean(signal));
if (present.length === 0) {
return { signal: new AbortController().signal, cleanup: () => undefined };
}
if (present.length === 1) {
return { signal: present[0], cleanup: () => undefined };
}
const anyFn = (
AbortSignal as unknown as {
any?: (signals: AbortSignal[]) => AbortSignal;
}
).any;
if (typeof anyFn === "function") {
return { signal: anyFn(present), cleanup: () => undefined };
}
const controller = new AbortController();
const alreadyAborted = present.find((signal) => signal.aborted);
if (alreadyAborted) {
controller.abort((alreadyAborted as { reason?: unknown }).reason);
return { signal: controller.signal, cleanup: () => undefined };
}
const unsubscribe: Array<() => void> = [];
for (const signal of present) {
const onAbort = () => controller.abort((signal as { reason?: unknown }).reason);
signal.addEventListener("abort", onAbort, { once: true });
unsubscribe.push(() => signal.removeEventListener("abort", onAbort));
}
return {
signal: controller.signal,
cleanup() {
for (const remove of unsubscribe) {
remove();
}
unsubscribe.length = 0;
},
};
}
const STALLED_STREAM_ERROR_MESSAGE =
"opencode-go stream timed out after provider-owned SSE boundary stalled";
function buildStalledErrorEvent(partial: AssistantMessage | undefined): AssistantMessageEvent {
if (partial) {
return {
type: "error",
reason: "error",
error: {
...partial,
stopReason: "error",
errorMessage: STALLED_STREAM_ERROR_MESSAGE,
},
};
}
return {
type: "error",
reason: "error",
error: synthesizeMinimalAssistantMessage(STALLED_STREAM_ERROR_MESSAGE, "error"),
};
}
function buildUnterminatedErrorEvent(partial: AssistantMessage | undefined): AssistantMessageEvent {
if (partial) {
return {
type: "error",
reason: "error",
error: {
...partial,
stopReason: "error",
errorMessage: "opencode-go stream ended without a terminal event",
},
};
}
return {
type: "error",
reason: "error",
error: synthesizeMinimalAssistantMessage(
"opencode-go stream ended without a terminal event",
"error",
),
};
}
function buildCaughtErrorEvent(
partial: AssistantMessage | undefined,
error: unknown,
): AssistantMessageEvent {
const message = error instanceof Error ? error.message : String(error);
if (partial) {
return {
type: "error",
reason: "error",
error: {
...partial,
stopReason: "error",
errorMessage: message,
},
};
}
return {
type: "error",
reason: "error",
error: synthesizeMinimalAssistantMessage(message, "error"),
};
}
function synthesizeMinimalAssistantMessage(
errorMessage: string,
stopReason: AssistantMessage["stopReason"],
): AssistantMessage {
return {
role: "assistant",
content: [],
api: "openai-completions",
provider: "opencode-go",
model: "",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason,
errorMessage,
timestamp: Date.now(),
};
}
/**
* Wraps an opencode-go provider stream function so that an SSE socket that
* fails to deliver a first event or stops producing progress is aborted at the
* provider-owned raw boundary via the injected AbortSignal, instead of waiting
* for the much later shared runtime stuck-session recovery.
*
* Behavior:
* - Provider-scoped: only applies when `model.provider === options.provider`.
* - Idle-based: the timer covers stream creation, first event delivery, and
* every gap after provider progress begins; if no event arrives within
* `idleTimeoutMs`, the wrapper calls `controller.abort()` on the AbortSignal
* injected into the underlying call (so the OpenAI SDK request is genuinely
* interrupted, not just the iterator) and pushes a terminal `error` event
* downstream.
* - Terminal-safe: when the underlying stream emits `done` or `error`, the
* wrapper forwards the event, clears all timers, and ends the stream.
*
* The wrapper never shortens the natural end of a normal completion, because
* provider progress refreshes the idle timer and a terminal event cancels it entirely.
*/
export function createOpencodeGoStalledStreamWrapper(
underlying: ProviderStreamFn,
options: OpencodeGoStalledStreamWrapperOptions,
): ProviderStreamFn {
if (!options || options.idleTimeoutMs <= 0) {
throw new Error("createOpencodeGoStalledStreamWrapper requires idleTimeoutMs > 0");
}
if (options.firstEventTimeoutMs !== undefined && options.firstEventTimeoutMs <= 0) {
throw new Error("createOpencodeGoStalledStreamWrapper requires firstEventTimeoutMs > 0");
}
const providerId = options.provider;
const idleTimeoutMsDefault = options.idleTimeoutMs;
const firstEventTimeoutMsDefault = options.firstEventTimeoutMs ?? options.idleTimeoutMs;
return (model, context, callOptions) => {
if (!isOpencodeGoModel(model, providerId)) {
return underlying(model, context, callOptions);
}
const output = createAssistantMessageEventStream();
const idleTimeoutMs = resolveTimeoutMs(model, idleTimeoutMsDefault);
const firstEventTimeoutMs = resolveTimeoutMs(model, firstEventTimeoutMsDefault);
const controller = new AbortController();
const combinedSignal = combineAbortSignals([
(callOptions as { signal?: AbortSignal } | undefined)?.signal,
controller.signal,
]);
const wrappedOptions = {
...callOptions,
signal: combinedSignal.signal,
};
let idleTimer: ReturnType<typeof setTimeout> | undefined;
let lastSeenPartial: AssistantMessage | undefined;
let settled = false;
let baseIterator: AsyncIterator<AssistantMessageEvent> | undefined;
const clearIdleTimer = () => {
if (idleTimer !== undefined) {
clearTimeout(idleTimer);
idleTimer = undefined;
}
};
const cleanup = () => {
clearIdleTimer();
combinedSignal.cleanup();
};
const releaseBaseStream = () => {
if (baseIterator?.return) {
void Promise.resolve(baseIterator.return()).catch(() => undefined);
}
};
const finishWith = (event: AssistantMessageEvent) => {
if (settled) {
return;
}
settled = true;
cleanup();
output.push(event);
output.end(
event.type === "done" ? (event as { message: AssistantMessage }).message : undefined,
);
};
const abortStalledStream = () => {
if (settled) {
return;
}
settled = true;
clearIdleTimer();
controller.abort(new Error("opencode-go stream stalled"));
combinedSignal.cleanup();
releaseBaseStream();
output.push(buildStalledErrorEvent(lastSeenPartial));
output.end();
};
const armTimer = (timeoutMs: number) => {
clearIdleTimer();
idleTimer = setTimeout(abortStalledStream, timeoutMs);
idleTimer.unref?.();
};
const armFirstEventTimer = () => armTimer(firstEventTimeoutMs);
const armIdleTimer = () => armTimer(idleTimeoutMs);
const trackPartial = (event: AssistantMessageEvent) => {
const partial =
(event as { partial?: AssistantMessage; message?: AssistantMessage }).partial ??
(event as { message?: AssistantMessage }).message;
if (partial) {
lastSeenPartial = partial;
}
};
const releaseResolvedStream = (baseStream: AsyncIterable<AssistantMessageEvent>) => {
const iterator = baseStream[Symbol.asyncIterator]();
if (iterator.return) {
void Promise.resolve(iterator.return()).catch(() => undefined);
}
};
armFirstEventTimer();
let baseStreamResult: ReturnType<ProviderStreamFn>;
try {
baseStreamResult = underlying(model, context, wrappedOptions);
} catch (error) {
cleanup();
throw error;
}
void (async () => {
try {
const baseStream = await Promise.resolve(
baseStreamResult as Awaited<ReturnType<ProviderStreamFn>>,
);
if (settled) {
releaseResolvedStream(baseStream as AsyncIterable<AssistantMessageEvent>);
return;
}
baseIterator = (baseStream as AsyncIterable<AssistantMessageEvent>)[Symbol.asyncIterator]();
for (;;) {
const result = await baseIterator.next();
if (settled) {
return;
}
if (result.done) {
finishWith(buildUnterminatedErrorEvent(lastSeenPartial));
return;
}
const event = result.value;
if (event.type === "done" || event.type === "error") {
trackPartial(event);
finishWith(event);
return;
}
trackPartial(event);
output.push(event);
if (isProviderProgressEvent(event)) {
armIdleTimer();
}
}
} catch (error) {
if (!settled) {
finishWith(buildCaughtErrorEvent(lastSeenPartial, error));
}
} finally {
cleanup();
}
})();
return output;
};
}

View File

@@ -6,11 +6,6 @@ import {
} from "openclaw/plugin-sdk/provider-stream-shared";
import { isOpencodeGoKimiNoReasoningModelId } from "./provider-catalog.js";
import { stripOpencodeGoKimiReasoningPayload } from "./reasoning-sanitizer.js";
import {
createOpencodeGoStalledStreamWrapper,
OPENCODE_GO_STREAM_FIRST_EVENT_TIMEOUT_MS_DEFAULT,
OPENCODE_GO_STREAM_IDLE_TIMEOUT_MS_DEFAULT,
} from "./stream-termination.js";
function isOpencodeGoDeepSeekV4ModelId(modelId: unknown): boolean {
return modelId === "deepseek-v4-flash" || modelId === "deepseek-v4-pro";
@@ -51,18 +46,6 @@ export function createOpencodeGoWrapper(
baseStreamFn: ProviderWrapStreamFnContext["streamFn"],
thinkingLevel: ProviderWrapStreamFnContext["thinkingLevel"],
): ProviderWrapStreamFnContext["streamFn"] {
if (!baseStreamFn) {
return undefined;
}
const kimiWrapped = createOpencodeGoKimiNoReasoningWrapper(baseStreamFn) ?? baseStreamFn;
const deepSeekWrapped =
createOpencodeGoDeepSeekV4Wrapper(kimiWrapped, thinkingLevel) ?? kimiWrapped;
// Outermost layer: provider-owned stalled SSE termination so the underlying
// OpenAI SDK request is aborted at the raw opencode-go boundary instead of
// waiting for the shared runtime stuck-session recovery.
return createOpencodeGoStalledStreamWrapper(deepSeekWrapped, {
provider: "opencode-go",
idleTimeoutMs: OPENCODE_GO_STREAM_IDLE_TIMEOUT_MS_DEFAULT,
firstEventTimeoutMs: OPENCODE_GO_STREAM_FIRST_EVENT_TIMEOUT_MS_DEFAULT,
});
return createOpencodeGoDeepSeekV4Wrapper(kimiWrapped, thinkingLevel) ?? kimiWrapped;
}

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