mirror of https://github.com/openclaw/openclaw.git
Merge branch 'main' into main
This commit is contained in:
commit
2e9549a39b
|
|
@ -393,6 +393,7 @@ jobs:
|
|||
}
|
||||
|
||||
const invalidLabel = "invalid";
|
||||
const spamLabel = "r: spam";
|
||||
const dirtyLabel = "dirty";
|
||||
const noisyPrMessage =
|
||||
"Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch.";
|
||||
|
|
@ -429,6 +430,21 @@ jobs:
|
|||
});
|
||||
return;
|
||||
}
|
||||
if (labelSet.has(spamLabel)) {
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
state: "closed",
|
||||
});
|
||||
await github.rest.issues.lock({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
lock_reason: "spam",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (labelSet.has(invalidLabel)) {
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
|
|
@ -440,6 +456,23 @@ jobs:
|
|||
}
|
||||
}
|
||||
|
||||
if (issue && labelSet.has(spamLabel)) {
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: "closed",
|
||||
state_reason: "not_planned",
|
||||
});
|
||||
await github.rest.issues.lock({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
lock_reason: "spam",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (issue && labelSet.has(invalidLabel)) {
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ jobs:
|
|||
- name: Set up Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@v1
|
||||
|
||||
# Blacksmith can fall back to the local docker driver, which rejects gha
|
||||
# cache export/import. Keep smoke builds driver-agnostic.
|
||||
- name: Build root Dockerfile smoke image
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
with:
|
||||
|
|
@ -52,8 +54,6 @@ jobs:
|
|||
load: true
|
||||
push: false
|
||||
provenance: false
|
||||
cache-from: type=gha,scope=install-smoke-root-dockerfile
|
||||
cache-to: type=gha,mode=max,scope=install-smoke-root-dockerfile
|
||||
|
||||
- name: Run root Dockerfile CLI smoke
|
||||
run: |
|
||||
|
|
@ -73,8 +73,6 @@ jobs:
|
|||
load: true
|
||||
push: false
|
||||
provenance: false
|
||||
cache-from: type=gha,scope=install-smoke-root-dockerfile-ext
|
||||
cache-to: type=gha,mode=max,scope=install-smoke-root-dockerfile-ext
|
||||
|
||||
- name: Smoke test Dockerfile with extension build arg
|
||||
run: |
|
||||
|
|
@ -89,8 +87,6 @@ jobs:
|
|||
load: true
|
||||
push: false
|
||||
provenance: false
|
||||
cache-from: type=gha,scope=install-smoke-installer-root
|
||||
cache-to: type=gha,mode=max,scope=install-smoke-installer-root
|
||||
|
||||
- name: Build installer non-root image
|
||||
if: github.event_name != 'pull_request'
|
||||
|
|
@ -102,8 +98,6 @@ jobs:
|
|||
load: true
|
||||
push: false
|
||||
provenance: false
|
||||
cache-from: type=gha,scope=install-smoke-installer-nonroot
|
||||
cache-to: type=gha,mode=max,scope=install-smoke-installer-nonroot
|
||||
|
||||
- name: Run installer docker tests
|
||||
env:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
name: OpenClaw NPM Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
concurrency:
|
||||
group: openclaw-npm-release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
NODE_VERSION: "22.x"
|
||||
PNPM_VERSION: "10.23.0"
|
||||
|
||||
jobs:
|
||||
publish_openclaw_npm:
|
||||
# npm trusted publishing + provenance requires a GitHub-hosted runner.
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Validate release tag and package metadata
|
||||
env:
|
||||
RELEASE_SHA: ${{ github.sha }}
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
RELEASE_MAIN_REF: origin/main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Fetch the full main ref so merge-base ancestry checks keep working
|
||||
# for older tagged commits that are still contained in main.
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
pnpm release:openclaw:npm:check
|
||||
|
||||
- name: Ensure version is not already published
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||
|
||||
if npm view "openclaw@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
|
||||
echo "openclaw@${PACKAGE_VERSION} is already published on npm."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Publishing openclaw@${PACKAGE_VERSION}"
|
||||
|
||||
- name: Check
|
||||
run: pnpm check
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Verify release contents
|
||||
run: pnpm release:check
|
||||
|
||||
- name: Publish
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||
|
||||
if [[ "$PACKAGE_VERSION" == *-beta.* ]]; then
|
||||
npm publish --access public --tag beta --provenance
|
||||
else
|
||||
npm publish --access public --provenance
|
||||
fi
|
||||
|
|
@ -81,6 +81,7 @@ apps/ios/*.mobileprovision
|
|||
# Local untracked files
|
||||
.local/
|
||||
docs/.local/
|
||||
tmp/
|
||||
IDENTITY.md
|
||||
USER.md
|
||||
.tgz
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
- `r: testflight`: close requests asking for TestFlight access/builds. OpenClaw does not provide TestFlight distribution yet, so use the standard response (“Not available, build from source.”) instead of ad-hoc replies.
|
||||
- `r: third-party-extension`: close with guidance to ship as third-party plugin.
|
||||
- `r: moltbook`: close + lock as off-topic (not affiliated).
|
||||
- `r: spam`: close + lock as spam (`lock_reason: spam`).
|
||||
- `invalid`: close invalid items (issues are closed as `not_planned`; PRs are closed).
|
||||
- `dirty`: close PRs with too many unrelated/unexpected changes (PR-only label).
|
||||
|
||||
|
|
|
|||
21
CHANGELOG.md
21
CHANGELOG.md
|
|
@ -10,6 +10,9 @@ Docs: https://docs.openclaw.ai
|
|||
- Git/runtime state: ignore the gateway-generated `.dev-state` file so local runtime state does not show up as untracked repo noise. (#41848) Thanks @smysle.
|
||||
- ACP/sessions_spawn: add optional `resumeSessionId` for `runtime: "acp"` so spawned ACP sessions can resume an existing ACPX/Codex conversation instead of always starting fresh. (#41847) Thanks @pejmanjohn.
|
||||
- Exec/child commands: mark child command environments with `OPENCLAW_CLI` so subprocesses can detect when they were launched from the OpenClaw CLI. (#41411) Thanks @vincentkoc.
|
||||
- iOS/Home canvas: add a bundled welcome screen with a live agent overview that refreshes on connect, reconnect, and foreground return, and move the compact connection pill off the top-left canvas overlay. (#42456) Thanks @ngutman.
|
||||
- iOS/Home canvas: replace floating controls with a docked toolbar, make the bundled home scaffold adapt to smaller phones, and open chat in the resolved main session instead of a synthetic `ios` session. (#42456) Thanks @ngutman.
|
||||
- Discord/auto threads: add `autoArchiveDuration` channel config for auto-created threads so Discord thread archiving can stay at 1 hour, 1 day, 3 days, or 1 week instead of always using the 1-hour default. (#35065) Thanks @davidguttman.
|
||||
|
||||
### Breaking
|
||||
|
||||
|
|
@ -23,6 +26,8 @@ Docs: https://docs.openclaw.ai
|
|||
- ACP/ACPX plugin: bump the bundled `acpx` pin to `0.1.16` so plugin-local installs and strict version checks match the latest published CLI. (#41975) Thanks @dutifulbob.
|
||||
- macOS/LaunchAgent install: tighten LaunchAgent directory and plist permissions during install so launchd bootstrap does not fail when the target home path or generated plist inherited group/world-writable modes.
|
||||
- Gateway/Control UI: keep dashboard auth tokens in session-scoped browser storage so same-tab refreshes preserve remote token auth without restoring long-lived localStorage token persistence, while scoping tokens to the selected gateway URL and fragment-only bootstrap flow. (#40892) thanks @velvet-shark.
|
||||
- Secret files: harden CLI and channel credential file reads against path-swap races by requiring direct regular files for `*File` secret inputs and rejecting symlink-backed secret files.
|
||||
- Archive extraction: harden TAR and external `tar.bz2` installs against destination symlink and pre-existing child-symlink escapes by extracting into staging first and merging into the canonical destination with safe file opens.
|
||||
- Models/Kimi Coding: send `anthropic-messages` tools in native Anthropic format again so `kimi-coding` stops degrading tool calls into XML/plain-text pseudo invocations instead of real `tool_use` blocks. (#38669, #39907, #40552) Thanks @opriz.
|
||||
- Context engine/tests: add bundled-registry regression coverage for cross-chunk resolution, plugin-sdk re-exports, and concurrent chunk registration. (#40460) thanks @dsantoreis.
|
||||
- Agents/embedded runner: bound compaction retry waiting and drain embedded runs during SIGUSR1 restart so session lanes recover instead of staying blocked behind compaction. (#40324) thanks @cgdusek.
|
||||
|
|
@ -65,6 +70,19 @@ Docs: https://docs.openclaw.ai
|
|||
- Agents/fallback cooldown probing: cap cooldown-bypass probing to one attempt per provider per fallback run so multi-model same-provider cooldown chains can continue to cross-provider fallbacks instead of repeatedly stalling on duplicate cooldown probes. (#41711) Thanks @cgdusek.
|
||||
- Telegram/direct delivery: bridge direct delivery sends to internal `message:sent` hooks so internal hook listeners observe successful Telegram deliveries. (#40185) Thanks @vincentkoc.
|
||||
- Plugins/global hook runner: harden singleton state handling so shared global hook runner reuse does not leak or corrupt runner state across executions. (#40184) Thanks @vincentkoc.
|
||||
- Agents/fallback: recognize Poe `402 You've used up your points!` billing errors so configured model fallbacks trigger instead of surfacing the raw provider error. (#42278) Thanks @CryUshio.
|
||||
- Telegram/outbound HTML sends: chunk long HTML-mode messages, preserve plain-text fallback and silent-delivery params across retries, and cut over to plain text when HTML chunk planning cannot safely preserve the full message. (#42240) thanks @obviyus.
|
||||
- Agents/embedded overload logs: include the failing model and provider in error-path console output, with lifecycle regression coverage for the rendered and sanitized `consoleMessage`. (#41236) thanks @jiarung.
|
||||
- Agents/failover: treat Gemini `MALFORMED_RESPONSE` stop reasons as retryable timeouts so preview-model enum drift falls back cleanly instead of crashing the run, without also reclassifying malformed function-call errors. (#42292) Thanks @jnMetaCode.
|
||||
- Discord/Telegram outbound runtime config: thread runtime-resolved config through Discord and Telegram send paths so SecretRef-based credentials stay resolved during message delivery. (#42352) Thanks @joshavant.
|
||||
- Secrets/SecretRef: reject exec SecretRef traversal ids across schema, runtime, and gateway. (#42370) Thanks @joshavant.
|
||||
- Telegram/docs: clarify that `channels.telegram.groups` allowlists chats while `groupAllowFrom` allowlists users inside those chats, and point invalid negative chat IDs at the right config key. (#42451) Thanks @altaywtf.
|
||||
- Models/Alibaba Cloud Model Studio: wire `MODELSTUDIO_API_KEY` through shared env auth, implicit provider discovery, and shell-env fallback so onboarding works outside the wizard too. (#40634) Thanks @pomelo-nwu.
|
||||
- ACP/sessions_spawn: implicitly stream `mode="run"` ACP spawns to parent only for eligible subagent orchestrator sessions (heartbeat `target: "last"` with a usable session-local route), restoring parent progress relays without thread binding. (#42404) Thanks @davidguttman.
|
||||
- Sessions/reset model recompute: clear stale runtime model, context-token, and system-prompt metadata before session resets recompute the replacement session, so resets pick up current defaults and explicit overrides instead of reusing old runtime model state. (#41173) thanks @PonyX-lab.
|
||||
- Browser/Browserbase 429 handling: surface stable no-retry rate-limit guidance without buffering discarded HTTP 429 response bodies from remote browser services. (#40491) thanks @mvanhorn.
|
||||
- Gateway/auth: allow one trusted device-token retry on shared-token mismatch with recovery hints to prevent reconnect churn during token drift. (#42507) Thanks @joshavant.
|
||||
- Channels/allowlists: remove stale matcher caching so same-array allowlist edits and wildcard replacements take effect immediately, with regression coverage for in-place mutation cases.
|
||||
|
||||
## 2026.3.8
|
||||
|
||||
|
|
@ -131,6 +149,9 @@ Docs: https://docs.openclaw.ai
|
|||
- Security/system.run: bind approved `bun` and `deno run` script operands to on-disk file snapshots so post-approval script rewrites are denied before execution.
|
||||
- Skills/download installs: pin the validated per-skill tools root before writing downloaded archives, so rebinding the lexical tools path cannot redirect download writes outside the intended tools directory. Thanks @tdjackey.
|
||||
- Control UI/Debug: replace the Manual RPC free-text method field with a sorted dropdown sourced from gateway-advertised methods, and stack the form vertically for narrower layouts. (#14967) thanks @rixau.
|
||||
- Auth/profile resolution: log debug details when auto-discovered auth profiles fail during provider API-key resolution, so `--debug` output surfaces the real refresh/keychain/credential-store failure instead of only the generic missing-key message. (#41271) thanks @he-yufeng.
|
||||
- ACP/cancel scoping: scope `chat.abort` and shared-session ACP event routing by `runId` so one session cannot cancel or consume another session's run when they share the same gateway session key. (#41331) Thanks @pejmanjohn.
|
||||
- SecretRef/models: harden custom/provider secret persistence and reuse across models.json snapshots, merge behavior, runtime headers, and secret audits. (#42554) Thanks @joshavant.
|
||||
|
||||
## 2026.3.7
|
||||
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ Welcome to the lobster tank! 🦞
|
|||
|
||||
- Test locally with your OpenClaw instance
|
||||
- Run tests: `pnpm build && pnpm check && pnpm test`
|
||||
- If you have access to Codex, run `codex review --base origin/main` locally before opening or updating your PR. Treat this as the current highest standard of AI review, even if GitHub Codex review also runs.
|
||||
- Ensure CI checks pass
|
||||
- Keep PRs focused (one thing per PR; do not mix unrelated concerns)
|
||||
- Describe what & why
|
||||
|
|
@ -99,6 +100,8 @@ If a review bot leaves review conversations on your PR, you are expected to hand
|
|||
- Resolve the conversation yourself once the code or explanation fully addresses the bot's concern
|
||||
- Reply and leave it open only when you need maintainer or reviewer judgment
|
||||
- Do not leave "fixed" bot review conversations for maintainers to clean up for you
|
||||
- If Codex leaves comments, address every relevant one or resolve it with a short explanation when it is not applicable to your change
|
||||
- If GitHub Codex review does not trigger for some reason, run `codex review --base origin/main` locally anyway and treat that output as required review work
|
||||
|
||||
This applies to both human-authored and AI-assisted PRs.
|
||||
|
||||
|
|
@ -127,6 +130,7 @@ Please include in your PR:
|
|||
- [ ] Note the degree of testing (untested / lightly tested / fully tested)
|
||||
- [ ] Include prompts or session logs if possible (super helpful!)
|
||||
- [ ] Confirm you understand what the code does
|
||||
- [ ] If you have access to Codex, run `codex review --base origin/main` locally and address the findings before asking for review
|
||||
- [ ] Resolve or reply to bot review conversations after you address them
|
||||
|
||||
AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for. If you are using an LLM coding agent, instruct it to resolve bot review conversations it has addressed instead of leaving them for maintainers.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,223 @@
|
|||
import SwiftUI
|
||||
|
||||
struct HomeToolbar: View {
|
||||
var gateway: StatusPill.GatewayState
|
||||
var voiceWakeEnabled: Bool
|
||||
var activity: StatusPill.Activity?
|
||||
var brighten: Bool
|
||||
var talkButtonEnabled: Bool
|
||||
var talkActive: Bool
|
||||
var talkTint: Color
|
||||
var onStatusTap: () -> Void
|
||||
var onChatTap: () -> Void
|
||||
var onTalkTap: () -> Void
|
||||
var onSettingsTap: () -> Void
|
||||
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(.white.opacity(self.contrast == .increased ? 0.46 : (self.brighten ? 0.18 : 0.12)))
|
||||
.frame(height: self.contrast == .increased ? 1.0 : 0.6)
|
||||
.allowsHitTesting(false)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
HomeToolbarStatusButton(
|
||||
gateway: self.gateway,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
activity: self.activity,
|
||||
brighten: self.brighten,
|
||||
onTap: self.onStatusTap)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
HomeToolbarActionButton(
|
||||
systemImage: "text.bubble.fill",
|
||||
accessibilityLabel: "Chat",
|
||||
brighten: self.brighten,
|
||||
action: self.onChatTap)
|
||||
|
||||
if self.talkButtonEnabled {
|
||||
HomeToolbarActionButton(
|
||||
systemImage: self.talkActive ? "waveform.circle.fill" : "waveform.circle",
|
||||
accessibilityLabel: self.talkActive ? "Talk Mode On" : "Talk Mode Off",
|
||||
brighten: self.brighten,
|
||||
tint: self.talkTint,
|
||||
isActive: self.talkActive,
|
||||
action: self.onTalkTap)
|
||||
}
|
||||
|
||||
HomeToolbarActionButton(
|
||||
systemImage: "gearshape.fill",
|
||||
accessibilityLabel: "Settings",
|
||||
brighten: self.brighten,
|
||||
action: self.onSettingsTap)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.ultraThinMaterial)
|
||||
.overlay(alignment: .top) {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
.white.opacity(self.brighten ? 0.10 : 0.06),
|
||||
.clear,
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HomeToolbarStatusButton: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
var gateway: StatusPill.GatewayState
|
||||
var voiceWakeEnabled: Bool
|
||||
var activity: StatusPill.Activity?
|
||||
var brighten: Bool
|
||||
var onTap: () -> Void
|
||||
|
||||
@State private var pulse: Bool = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: self.onTap) {
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(self.gateway.color)
|
||||
.frame(width: 8, height: 8)
|
||||
.scaleEffect(
|
||||
self.gateway == .connecting && !self.reduceMotion
|
||||
? (self.pulse ? 1.15 : 0.85)
|
||||
: 1.0
|
||||
)
|
||||
.opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0)
|
||||
|
||||
Text(self.gateway.title)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
if let activity {
|
||||
Image(systemName: activity.systemImage)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(activity.tint ?? .primary)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
} else {
|
||||
Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(Color.black.opacity(self.brighten ? 0.12 : 0.18))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(
|
||||
.white.opacity(self.contrast == .increased ? 0.46 : (self.brighten ? 0.22 : 0.16)),
|
||||
lineWidth: self.contrast == .increased ? 1.0 : 0.6)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Connection Status")
|
||||
.accessibilityValue(self.accessibilityValue)
|
||||
.accessibilityHint(self.gateway == .connected ? "Double tap for gateway actions" : "Double tap to open settings")
|
||||
.onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion) }
|
||||
.onDisappear { self.pulse = false }
|
||||
.onChange(of: self.gateway) { _, newValue in
|
||||
self.updatePulse(for: newValue, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion)
|
||||
}
|
||||
.onChange(of: self.scenePhase) { _, newValue in
|
||||
self.updatePulse(for: self.gateway, scenePhase: newValue, reduceMotion: self.reduceMotion)
|
||||
}
|
||||
.onChange(of: self.reduceMotion) { _, newValue in
|
||||
self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: newValue)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.18), value: self.activity?.title)
|
||||
}
|
||||
|
||||
private var accessibilityValue: String {
|
||||
if let activity {
|
||||
return "\(self.gateway.title), \(activity.title)"
|
||||
}
|
||||
return "\(self.gateway.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")"
|
||||
}
|
||||
|
||||
private func updatePulse(for gateway: StatusPill.GatewayState, scenePhase: ScenePhase, reduceMotion: Bool) {
|
||||
guard gateway == .connecting, scenePhase == .active, !reduceMotion else {
|
||||
withAnimation(reduceMotion ? .none : .easeOut(duration: 0.2)) { self.pulse = false }
|
||||
return
|
||||
}
|
||||
|
||||
guard !self.pulse else { return }
|
||||
withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) {
|
||||
self.pulse = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HomeToolbarActionButton: View {
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
let systemImage: String
|
||||
let accessibilityLabel: String
|
||||
let brighten: Bool
|
||||
var tint: Color?
|
||||
var isActive: Bool = false
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: self.action) {
|
||||
Image(systemName: self.systemImage)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(self.isActive ? (self.tint ?? .primary) : .primary)
|
||||
.frame(width: 40, height: 40)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color.black.opacity(self.brighten ? 0.12 : 0.18))
|
||||
.overlay {
|
||||
if let tint {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
tint.opacity(self.isActive ? 0.22 : 0.14),
|
||||
tint.opacity(self.isActive ? 0.08 : 0.04),
|
||||
.clear,
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing))
|
||||
.blendMode(.overlay)
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(
|
||||
(self.tint ?? .white).opacity(
|
||||
self.isActive
|
||||
? 0.34
|
||||
: (self.contrast == .increased ? 0.4 : (self.brighten ? 0.22 : 0.16))
|
||||
),
|
||||
lineWidth: self.contrast == .increased ? 1.0 : (self.isActive ? 0.8 : 0.6))
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(self.accessibilityLabel)
|
||||
}
|
||||
}
|
||||
|
|
@ -34,18 +34,11 @@ extension NodeAppModel {
|
|||
}
|
||||
|
||||
func showA2UIOnConnectIfNeeded() async {
|
||||
let current = self.screen.urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if current.isEmpty || current == self.lastAutoA2uiURL {
|
||||
if let canvasUrl = await self.resolveCanvasHostURLWithCapabilityRefresh(),
|
||||
let url = URL(string: canvasUrl),
|
||||
await Self.probeTCP(url: url, timeoutSeconds: 2.5)
|
||||
{
|
||||
self.screen.navigate(to: canvasUrl)
|
||||
self.lastAutoA2uiURL = canvasUrl
|
||||
} else {
|
||||
self.lastAutoA2uiURL = nil
|
||||
self.screen.showDefaultCanvas()
|
||||
}
|
||||
await MainActor.run {
|
||||
// Keep the bundled home canvas as the default connected view.
|
||||
// Agents can still explicitly present a remote or local canvas later.
|
||||
self.lastAutoA2uiURL = nil
|
||||
self.screen.showDefaultCanvas()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ final class NodeAppModel {
|
|||
var selectedAgentId: String?
|
||||
var gatewayDefaultAgentId: String?
|
||||
var gatewayAgents: [AgentSummary] = []
|
||||
var homeCanvasRevision: Int = 0
|
||||
var lastShareEventText: String = "No share events yet."
|
||||
var openChatRequestID: Int = 0
|
||||
private(set) var pendingAgentDeepLinkPrompt: AgentDeepLinkPrompt?
|
||||
|
|
@ -548,6 +549,7 @@ final class NodeAppModel {
|
|||
self.seamColorHex = raw.isEmpty ? nil : raw
|
||||
self.mainSessionBaseKey = mainKey
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
self.homeCanvasRevision &+= 1
|
||||
}
|
||||
} catch {
|
||||
if let gatewayError = error as? GatewayResponseError {
|
||||
|
|
@ -574,12 +576,19 @@ final class NodeAppModel {
|
|||
self.selectedAgentId = nil
|
||||
}
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
self.homeCanvasRevision &+= 1
|
||||
}
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
}
|
||||
}
|
||||
|
||||
func refreshGatewayOverviewIfConnected() async {
|
||||
guard await self.isOperatorConnected() else { return }
|
||||
await self.refreshBrandingFromGateway()
|
||||
await self.refreshAgentsFromGateway()
|
||||
}
|
||||
|
||||
func setSelectedAgentId(_ agentId: String?) {
|
||||
let trimmed = (agentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let stableID = (self.connectedGatewayID ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
|
@ -590,6 +599,7 @@ final class NodeAppModel {
|
|||
GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: stableID, agentId: self.selectedAgentId)
|
||||
}
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
self.homeCanvasRevision &+= 1
|
||||
if let relay = ShareGatewayRelaySettings.loadConfig() {
|
||||
ShareGatewayRelaySettings.saveConfig(
|
||||
ShareGatewayRelayConfig(
|
||||
|
|
@ -1629,11 +1639,9 @@ extension NodeAppModel {
|
|||
}
|
||||
|
||||
var chatSessionKey: String {
|
||||
let base = "ios"
|
||||
let agentId = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let defaultId = (self.gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if agentId.isEmpty || (!defaultId.isEmpty && agentId == defaultId) { return base }
|
||||
return SessionKey.makeAgentSessionKey(agentId: agentId, baseKey: base)
|
||||
// Keep chat aligned with the gateway's resolved main session key.
|
||||
// A hardcoded "ios" base creates synthetic placeholder sessions in the chat UI.
|
||||
self.mainSessionKey
|
||||
}
|
||||
|
||||
var activeAgentName: String {
|
||||
|
|
@ -1749,6 +1757,7 @@ private extension NodeAppModel {
|
|||
self.gatewayDefaultAgentId = nil
|
||||
self.gatewayAgents = []
|
||||
self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: stableID)
|
||||
self.homeCanvasRevision &+= 1
|
||||
self.apnsLastRegisteredTokenHex = nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -536,7 +536,7 @@ struct OnboardingWizardView: View {
|
|||
Text(
|
||||
"Approve this device on the gateway.\n"
|
||||
+ "1) `openclaw devices approve` (or `openclaw devices approve <requestId>`)\n"
|
||||
+ "2) `/pair approve` in Telegram\n"
|
||||
+ "2) `/pair approve` in your OpenClaw chat\n"
|
||||
+ "\(requestLine)\n"
|
||||
+ "OpenClaw will also retry automatically when you return to this app.")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import SwiftUI
|
||||
import UIKit
|
||||
import OpenClawProtocol
|
||||
|
||||
struct RootCanvas: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
|
|
@ -137,16 +138,33 @@ struct RootCanvas: View {
|
|||
.environment(self.gatewayController)
|
||||
}
|
||||
.onAppear { self.updateIdleTimer() }
|
||||
.onAppear { self.updateHomeCanvasState() }
|
||||
.onAppear { self.evaluateOnboardingPresentation(force: false) }
|
||||
.onAppear { self.maybeAutoOpenSettings() }
|
||||
.onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() }
|
||||
.onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() }
|
||||
.onChange(of: self.scenePhase) { _, newValue in
|
||||
self.updateIdleTimer()
|
||||
self.updateHomeCanvasState()
|
||||
guard newValue == .active else { return }
|
||||
Task {
|
||||
await self.appModel.refreshGatewayOverviewIfConnected()
|
||||
await MainActor.run {
|
||||
self.updateHomeCanvasState()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear { self.maybeShowQuickSetup() }
|
||||
.onChange(of: self.gatewayController.gateways.count) { _, _ in self.maybeShowQuickSetup() }
|
||||
.onAppear { self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.gatewayStatusText) { _, _ in
|
||||
self.updateCanvasDebugStatus()
|
||||
self.updateHomeCanvasState()
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, _ in
|
||||
self.updateCanvasDebugStatus()
|
||||
self.updateHomeCanvasState()
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
||||
if newValue != nil {
|
||||
self.showOnboarding = false
|
||||
|
|
@ -155,7 +173,13 @@ struct RootCanvas: View {
|
|||
.onChange(of: self.onboardingRequestID) { _, _ in
|
||||
self.evaluateOnboardingPresentation(force: true)
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in
|
||||
self.updateCanvasDebugStatus()
|
||||
self.updateHomeCanvasState()
|
||||
}
|
||||
.onChange(of: self.appModel.homeCanvasRevision) { _, _ in
|
||||
self.updateHomeCanvasState()
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
||||
if newValue != nil {
|
||||
self.onboardingComplete = true
|
||||
|
|
@ -209,6 +233,134 @@ struct RootCanvas: View {
|
|||
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
|
||||
}
|
||||
|
||||
private func updateHomeCanvasState() {
|
||||
let payload = self.makeHomeCanvasPayload()
|
||||
guard let data = try? JSONEncoder().encode(payload),
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
self.appModel.screen.updateHomeCanvasState(json: nil)
|
||||
return
|
||||
}
|
||||
self.appModel.screen.updateHomeCanvasState(json: json)
|
||||
}
|
||||
|
||||
private func makeHomeCanvasPayload() -> HomeCanvasPayload {
|
||||
let gatewayName = self.normalized(self.appModel.gatewayServerName)
|
||||
let gatewayAddress = self.normalized(self.appModel.gatewayRemoteAddress)
|
||||
let gatewayLabel = gatewayName ?? gatewayAddress ?? "Gateway"
|
||||
let activeAgentID = self.resolveActiveAgentID()
|
||||
let agents = self.homeCanvasAgents(activeAgentID: activeAgentID)
|
||||
|
||||
switch self.gatewayStatus {
|
||||
case .connected:
|
||||
return HomeCanvasPayload(
|
||||
gatewayState: "connected",
|
||||
eyebrow: "Connected to \(gatewayLabel)",
|
||||
title: "Your agents are ready",
|
||||
subtitle:
|
||||
"This phone stays dormant until the gateway needs it, then wakes, syncs, and goes back to sleep.",
|
||||
gatewayLabel: gatewayLabel,
|
||||
activeAgentName: self.appModel.activeAgentName,
|
||||
activeAgentBadge: agents.first(where: { $0.isActive })?.badge ?? "OC",
|
||||
activeAgentCaption: "Selected on this phone",
|
||||
agentCount: agents.count,
|
||||
agents: Array(agents.prefix(6)),
|
||||
footer: "The overview refreshes on reconnect and when the app returns to foreground.")
|
||||
case .connecting:
|
||||
return HomeCanvasPayload(
|
||||
gatewayState: "connecting",
|
||||
eyebrow: "Reconnecting",
|
||||
title: "OpenClaw is syncing back up",
|
||||
subtitle:
|
||||
"The gateway session is coming back online. "
|
||||
+ "Agent shortcuts should settle automatically in a moment.",
|
||||
gatewayLabel: gatewayLabel,
|
||||
activeAgentName: self.appModel.activeAgentName,
|
||||
activeAgentBadge: "OC",
|
||||
activeAgentCaption: "Gateway session in progress",
|
||||
agentCount: agents.count,
|
||||
agents: Array(agents.prefix(4)),
|
||||
footer: "If the gateway is reachable, reconnect should complete without intervention.")
|
||||
case .error, .disconnected:
|
||||
return HomeCanvasPayload(
|
||||
gatewayState: self.gatewayStatus == .error ? "error" : "offline",
|
||||
eyebrow: "Welcome to OpenClaw",
|
||||
title: "Your phone stays quiet until it is needed",
|
||||
subtitle:
|
||||
"Pair this device to your gateway to wake it only for real work, "
|
||||
+ "keep a live agent overview handy, and avoid battery-draining background loops.",
|
||||
gatewayLabel: gatewayLabel,
|
||||
activeAgentName: "Main",
|
||||
activeAgentBadge: "OC",
|
||||
activeAgentCaption: "Connect to load your agents",
|
||||
agentCount: agents.count,
|
||||
agents: Array(agents.prefix(4)),
|
||||
footer:
|
||||
"When connected, the gateway can wake the phone with a silent push "
|
||||
+ "instead of holding an always-on session.")
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveActiveAgentID() -> String {
|
||||
let selected = self.normalized(self.appModel.selectedAgentId) ?? ""
|
||||
if !selected.isEmpty {
|
||||
return selected
|
||||
}
|
||||
return self.resolveDefaultAgentID()
|
||||
}
|
||||
|
||||
private func resolveDefaultAgentID() -> String {
|
||||
self.normalized(self.appModel.gatewayDefaultAgentId) ?? ""
|
||||
}
|
||||
|
||||
private func homeCanvasAgents(activeAgentID: String) -> [HomeCanvasAgentCard] {
|
||||
let defaultAgentID = self.resolveDefaultAgentID()
|
||||
let cards = self.appModel.gatewayAgents.map { agent -> HomeCanvasAgentCard in
|
||||
let isActive = !activeAgentID.isEmpty && agent.id == activeAgentID
|
||||
let isDefault = !defaultAgentID.isEmpty && agent.id == defaultAgentID
|
||||
return HomeCanvasAgentCard(
|
||||
id: agent.id,
|
||||
name: self.homeCanvasName(for: agent),
|
||||
badge: self.homeCanvasBadge(for: agent),
|
||||
caption: isActive ? "Active on this phone" : (isDefault ? "Default agent" : "Ready"),
|
||||
isActive: isActive)
|
||||
}
|
||||
|
||||
return cards.sorted { lhs, rhs in
|
||||
if lhs.isActive != rhs.isActive {
|
||||
return lhs.isActive
|
||||
}
|
||||
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
private func homeCanvasName(for agent: AgentSummary) -> String {
|
||||
self.normalized(agent.name) ?? agent.id
|
||||
}
|
||||
|
||||
private func homeCanvasBadge(for agent: AgentSummary) -> String {
|
||||
if let identity = agent.identity,
|
||||
let emoji = identity["emoji"]?.value as? String,
|
||||
let normalizedEmoji = self.normalized(emoji)
|
||||
{
|
||||
return normalizedEmoji
|
||||
}
|
||||
let words = self.homeCanvasName(for: agent)
|
||||
.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" })
|
||||
.prefix(2)
|
||||
let initials = words.compactMap { $0.first }.map(String.init).joined()
|
||||
if !initials.isEmpty {
|
||||
return initials.uppercased()
|
||||
}
|
||||
return "OC"
|
||||
}
|
||||
|
||||
private func normalized(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private func evaluateOnboardingPresentation(force: Bool) {
|
||||
if force {
|
||||
self.onboardingAllowSkip = true
|
||||
|
|
@ -274,6 +426,28 @@ struct RootCanvas: View {
|
|||
}
|
||||
}
|
||||
|
||||
private struct HomeCanvasPayload: Codable {
|
||||
var gatewayState: String
|
||||
var eyebrow: String
|
||||
var title: String
|
||||
var subtitle: String
|
||||
var gatewayLabel: String
|
||||
var activeAgentName: String
|
||||
var activeAgentBadge: String
|
||||
var activeAgentCaption: String
|
||||
var agentCount: Int
|
||||
var agents: [HomeCanvasAgentCard]
|
||||
var footer: String
|
||||
}
|
||||
|
||||
private struct HomeCanvasAgentCard: Codable {
|
||||
var id: String
|
||||
var name: String
|
||||
var badge: String
|
||||
var caption: String
|
||||
var isActive: Bool
|
||||
}
|
||||
|
||||
private struct CanvasContent: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
||||
|
|
@ -301,53 +475,33 @@ private struct CanvasContent: View {
|
|||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .topLeading) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
StatusPill(
|
||||
gateway: self.gatewayStatus,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
activity: self.statusActivity,
|
||||
brighten: self.brightenButtons,
|
||||
onTap: {
|
||||
if self.gatewayStatus == .connected {
|
||||
self.showGatewayActions = true
|
||||
} else {
|
||||
self.openSettings()
|
||||
}
|
||||
})
|
||||
.layoutPriority(1)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
OverlayButton(systemImage: "text.bubble.fill", brighten: self.brightenButtons) {
|
||||
self.openChat()
|
||||
}
|
||||
.accessibilityLabel("Chat")
|
||||
|
||||
if self.talkButtonEnabled {
|
||||
// Keep Talk mode near status controls while freeing right-side screen real estate.
|
||||
OverlayButton(
|
||||
systemImage: self.talkActive ? "waveform.circle.fill" : "waveform.circle",
|
||||
brighten: self.brightenButtons,
|
||||
tint: self.appModel.seamColor,
|
||||
isActive: self.talkActive)
|
||||
{
|
||||
let next = !self.talkActive
|
||||
self.talkEnabled = next
|
||||
self.appModel.setTalkEnabled(next)
|
||||
}
|
||||
.accessibilityLabel("Talk Mode")
|
||||
}
|
||||
|
||||
OverlayButton(systemImage: "gearshape.fill", brighten: self.brightenButtons) {
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
HomeToolbar(
|
||||
gateway: self.gatewayStatus,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
activity: self.statusActivity,
|
||||
brighten: self.brightenButtons,
|
||||
talkButtonEnabled: self.talkButtonEnabled,
|
||||
talkActive: self.talkActive,
|
||||
talkTint: self.appModel.seamColor,
|
||||
onStatusTap: {
|
||||
if self.gatewayStatus == .connected {
|
||||
self.showGatewayActions = true
|
||||
} else {
|
||||
self.openSettings()
|
||||
}
|
||||
.accessibilityLabel("Settings")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.safeAreaPadding(.top, 10)
|
||||
},
|
||||
onChatTap: {
|
||||
self.openChat()
|
||||
},
|
||||
onTalkTap: {
|
||||
let next = !self.talkActive
|
||||
self.talkEnabled = next
|
||||
self.appModel.setTalkEnabled(next)
|
||||
},
|
||||
onSettingsTap: {
|
||||
self.openSettings()
|
||||
})
|
||||
}
|
||||
.overlay(alignment: .topLeading) {
|
||||
if let voiceWakeToastText, !voiceWakeToastText.isEmpty {
|
||||
|
|
@ -380,63 +534,6 @@ private struct CanvasContent: View {
|
|||
}
|
||||
}
|
||||
|
||||
private struct OverlayButton: View {
|
||||
let systemImage: String
|
||||
let brighten: Bool
|
||||
var tint: Color?
|
||||
var isActive: Bool = false
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: self.action) {
|
||||
Image(systemName: self.systemImage)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(self.isActive ? (self.tint ?? .primary) : .primary)
|
||||
.padding(10)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
.white.opacity(self.brighten ? 0.26 : 0.18),
|
||||
.white.opacity(self.brighten ? 0.08 : 0.04),
|
||||
.clear,
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing))
|
||||
.blendMode(.overlay)
|
||||
}
|
||||
.overlay {
|
||||
if let tint {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
tint.opacity(self.isActive ? 0.22 : 0.14),
|
||||
tint.opacity(self.isActive ? 0.10 : 0.06),
|
||||
.clear,
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing))
|
||||
.blendMode(.overlay)
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(
|
||||
(self.tint ?? .white).opacity(self.isActive ? 0.34 : (self.brighten ? 0.24 : 0.18)),
|
||||
lineWidth: self.isActive ? 0.7 : 0.5)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.35), radius: 12, y: 6)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CameraFlashOverlay: View {
|
||||
var nonce: Int
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ final class ScreenController {
|
|||
private var debugStatusEnabled: Bool = false
|
||||
private var debugStatusTitle: String?
|
||||
private var debugStatusSubtitle: String?
|
||||
private var homeCanvasStateJSON: String?
|
||||
|
||||
init() {
|
||||
self.reload()
|
||||
|
|
@ -94,6 +95,26 @@ final class ScreenController {
|
|||
subtitle: self.debugStatusSubtitle)
|
||||
}
|
||||
|
||||
func updateHomeCanvasState(json: String?) {
|
||||
self.homeCanvasStateJSON = json
|
||||
self.applyHomeCanvasStateIfNeeded()
|
||||
}
|
||||
|
||||
func applyHomeCanvasStateIfNeeded() {
|
||||
guard let webView = self.activeWebView else { return }
|
||||
let payload = self.homeCanvasStateJSON ?? "null"
|
||||
let js = """
|
||||
(() => {
|
||||
try {
|
||||
const api = globalThis.__openclaw;
|
||||
if (!api || typeof api.renderHome !== 'function') return;
|
||||
api.renderHome(\(payload));
|
||||
} catch (_) {}
|
||||
})()
|
||||
"""
|
||||
webView.evaluateJavaScript(js) { _, _ in }
|
||||
}
|
||||
|
||||
func waitForA2UIReady(timeoutMs: Int) async -> Bool {
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now.advanced(by: .milliseconds(timeoutMs))
|
||||
|
|
@ -191,6 +212,7 @@ final class ScreenController {
|
|||
self.activeWebView = webView
|
||||
self.reload()
|
||||
self.applyDebugStatusIfNeeded()
|
||||
self.applyHomeCanvasStateIfNeeded()
|
||||
}
|
||||
|
||||
func detachWebView(_ webView: WKWebView) {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ struct ScreenTab: View {
|
|||
var body: some View {
|
||||
ZStack(alignment: .top) {
|
||||
ScreenWebView(controller: self.appModel.screen)
|
||||
.ignoresSafeArea()
|
||||
.ignoresSafeArea(.container, edges: [.top, .leading, .trailing])
|
||||
.overlay(alignment: .top) {
|
||||
if let errorText = self.appModel.screen.errorText,
|
||||
self.appModel.gatewayServerName == nil
|
||||
|
|
|
|||
|
|
@ -161,6 +161,7 @@ private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate {
|
|||
func webView(_: WKWebView, didFinish _: WKNavigation?) {
|
||||
self.controller?.errorText = nil
|
||||
self.controller?.applyDebugStatusIfNeeded()
|
||||
self.controller?.applyHomeCanvasStateIfNeeded()
|
||||
}
|
||||
|
||||
func webView(_: WKWebView, didFail _: WKNavigation?, withError error: any Error) {
|
||||
|
|
|
|||
|
|
@ -65,10 +65,10 @@ struct SettingsTab: View {
|
|||
DisclosureGroup(isExpanded: self.$gatewayExpanded) {
|
||||
if !self.isGatewayConnected {
|
||||
Text(
|
||||
"1. Open Telegram and message your bot: /pair\n"
|
||||
"1. Open a chat with your OpenClaw agent and send /pair\n"
|
||||
+ "2. Copy the setup code it returns\n"
|
||||
+ "3. Paste here and tap Connect\n"
|
||||
+ "4. Back in Telegram, run /pair approve")
|
||||
+ "4. Back in that chat, run /pair approve")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
|
|
@ -340,9 +340,9 @@ struct SettingsTab: View {
|
|||
.foregroundStyle(.secondary)
|
||||
}
|
||||
self.featureToggle(
|
||||
"Show Talk Button",
|
||||
"Show Talk Control",
|
||||
isOn: self.$talkButtonEnabled,
|
||||
help: "Shows the floating Talk button in the main interface.")
|
||||
help: "Shows the Talk control in the main toolbar.")
|
||||
TextField("Default Share Instruction", text: self.$defaultShareInstruction, axis: .vertical)
|
||||
.lineLimit(2 ... 6)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
|
|
@ -896,7 +896,7 @@ struct SettingsTab: View {
|
|||
guard !trimmed.isEmpty else { return nil }
|
||||
let lower = trimmed.lowercased()
|
||||
if lower.contains("pairing required") {
|
||||
return "Pairing required. Go back to Telegram and run /pair approve, then tap Connect again."
|
||||
return "Pairing required. Go back to your OpenClaw chat and run /pair approve, then tap Connect again."
|
||||
}
|
||||
if lower.contains("device nonce required") || lower.contains("device nonce mismatch") {
|
||||
return "Secure handshake failed. Make sure Tailscale is connected, then tap Connect again."
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ struct StatusPill: View {
|
|||
var gateway: GatewayState
|
||||
var voiceWakeEnabled: Bool
|
||||
var activity: Activity?
|
||||
var compact: Bool = false
|
||||
var brighten: Bool = false
|
||||
var onTap: () -> Void
|
||||
|
||||
|
|
@ -45,11 +46,11 @@ struct StatusPill: View {
|
|||
|
||||
var body: some View {
|
||||
Button(action: self.onTap) {
|
||||
HStack(spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: self.compact ? 8 : 10) {
|
||||
HStack(spacing: self.compact ? 6 : 8) {
|
||||
Circle()
|
||||
.fill(self.gateway.color)
|
||||
.frame(width: 9, height: 9)
|
||||
.frame(width: self.compact ? 8 : 9, height: self.compact ? 8 : 9)
|
||||
.scaleEffect(
|
||||
self.gateway == .connecting && !self.reduceMotion
|
||||
? (self.pulse ? 1.15 : 0.85)
|
||||
|
|
@ -58,34 +59,38 @@ struct StatusPill: View {
|
|||
.opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0)
|
||||
|
||||
Text(self.gateway.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.font((self.compact ? Font.footnote : Font.subheadline).weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
|
||||
Divider()
|
||||
.frame(height: 14)
|
||||
.opacity(0.35)
|
||||
|
||||
if let activity {
|
||||
HStack(spacing: 6) {
|
||||
if !self.compact {
|
||||
Divider()
|
||||
.frame(height: 14)
|
||||
.opacity(0.35)
|
||||
}
|
||||
|
||||
HStack(spacing: self.compact ? 4 : 6) {
|
||||
Image(systemName: activity.systemImage)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.font((self.compact ? Font.footnote : Font.subheadline).weight(.semibold))
|
||||
.foregroundStyle(activity.tint ?? .primary)
|
||||
Text(activity.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
if !self.compact {
|
||||
Text(activity.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
} else {
|
||||
Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.font((self.compact ? Font.footnote : Font.subheadline).weight(.semibold))
|
||||
.foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary)
|
||||
.accessibilityLabel(self.voiceWakeEnabled ? "Voice Wake enabled" : "Voice Wake disabled")
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
.statusGlassCard(brighten: self.brighten, verticalPadding: 8)
|
||||
.statusGlassCard(brighten: self.brighten, verticalPadding: self.compact ? 6 : 8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Connection Status")
|
||||
|
|
|
|||
|
|
@ -83,16 +83,16 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
|
|||
#expect(json.contains("\"value\""))
|
||||
}
|
||||
|
||||
@Test @MainActor func chatSessionKeyDefaultsToIOSBase() {
|
||||
@Test @MainActor func chatSessionKeyDefaultsToMainBase() {
|
||||
let appModel = NodeAppModel()
|
||||
#expect(appModel.chatSessionKey == "ios")
|
||||
#expect(appModel.chatSessionKey == "main")
|
||||
}
|
||||
|
||||
@Test @MainActor func chatSessionKeyUsesAgentScopedKeyForNonDefaultAgent() {
|
||||
let appModel = NodeAppModel()
|
||||
appModel.gatewayDefaultAgentId = "main"
|
||||
appModel.setSelectedAgentId("agent-123")
|
||||
#expect(appModel.chatSessionKey == SessionKey.makeAgentSessionKey(agentId: "agent-123", baseKey: "ios"))
|
||||
#expect(appModel.chatSessionKey == SessionKey.makeAgentSessionKey(agentId: "agent-123", baseKey: "main"))
|
||||
#expect(appModel.mainSessionKey == "agent:agent-123:main")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -131,6 +131,41 @@ private let defaultOperatorConnectScopes: [String] = [
|
|||
"operator.pairing",
|
||||
]
|
||||
|
||||
private enum GatewayConnectErrorCodes {
|
||||
static let authTokenMismatch = "AUTH_TOKEN_MISMATCH"
|
||||
static let authDeviceTokenMismatch = "AUTH_DEVICE_TOKEN_MISMATCH"
|
||||
static let authTokenMissing = "AUTH_TOKEN_MISSING"
|
||||
static let authPasswordMissing = "AUTH_PASSWORD_MISSING"
|
||||
static let authPasswordMismatch = "AUTH_PASSWORD_MISMATCH"
|
||||
static let authRateLimited = "AUTH_RATE_LIMITED"
|
||||
static let pairingRequired = "PAIRING_REQUIRED"
|
||||
static let controlUiDeviceIdentityRequired = "CONTROL_UI_DEVICE_IDENTITY_REQUIRED"
|
||||
static let deviceIdentityRequired = "DEVICE_IDENTITY_REQUIRED"
|
||||
}
|
||||
|
||||
private struct GatewayConnectAuthError: LocalizedError {
|
||||
let message: String
|
||||
let detailCode: String?
|
||||
let canRetryWithDeviceToken: Bool
|
||||
|
||||
var errorDescription: String? { self.message }
|
||||
|
||||
var isNonRecoverable: Bool {
|
||||
switch self.detailCode {
|
||||
case GatewayConnectErrorCodes.authTokenMissing,
|
||||
GatewayConnectErrorCodes.authPasswordMissing,
|
||||
GatewayConnectErrorCodes.authPasswordMismatch,
|
||||
GatewayConnectErrorCodes.authRateLimited,
|
||||
GatewayConnectErrorCodes.pairingRequired,
|
||||
GatewayConnectErrorCodes.controlUiDeviceIdentityRequired,
|
||||
GatewayConnectErrorCodes.deviceIdentityRequired:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public actor GatewayChannelActor {
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "gateway")
|
||||
private var task: WebSocketTaskBox?
|
||||
|
|
@ -160,6 +195,9 @@ public actor GatewayChannelActor {
|
|||
private var watchdogTask: Task<Void, Never>?
|
||||
private var tickTask: Task<Void, Never>?
|
||||
private var keepaliveTask: Task<Void, Never>?
|
||||
private var pendingDeviceTokenRetry = false
|
||||
private var deviceTokenRetryBudgetUsed = false
|
||||
private var reconnectPausedForAuthFailure = false
|
||||
private let defaultRequestTimeoutMs: Double = 15000
|
||||
private let pushHandler: (@Sendable (GatewayPush) async -> Void)?
|
||||
private let connectOptions: GatewayConnectOptions?
|
||||
|
|
@ -232,10 +270,19 @@ public actor GatewayChannelActor {
|
|||
while self.shouldReconnect {
|
||||
guard await self.sleepUnlessCancelled(nanoseconds: 30 * 1_000_000_000) else { return } // 30s cadence
|
||||
guard self.shouldReconnect else { return }
|
||||
if self.reconnectPausedForAuthFailure { continue }
|
||||
if self.connected { continue }
|
||||
do {
|
||||
try await self.connect()
|
||||
} catch {
|
||||
if self.shouldPauseReconnectAfterAuthFailure(error) {
|
||||
self.reconnectPausedForAuthFailure = true
|
||||
self.logger.error(
|
||||
"gateway watchdog reconnect paused for non-recoverable auth failure " +
|
||||
"\(error.localizedDescription, privacy: .public)"
|
||||
)
|
||||
continue
|
||||
}
|
||||
let wrapped = self.wrap(error, context: "gateway watchdog reconnect")
|
||||
self.logger.error("gateway watchdog reconnect failed \(wrapped.localizedDescription, privacy: .public)")
|
||||
}
|
||||
|
|
@ -267,7 +314,12 @@ public actor GatewayChannelActor {
|
|||
},
|
||||
operation: { try await self.sendConnect() })
|
||||
} catch {
|
||||
let wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)")
|
||||
let wrapped: Error
|
||||
if let authError = error as? GatewayConnectAuthError {
|
||||
wrapped = authError
|
||||
} else {
|
||||
wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)")
|
||||
}
|
||||
self.connected = false
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
await self.disconnectHandler?("connect failed: \(wrapped.localizedDescription)")
|
||||
|
|
@ -281,6 +333,7 @@ public actor GatewayChannelActor {
|
|||
}
|
||||
self.listen()
|
||||
self.connected = true
|
||||
self.reconnectPausedForAuthFailure = false
|
||||
self.backoffMs = 500
|
||||
self.lastSeq = nil
|
||||
self.startKeepalive()
|
||||
|
|
@ -371,11 +424,18 @@ public actor GatewayChannelActor {
|
|||
(includeDeviceIdentity && identity != nil)
|
||||
? DeviceAuthStore.loadToken(deviceId: identity!.deviceId, role: role)?.token
|
||||
: nil
|
||||
// If we're not sending a device identity, a device token can't be validated server-side.
|
||||
// In that mode we always use the shared gateway token/password.
|
||||
let authToken = includeDeviceIdentity ? (storedToken ?? self.token) : self.token
|
||||
let shouldUseDeviceRetryToken =
|
||||
includeDeviceIdentity && self.pendingDeviceTokenRetry &&
|
||||
storedToken != nil && self.token != nil && self.isTrustedDeviceRetryEndpoint()
|
||||
if shouldUseDeviceRetryToken {
|
||||
self.pendingDeviceTokenRetry = false
|
||||
}
|
||||
// Keep shared credentials explicit when provided. Device token retry is attached
|
||||
// only on a bounded second attempt after token mismatch.
|
||||
let authToken = self.token ?? (includeDeviceIdentity ? storedToken : nil)
|
||||
let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil
|
||||
let authSource: GatewayAuthSource
|
||||
if storedToken != nil {
|
||||
if authDeviceToken != nil || (self.token == nil && storedToken != nil) {
|
||||
authSource = .deviceToken
|
||||
} else if authToken != nil {
|
||||
authSource = .sharedToken
|
||||
|
|
@ -386,9 +446,12 @@ public actor GatewayChannelActor {
|
|||
}
|
||||
self.lastAuthSource = authSource
|
||||
self.logger.info("gateway connect auth=\(authSource.rawValue, privacy: .public)")
|
||||
let canFallbackToShared = includeDeviceIdentity && storedToken != nil && self.token != nil
|
||||
if let authToken {
|
||||
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)])
|
||||
var auth: [String: ProtoAnyCodable] = ["token": ProtoAnyCodable(authToken)]
|
||||
if let authDeviceToken {
|
||||
auth["deviceToken"] = ProtoAnyCodable(authDeviceToken)
|
||||
}
|
||||
params["auth"] = ProtoAnyCodable(auth)
|
||||
} else if let password = self.password {
|
||||
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
|
||||
}
|
||||
|
|
@ -426,11 +489,24 @@ public actor GatewayChannelActor {
|
|||
do {
|
||||
let response = try await self.waitForConnectResponse(reqId: reqId)
|
||||
try await self.handleConnectResponse(response, identity: identity, role: role)
|
||||
self.pendingDeviceTokenRetry = false
|
||||
self.deviceTokenRetryBudgetUsed = false
|
||||
} catch {
|
||||
if canFallbackToShared {
|
||||
if let identity {
|
||||
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
|
||||
}
|
||||
let shouldRetryWithDeviceToken = self.shouldRetryWithStoredDeviceToken(
|
||||
error: error,
|
||||
explicitGatewayToken: self.token,
|
||||
storedToken: storedToken,
|
||||
attemptedDeviceTokenRetry: authDeviceToken != nil)
|
||||
if shouldRetryWithDeviceToken {
|
||||
self.pendingDeviceTokenRetry = true
|
||||
self.deviceTokenRetryBudgetUsed = true
|
||||
self.backoffMs = min(self.backoffMs, 250)
|
||||
} else if authDeviceToken != nil,
|
||||
let identity,
|
||||
self.shouldClearStoredDeviceTokenAfterRetry(error)
|
||||
{
|
||||
// Retry failed with an explicit device-token mismatch; clear stale local token.
|
||||
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
|
@ -443,7 +519,13 @@ public actor GatewayChannelActor {
|
|||
) async throws {
|
||||
if res.ok == false {
|
||||
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
|
||||
throw NSError(domain: "Gateway", code: 1008, userInfo: [NSLocalizedDescriptionKey: msg])
|
||||
let details = res.error?["details"]?.value as? [String: ProtoAnyCodable]
|
||||
let detailCode = details?["code"]?.value as? String
|
||||
let canRetryWithDeviceToken = details?["canRetryWithDeviceToken"]?.value as? Bool ?? false
|
||||
throw GatewayConnectAuthError(
|
||||
message: msg,
|
||||
detailCode: detailCode,
|
||||
canRetryWithDeviceToken: canRetryWithDeviceToken)
|
||||
}
|
||||
guard let payload = res.payload else {
|
||||
throw NSError(
|
||||
|
|
@ -616,19 +698,91 @@ public actor GatewayChannelActor {
|
|||
|
||||
private func scheduleReconnect() async {
|
||||
guard self.shouldReconnect else { return }
|
||||
guard !self.reconnectPausedForAuthFailure else { return }
|
||||
let delay = self.backoffMs / 1000
|
||||
self.backoffMs = min(self.backoffMs * 2, 30000)
|
||||
guard await self.sleepUnlessCancelled(nanoseconds: UInt64(delay * 1_000_000_000)) else { return }
|
||||
guard self.shouldReconnect else { return }
|
||||
guard !self.reconnectPausedForAuthFailure else { return }
|
||||
do {
|
||||
try await self.connect()
|
||||
} catch {
|
||||
if self.shouldPauseReconnectAfterAuthFailure(error) {
|
||||
self.reconnectPausedForAuthFailure = true
|
||||
self.logger.error(
|
||||
"gateway reconnect paused for non-recoverable auth failure " +
|
||||
"\(error.localizedDescription, privacy: .public)"
|
||||
)
|
||||
return
|
||||
}
|
||||
let wrapped = self.wrap(error, context: "gateway reconnect")
|
||||
self.logger.error("gateway reconnect failed \(wrapped.localizedDescription, privacy: .public)")
|
||||
await self.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldRetryWithStoredDeviceToken(
|
||||
error: Error,
|
||||
explicitGatewayToken: String?,
|
||||
storedToken: String?,
|
||||
attemptedDeviceTokenRetry: Bool
|
||||
) -> Bool {
|
||||
if self.deviceTokenRetryBudgetUsed {
|
||||
return false
|
||||
}
|
||||
if attemptedDeviceTokenRetry {
|
||||
return false
|
||||
}
|
||||
guard explicitGatewayToken != nil, storedToken != nil else {
|
||||
return false
|
||||
}
|
||||
guard self.isTrustedDeviceRetryEndpoint() else {
|
||||
return false
|
||||
}
|
||||
guard let authError = error as? GatewayConnectAuthError else {
|
||||
return false
|
||||
}
|
||||
return authError.canRetryWithDeviceToken ||
|
||||
authError.detailCode == GatewayConnectErrorCodes.authTokenMismatch
|
||||
}
|
||||
|
||||
private func shouldPauseReconnectAfterAuthFailure(_ error: Error) -> Bool {
|
||||
guard let authError = error as? GatewayConnectAuthError else {
|
||||
return false
|
||||
}
|
||||
if authError.isNonRecoverable {
|
||||
return true
|
||||
}
|
||||
if authError.detailCode == GatewayConnectErrorCodes.authTokenMismatch &&
|
||||
self.deviceTokenRetryBudgetUsed && !self.pendingDeviceTokenRetry
|
||||
{
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func shouldClearStoredDeviceTokenAfterRetry(_ error: Error) -> Bool {
|
||||
guard let authError = error as? GatewayConnectAuthError else {
|
||||
return false
|
||||
}
|
||||
return authError.detailCode == GatewayConnectErrorCodes.authDeviceTokenMismatch
|
||||
}
|
||||
|
||||
private func isTrustedDeviceRetryEndpoint() -> Bool {
|
||||
// This client currently treats loopback as the only trusted retry target.
|
||||
// Unlike the Node gateway client, it does not yet expose a pinned TLS-fingerprint
|
||||
// trust path for remote retry, so remote fallback remains disabled by default.
|
||||
guard let host = self.url.host?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
|
||||
!host.isEmpty
|
||||
else {
|
||||
return false
|
||||
}
|
||||
if host == "localhost" || host == "::1" || host == "127.0.0.1" || host.hasPrefix("127.") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private nonisolated func sleepUnlessCancelled(nanoseconds: UInt64) async -> Bool {
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: nanoseconds)
|
||||
|
|
@ -756,7 +910,8 @@ public actor GatewayChannelActor {
|
|||
return (id: id, data: data)
|
||||
} catch {
|
||||
self.logger.error(
|
||||
"gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
"gateway \(kind) encode failed \(method, privacy: .public) " +
|
||||
"error=\(error.localizedDescription, privacy: .public)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Canvas</title>
|
||||
<title>OpenClaw</title>
|
||||
<script>
|
||||
(() => {
|
||||
try {
|
||||
|
|
@ -15,99 +15,358 @@
|
|||
}
|
||||
if (/android/i.test(navigator.userAgent || '')) {
|
||||
document.documentElement.dataset.platform = 'android';
|
||||
} else {
|
||||
document.documentElement.dataset.platform = 'ios';
|
||||
}
|
||||
} catch (_) {}
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
body::before, body::after { animation: none !important; }
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #06070b;
|
||||
--panel: rgba(14, 17, 24, 0.74);
|
||||
--panel-strong: rgba(18, 23, 32, 0.86);
|
||||
--line: rgba(255, 255, 255, 0.1);
|
||||
--line-strong: rgba(255, 255, 255, 0.18);
|
||||
--text: rgba(255, 255, 255, 0.96);
|
||||
--muted: rgba(222, 229, 239, 0.72);
|
||||
--soft: rgba(222, 229, 239, 0.5);
|
||||
--accent: #8ec5ff;
|
||||
--accent-strong: #5b9dff;
|
||||
--accent-warm: #ff9159;
|
||||
--accent-rose: #ff5fa2;
|
||||
--state: #7d8ca3;
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
html,body { height:100%; margin:0; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", system-ui, sans-serif;
|
||||
background:
|
||||
radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.18), rgba(0,0,0,0) 55%),
|
||||
radial-gradient(900px 700px at 85% 30%, rgba(255, 0, 138, 0.14), rgba(0,0,0,0) 60%),
|
||||
radial-gradient(1000px 900px at 60% 90%, rgba(0, 209, 255, 0.10), rgba(0,0,0,0) 60%),
|
||||
#000;
|
||||
radial-gradient(900px 640px at 12% 10%, rgba(91, 157, 255, 0.36), rgba(0, 0, 0, 0) 58%),
|
||||
radial-gradient(840px 600px at 88% 16%, rgba(255, 95, 162, 0.24), rgba(0, 0, 0, 0) 62%),
|
||||
radial-gradient(960px 720px at 50% 100%, rgba(255, 145, 89, 0.18), rgba(0, 0, 0, 0) 60%),
|
||||
linear-gradient(180deg, #090b11 0%, #05060a 100%);
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
}
|
||||
:root[data-platform="android"] body {
|
||||
background:
|
||||
radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.62), rgba(0,0,0,0) 55%),
|
||||
radial-gradient(900px 700px at 85% 30%, rgba(255, 0, 138, 0.52), rgba(0,0,0,0) 60%),
|
||||
radial-gradient(1000px 900px at 60% 90%, rgba(0, 209, 255, 0.48), rgba(0,0,0,0) 60%),
|
||||
#0b1328;
|
||||
}
|
||||
body::before {
|
||||
content:"";
|
||||
position: fixed;
|
||||
inset: -20%;
|
||||
background:
|
||||
repeating-linear-gradient(0deg, rgba(255,255,255,0.03) 0, rgba(255,255,255,0.03) 1px,
|
||||
transparent 1px, transparent 48px),
|
||||
repeating-linear-gradient(90deg, rgba(255,255,255,0.03) 0, rgba(255,255,255,0.03) 1px,
|
||||
transparent 1px, transparent 48px);
|
||||
transform: translate3d(0,0,0) rotate(-7deg);
|
||||
will-change: transform, opacity;
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
animation: openclaw-grid-drift 140s ease-in-out infinite alternate;
|
||||
}
|
||||
:root[data-platform="android"] body::before { opacity: 0.80; }
|
||||
|
||||
body::before,
|
||||
body::after {
|
||||
content:"";
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: -35%;
|
||||
background:
|
||||
radial-gradient(900px 700px at 30% 30%, rgba(42,113,255,0.16), rgba(0,0,0,0) 60%),
|
||||
radial-gradient(800px 650px at 70% 35%, rgba(255,0,138,0.12), rgba(0,0,0,0) 62%),
|
||||
radial-gradient(900px 800px at 55% 75%, rgba(0,209,255,0.10), rgba(0,0,0,0) 62%);
|
||||
filter: blur(28px);
|
||||
opacity: 0.52;
|
||||
will-change: transform, opacity;
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
transform: translate3d(0,0,0);
|
||||
inset: -10%;
|
||||
pointer-events: none;
|
||||
animation: openclaw-glow-drift 110s ease-in-out infinite alternate;
|
||||
}
|
||||
:root[data-platform="android"] body::after { opacity: 0.85; }
|
||||
@supports (mix-blend-mode: screen) {
|
||||
body::after { mix-blend-mode: screen; }
|
||||
|
||||
body::before {
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.025) 0,
|
||||
rgba(255, 255, 255, 0.025) 1px,
|
||||
transparent 1px,
|
||||
transparent 52px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(255, 255, 255, 0.025) 0,
|
||||
rgba(255, 255, 255, 0.025) 1px,
|
||||
transparent 1px,
|
||||
transparent 52px
|
||||
);
|
||||
opacity: 0.42;
|
||||
transform: rotate(-7deg);
|
||||
}
|
||||
@supports not (mix-blend-mode: screen) {
|
||||
body::after { opacity: 0.70; }
|
||||
}
|
||||
@keyframes openclaw-grid-drift {
|
||||
0% { transform: translate3d(-12px, 8px, 0) rotate(-7deg); opacity: 0.40; }
|
||||
50% { transform: translate3d( 10px,-7px, 0) rotate(-6.6deg); opacity: 0.56; }
|
||||
100% { transform: translate3d(-8px, 6px, 0) rotate(-7.2deg); opacity: 0.42; }
|
||||
}
|
||||
@keyframes openclaw-glow-drift {
|
||||
0% { transform: translate3d(-18px, 12px, 0) scale(1.02); opacity: 0.40; }
|
||||
50% { transform: translate3d( 14px,-10px, 0) scale(1.05); opacity: 0.52; }
|
||||
100% { transform: translate3d(-10px, 8px, 0) scale(1.03); opacity: 0.43; }
|
||||
|
||||
body::after {
|
||||
background:
|
||||
radial-gradient(700px 460px at 20% 18%, rgba(142, 197, 255, 0.18), rgba(0, 0, 0, 0) 62%),
|
||||
radial-gradient(720px 520px at 84% 20%, rgba(255, 95, 162, 0.14), rgba(0, 0, 0, 0) 66%),
|
||||
radial-gradient(860px 620px at 52% 88%, rgba(255, 145, 89, 0.14), rgba(0, 0, 0, 0) 64%);
|
||||
filter: blur(28px);
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
body[data-state="connected"] { --state: #61d58b; }
|
||||
body[data-state="connecting"] { --state: #ffd05f; }
|
||||
body[data-state="error"] { --state: #ff6d6d; }
|
||||
body[data-state="offline"] { --state: #95a3b9; }
|
||||
|
||||
canvas {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display:block;
|
||||
width:100vw;
|
||||
height:100vh;
|
||||
touch-action: none;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: block;
|
||||
z-index: 1;
|
||||
}
|
||||
:root[data-platform="android"] #openclaw-canvas {
|
||||
background:
|
||||
radial-gradient(1100px 800px at 20% 15%, rgba(42, 113, 255, 0.78), rgba(0,0,0,0) 58%),
|
||||
radial-gradient(900px 650px at 82% 28%, rgba(255, 0, 138, 0.66), rgba(0,0,0,0) 62%),
|
||||
radial-gradient(1000px 900px at 60% 88%, rgba(0, 209, 255, 0.58), rgba(0,0,0,0) 62%),
|
||||
#141c33;
|
||||
|
||||
#openclaw-home {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: calc(var(--safe-top) + 18px) 16px calc(var(--safe-bottom) + 18px);
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: min(100%, 760px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
gap: 16px;
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 28px;
|
||||
background: linear-gradient(180deg, rgba(18, 24, 34, 0.86), rgba(10, 13, 19, 0.94));
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.42);
|
||||
padding: 22px 22px 18px;
|
||||
}
|
||||
|
||||
.hero::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -30% auto auto -20%;
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(circle, rgba(142, 197, 255, 0.18), rgba(0, 0, 0, 0));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.eyebrow-dot {
|
||||
flex: 0 0 auto;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 999px;
|
||||
background: var(--state);
|
||||
box-shadow: 0 0 18px color-mix(in srgb, var(--state) 68%, transparent);
|
||||
}
|
||||
|
||||
#openclaw-home-eyebrow {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 18px 0 0;
|
||||
font-size: clamp(32px, 7vw, 52px);
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 14px 0 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
color: var(--muted);
|
||||
max-width: 32rem;
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr;
|
||||
gap: 12px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.meta-card,
|
||||
.agent-card {
|
||||
border-radius: 22px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.meta-card {
|
||||
padding: 16px 16px 15px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--soft);
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
margin-top: 8px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.meta-subtitle {
|
||||
margin-top: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.agent-focus {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.agent-badge {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(142, 197, 255, 0.22), rgba(91, 157, 255, 0.1)),
|
||||
rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.agent-focus .name {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.agent-focus .caption {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 16px 16px 14px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.section-count {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--soft);
|
||||
}
|
||||
|
||||
.agent-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.agent-card {
|
||||
padding: 13px 13px 12px;
|
||||
}
|
||||
|
||||
.agent-card.active {
|
||||
background: var(--panel-strong);
|
||||
border-color: var(--line-strong);
|
||||
box-shadow: inset 0 0 0 1px rgba(142, 197, 255, 0.12);
|
||||
}
|
||||
|
||||
.agent-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.agent-row .badge {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.agent-row .name {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.agent-row .caption {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.12);
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.footer-note {
|
||||
margin-top: 12px;
|
||||
color: var(--soft);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
#openclaw-status {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
|
@ -115,41 +374,174 @@
|
|||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex-direction: column;
|
||||
padding-top: calc(20px + env(safe-area-inset-top, 0px));
|
||||
padding-top: calc(var(--safe-top) + 18px);
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
#openclaw-status .card {
|
||||
text-align: center;
|
||||
padding: 16px 18px;
|
||||
border-radius: 14px;
|
||||
background: rgba(18, 18, 22, 0.42);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
box-shadow: 0 18px 60px rgba(0,0,0,0.55);
|
||||
background: rgba(18, 18, 22, 0.46);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.55);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
#openclaw-status .title {
|
||||
font: 600 20px -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", system-ui, sans-serif;
|
||||
letter-spacing: 0.2px;
|
||||
color: rgba(255,255,255,0.92);
|
||||
text-shadow: 0 0 22px rgba(42, 113, 255, 0.35);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
#openclaw-status .subtitle {
|
||||
margin-top: 6px;
|
||||
font: 500 12px -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
|
||||
color: rgba(255,255,255,0.58);
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
#openclaw-home {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
border-radius: 24px;
|
||||
padding: 18px 16px 16px;
|
||||
}
|
||||
|
||||
.hero-grid,
|
||||
.agent-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 34px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 760px) {
|
||||
#openclaw-home {
|
||||
padding-top: calc(var(--safe-top) + 14px);
|
||||
padding-bottom: calc(var(--safe-bottom) + 12px);
|
||||
}
|
||||
|
||||
.shell {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
border-radius: 24px;
|
||||
padding: 16px 15px 15px;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin-top: 14px;
|
||||
font-size: clamp(28px, 8vw, 38px);
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin-top: 10px;
|
||||
font-size: 15px;
|
||||
line-height: 1.42;
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.meta-card {
|
||||
padding: 14px 14px 13px;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.agent-badge {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 16px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.agent-focus .name {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 14px 14px 12px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
body::before,
|
||||
body::after {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<body data-state="offline">
|
||||
<canvas id="openclaw-canvas"></canvas>
|
||||
<div id="openclaw-home">
|
||||
<div class="shell">
|
||||
<div class="hero">
|
||||
<div class="eyebrow">
|
||||
<span class="eyebrow-dot"></span>
|
||||
<span id="openclaw-home-eyebrow">Welcome to OpenClaw</span>
|
||||
</div>
|
||||
<h1 id="openclaw-home-title">Your phone stays quiet until it is needed</h1>
|
||||
<p id="openclaw-home-subtitle">
|
||||
Pair this device to your gateway to wake it only for real work, keep a live agent overview handy, and avoid battery-draining background loops.
|
||||
</p>
|
||||
|
||||
<div class="hero-grid">
|
||||
<div class="meta-card">
|
||||
<div class="meta-label">Gateway</div>
|
||||
<div class="meta-value" id="openclaw-home-gateway">Gateway</div>
|
||||
<div class="meta-subtitle" id="openclaw-home-gateway-caption">Connect to load your agents</div>
|
||||
</div>
|
||||
|
||||
<div class="meta-card">
|
||||
<div class="meta-label">Active Agent</div>
|
||||
<div class="agent-focus">
|
||||
<div class="agent-badge" id="openclaw-home-active-badge">OC</div>
|
||||
<div>
|
||||
<div class="name" id="openclaw-home-active-name">Main</div>
|
||||
<div class="caption" id="openclaw-home-active-caption">Connect to load your agents</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="meta-card section">
|
||||
<div class="section-header">
|
||||
<div class="section-title">Live agents</div>
|
||||
<div class="section-count" id="openclaw-home-agent-count">0 agents</div>
|
||||
</div>
|
||||
<div class="agent-grid" id="openclaw-home-agent-grid"></div>
|
||||
<div class="footer-note" id="openclaw-home-footer">
|
||||
When connected, the gateway can wake the phone with a silent push instead of holding an always-on session.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="openclaw-status">
|
||||
<div class="card">
|
||||
<div class="title" id="openclaw-status-title">Ready</div>
|
||||
<div class="subtitle" id="openclaw-status-subtitle">Waiting for agent</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const canvas = document.getElementById('openclaw-canvas');
|
||||
|
|
@ -157,6 +549,20 @@
|
|||
const statusEl = document.getElementById('openclaw-status');
|
||||
const titleEl = document.getElementById('openclaw-status-title');
|
||||
const subtitleEl = document.getElementById('openclaw-status-subtitle');
|
||||
const home = {
|
||||
root: document.getElementById('openclaw-home'),
|
||||
eyebrow: document.getElementById('openclaw-home-eyebrow'),
|
||||
title: document.getElementById('openclaw-home-title'),
|
||||
subtitle: document.getElementById('openclaw-home-subtitle'),
|
||||
gateway: document.getElementById('openclaw-home-gateway'),
|
||||
gatewayCaption: document.getElementById('openclaw-home-gateway-caption'),
|
||||
activeBadge: document.getElementById('openclaw-home-active-badge'),
|
||||
activeName: document.getElementById('openclaw-home-active-name'),
|
||||
activeCaption: document.getElementById('openclaw-home-active-caption'),
|
||||
agentCount: document.getElementById('openclaw-home-agent-count'),
|
||||
agentGrid: document.getElementById('openclaw-home-agent-grid'),
|
||||
footer: document.getElementById('openclaw-home-footer')
|
||||
};
|
||||
const debugStatusEnabledByQuery = (() => {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
|
@ -172,54 +578,114 @@
|
|||
|
||||
function resize() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = Math.max(1, Math.floor(window.innerWidth * dpr));
|
||||
const h = Math.max(1, Math.floor(window.innerHeight * dpr));
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const width = Math.max(1, Math.floor(window.innerWidth * dpr));
|
||||
const height = Math.max(1, Math.floor(window.innerHeight * dpr));
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
|
||||
function setDebugStatusEnabled(enabled) {
|
||||
debugStatusEnabled = !!enabled;
|
||||
if (!debugStatusEnabled) {
|
||||
statusEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(title, subtitle) {
|
||||
if (!debugStatusEnabled) return;
|
||||
if (!title && !subtitle) {
|
||||
statusEl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
statusEl.style.display = 'flex';
|
||||
if (typeof title === 'string') titleEl.textContent = title;
|
||||
if (typeof subtitle === 'string') subtitleEl.textContent = subtitle;
|
||||
}
|
||||
|
||||
function clearChildren(node) {
|
||||
while (node.firstChild) node.removeChild(node.firstChild);
|
||||
}
|
||||
|
||||
function createAgentCard(agent) {
|
||||
const card = document.createElement('div');
|
||||
card.className = `agent-card${agent.isActive ? ' active' : ''}`;
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'agent-row';
|
||||
|
||||
const badge = document.createElement('div');
|
||||
badge.className = 'badge';
|
||||
badge.textContent = agent.badge || 'OC';
|
||||
|
||||
const text = document.createElement('div');
|
||||
|
||||
const name = document.createElement('div');
|
||||
name.className = 'name';
|
||||
name.textContent = agent.name || agent.id || 'Agent';
|
||||
|
||||
const caption = document.createElement('div');
|
||||
caption.className = 'caption';
|
||||
caption.textContent = agent.caption || 'Ready';
|
||||
|
||||
text.appendChild(name);
|
||||
text.appendChild(caption);
|
||||
row.appendChild(badge);
|
||||
row.appendChild(text);
|
||||
card.appendChild(row);
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderHome(state) {
|
||||
if (!state || typeof state !== 'object') return;
|
||||
|
||||
document.body.dataset.state = state.gatewayState || 'offline';
|
||||
home.root.style.display = 'flex';
|
||||
home.eyebrow.textContent = state.eyebrow || 'Welcome to OpenClaw';
|
||||
home.title.textContent = state.title || 'OpenClaw';
|
||||
home.subtitle.textContent = state.subtitle || '';
|
||||
home.gateway.textContent = state.gatewayLabel || 'Gateway';
|
||||
home.gatewayCaption.textContent = state.gatewayState === 'connected'
|
||||
? `${state.agentCount || 0} agent${state.agentCount === 1 ? '' : 's'} available`
|
||||
: (state.activeAgentCaption || 'Connect to load your agents');
|
||||
home.activeBadge.textContent = state.activeAgentBadge || 'OC';
|
||||
home.activeName.textContent = state.activeAgentName || 'Main';
|
||||
home.activeCaption.textContent = state.activeAgentCaption || '';
|
||||
home.agentCount.textContent = `${state.agentCount || 0} agent${state.agentCount === 1 ? '' : 's'}`;
|
||||
home.footer.textContent = state.footer || '';
|
||||
|
||||
clearChildren(home.agentGrid);
|
||||
const agents = Array.isArray(state.agents) ? state.agents : [];
|
||||
if (!agents.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'empty-state';
|
||||
empty.textContent = state.gatewayState === 'connected'
|
||||
? 'Your gateway is online. Agents will appear here as soon as the current scope reports them.'
|
||||
: 'Connect this phone to your gateway and the live agent overview will appear here.';
|
||||
home.agentGrid.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
agents.forEach((agent) => {
|
||||
home.agentGrid.appendChild(createAgentCard(agent));
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('resize', resize);
|
||||
resize();
|
||||
|
||||
const setDebugStatusEnabled = (enabled) => {
|
||||
debugStatusEnabled = !!enabled;
|
||||
if (!statusEl) return;
|
||||
if (!debugStatusEnabled) {
|
||||
statusEl.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
if (statusEl && !debugStatusEnabled) {
|
||||
if (!debugStatusEnabled) {
|
||||
statusEl.style.display = 'none';
|
||||
}
|
||||
|
||||
const api = {
|
||||
window.__openclaw = {
|
||||
canvas,
|
||||
ctx,
|
||||
setDebugStatusEnabled,
|
||||
setStatus: (title, subtitle) => {
|
||||
if (!statusEl || !debugStatusEnabled) return;
|
||||
if (!title && !subtitle) {
|
||||
statusEl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
statusEl.style.display = 'flex';
|
||||
if (titleEl && typeof title === 'string') titleEl.textContent = title;
|
||||
if (subtitleEl && typeof subtitle === 'string') subtitleEl.textContent = subtitle;
|
||||
if (!debugStatusEnabled) {
|
||||
clearTimeout(window.__statusTimeout);
|
||||
window.__statusTimeout = setTimeout(() => {
|
||||
statusEl.style.display = 'none';
|
||||
}, 3000);
|
||||
} else {
|
||||
clearTimeout(window.__statusTimeout);
|
||||
}
|
||||
}
|
||||
setStatus,
|
||||
renderHome
|
||||
};
|
||||
window.__openclaw = api;
|
||||
})();
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ openclaw pairing approve discord <CODE>
|
|||
|
||||
<Note>
|
||||
Token resolution is account-aware. Config token values win over env fallback. `DISCORD_BOT_TOKEN` is only used for the default account.
|
||||
For advanced outbound calls (message tool/channel actions), an explicit per-call `token` is used for that call. Account policy/retry settings still come from the selected account in the active runtime snapshot.
|
||||
</Note>
|
||||
|
||||
## Recommended: Set up a guild workspace
|
||||
|
|
|
|||
|
|
@ -87,6 +87,8 @@ Token/secret files:
|
|||
}
|
||||
```
|
||||
|
||||
`tokenFile` and `secretFile` must point to regular files. Symlinks are rejected.
|
||||
|
||||
Multiple accounts:
|
||||
|
||||
```json5
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ Provider options:
|
|||
- `channels.nextcloud-talk.enabled`: enable/disable channel startup.
|
||||
- `channels.nextcloud-talk.baseUrl`: Nextcloud instance URL.
|
||||
- `channels.nextcloud-talk.botSecret`: bot shared secret.
|
||||
- `channels.nextcloud-talk.botSecretFile`: secret file path.
|
||||
- `channels.nextcloud-talk.botSecretFile`: regular-file secret path. Symlinks are rejected.
|
||||
- `channels.nextcloud-talk.apiUser`: API user for room lookups (DM detection).
|
||||
- `channels.nextcloud-talk.apiPassword`: API/app password for room lookups.
|
||||
- `channels.nextcloud-talk.apiPasswordFile`: API password file path.
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
|||
|
||||
`groupAllowFrom` is used for group sender filtering. If not set, Telegram falls back to `allowFrom`.
|
||||
`groupAllowFrom` entries should be numeric Telegram user IDs (`telegram:` / `tg:` prefixes are normalized).
|
||||
Do not put Telegram group or supergroup chat IDs in `groupAllowFrom`. Negative chat IDs belong under `channels.telegram.groups`.
|
||||
Non-numeric entries are ignored for sender authorization.
|
||||
Security boundary (`2026.2.25+`): group sender auth does **not** inherit DM pairing-store approvals.
|
||||
Pairing stays DM-only. For groups, set `groupAllowFrom` or per-group/per-topic `allowFrom`.
|
||||
|
|
@ -177,6 +178,31 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
|||
}
|
||||
```
|
||||
|
||||
Example: allow only specific users inside one specific group:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
groups: {
|
||||
"-1001234567890": {
|
||||
requireMention: true,
|
||||
allowFrom: ["8734062810", "745123456"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Common mistake: `groupAllowFrom` is not a Telegram group allowlist.
|
||||
|
||||
- Put negative Telegram group or supergroup chat IDs like `-1001234567890` under `channels.telegram.groups`.
|
||||
- Put Telegram user IDs like `8734062810` under `groupAllowFrom` when you want to limit which people inside an allowed group can trigger the bot.
|
||||
- Use `groupAllowFrom: ["*"]` only when you want any member of an allowed group to be able to talk to the bot.
|
||||
</Warning>
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab title="Mention behavior">
|
||||
|
|
@ -410,6 +436,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
|||
- `channels.telegram.actions.sticker` (default: disabled)
|
||||
|
||||
Note: `edit` and `topic-create` are currently enabled by default and do not have separate `channels.telegram.actions.*` toggles.
|
||||
Runtime sends use the active config/secrets snapshot (startup/reload), so action paths do not perform ad-hoc SecretRef re-resolution per send.
|
||||
|
||||
Reaction removal semantics: [/tools/reactions](/tools/reactions)
|
||||
|
||||
|
|
@ -865,7 +892,7 @@ Primary reference:
|
|||
|
||||
- `channels.telegram.enabled`: enable/disable channel startup.
|
||||
- `channels.telegram.botToken`: bot token (BotFather).
|
||||
- `channels.telegram.tokenFile`: read token from file path.
|
||||
- `channels.telegram.tokenFile`: read token from a regular file path. Symlinks are rejected.
|
||||
- `channels.telegram.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.telegram.allowFrom`: DM allowlist (numeric Telegram user IDs). `allowlist` requires at least one sender ID. `open` requires `"*"`. `openclaw doctor --fix` can resolve legacy `@username` entries to IDs and can recover allowlist entries from pairing-store files in allowlist migration flows.
|
||||
- `channels.telegram.actions.poll`: enable or disable Telegram poll creation (default: enabled; still requires `sendMessage`).
|
||||
|
|
@ -926,7 +953,7 @@ Primary reference:
|
|||
|
||||
Telegram-specific high-signal fields:
|
||||
|
||||
- startup/auth: `enabled`, `botToken`, `tokenFile`, `accounts.*`
|
||||
- startup/auth: `enabled`, `botToken`, `tokenFile`, `accounts.*` (`tokenFile` must point to a regular file; symlinks are rejected)
|
||||
- access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*`, top-level `bindings[]` (`type: "acp"`)
|
||||
- exec approvals: `execApprovals`, `accounts.*.execApprovals`
|
||||
- command/menu: `commands.native`, `commands.nativeSkills`, `customCommands`
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ Provider options:
|
|||
|
||||
- `channels.zalo.enabled`: enable/disable channel startup.
|
||||
- `channels.zalo.botToken`: bot token from Zalo Bot Platform.
|
||||
- `channels.zalo.tokenFile`: read token from file path.
|
||||
- `channels.zalo.tokenFile`: read token from a regular file path. Symlinks are rejected.
|
||||
- `channels.zalo.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`. The wizard will ask for numeric IDs.
|
||||
- `channels.zalo.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
|
||||
|
|
@ -193,7 +193,7 @@ Provider options:
|
|||
Multi-account options:
|
||||
|
||||
- `channels.zalo.accounts.<id>.botToken`: per-account token.
|
||||
- `channels.zalo.accounts.<id>.tokenFile`: per-account token file.
|
||||
- `channels.zalo.accounts.<id>.tokenFile`: per-account regular token file. Symlinks are rejected.
|
||||
- `channels.zalo.accounts.<id>.name`: display name.
|
||||
- `channels.zalo.accounts.<id>.enabled`: enable/disable account.
|
||||
- `channels.zalo.accounts.<id>.dmPolicy`: per-account DM policy.
|
||||
|
|
|
|||
|
|
@ -92,3 +92,40 @@ Pass `--token` or `--password` explicitly. Missing explicit credentials is an er
|
|||
- These commands require `operator.pairing` (or `operator.admin`) scope.
|
||||
- `devices clear` is intentionally gated by `--yes`.
|
||||
- If pairing scope is unavailable on local loopback (and no explicit `--url` is passed), list/approve can use a local pairing fallback.
|
||||
|
||||
## Token drift recovery checklist
|
||||
|
||||
Use this when Control UI or other clients keep failing with `AUTH_TOKEN_MISMATCH` or `AUTH_DEVICE_TOKEN_MISMATCH`.
|
||||
|
||||
1. Confirm current gateway token source:
|
||||
|
||||
```bash
|
||||
openclaw config get gateway.auth.token
|
||||
```
|
||||
|
||||
2. List paired devices and identify the affected device id:
|
||||
|
||||
```bash
|
||||
openclaw devices list
|
||||
```
|
||||
|
||||
3. Rotate operator token for the affected device:
|
||||
|
||||
```bash
|
||||
openclaw devices rotate --device <deviceId> --role operator
|
||||
```
|
||||
|
||||
4. If rotation is not enough, remove stale pairing and approve again:
|
||||
|
||||
```bash
|
||||
openclaw devices remove <deviceId>
|
||||
openclaw devices list
|
||||
openclaw devices approve <requestId>
|
||||
```
|
||||
|
||||
5. Retry client connection with the current shared token/password.
|
||||
|
||||
Related:
|
||||
|
||||
- [Dashboard auth troubleshooting](/web/dashboard#if-you-see-unauthorized-1008)
|
||||
- [Gateway troubleshooting](/gateway/troubleshooting#dashboard-control-ui-connectivity)
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
|||
}
|
||||
```
|
||||
|
||||
- Bot token: `channels.telegram.botToken` or `channels.telegram.tokenFile`, with `TELEGRAM_BOT_TOKEN` as fallback for the default account.
|
||||
- Bot token: `channels.telegram.botToken` or `channels.telegram.tokenFile` (regular file only; symlinks rejected), with `TELEGRAM_BOT_TOKEN` as fallback for the default account.
|
||||
- Optional `channels.telegram.defaultAccount` overrides default account selection when it matches a configured account id.
|
||||
- In multi-account setups (2+ account ids), set an explicit default (`channels.telegram.defaultAccount` or `channels.telegram.accounts.default`) to avoid fallback routing; `openclaw doctor` warns when this is missing or invalid.
|
||||
- `configWrites: false` blocks Telegram-initiated config writes (supergroup ID migrations, `/config set|unset`).
|
||||
|
|
@ -304,6 +304,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
|||
```
|
||||
|
||||
- Token: `channels.discord.token`, with `DISCORD_BOT_TOKEN` as fallback for the default account.
|
||||
- Direct outbound calls that provide an explicit Discord `token` use that token for the call; account retry/policy settings still come from the selected account in the active runtime snapshot.
|
||||
- Optional `channels.discord.defaultAccount` overrides default account selection when it matches a configured account id.
|
||||
- Use `user:<id>` (DM) or `channel:<id>` (guild channel) for delivery targets; bare numeric IDs are rejected.
|
||||
- Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged name (no `#`). Prefer guild IDs.
|
||||
|
|
@ -2712,6 +2713,7 @@ Validation:
|
|||
- `source: "env"` id pattern: `^[A-Z][A-Z0-9_]{0,127}$`
|
||||
- `source: "file"` id: absolute JSON pointer (for example `"/providers/openai/apiKey"`)
|
||||
- `source: "exec"` id pattern: `^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$`
|
||||
- `source: "exec"` ids must not contain `.` or `..` slash-delimited path segments (for example `a/../b` is rejected)
|
||||
|
||||
### Supported credential surface
|
||||
|
||||
|
|
|
|||
|
|
@ -206,6 +206,12 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
|
|||
persisted by the client for future connects.
|
||||
- Device tokens can be rotated/revoked via `device.token.rotate` and
|
||||
`device.token.revoke` (requires `operator.pairing` scope).
|
||||
- Auth failures include `error.details.code` plus recovery hints:
|
||||
- `error.details.canRetryWithDeviceToken` (boolean)
|
||||
- `error.details.recommendedNextStep` (`retry_with_device_token`, `update_auth_configuration`, `update_auth_credentials`, `wait_then_retry`, `review_auth_configuration`)
|
||||
- Client behavior for `AUTH_TOKEN_MISMATCH`:
|
||||
- Trusted clients may attempt one bounded retry with a cached per-device token.
|
||||
- If that retry fails, clients should stop automatic reconnect loops and surface operator action guidance.
|
||||
|
||||
## Device identity + pairing
|
||||
|
||||
|
|
@ -217,8 +223,9 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
|
|||
- **Local** connects include loopback and the gateway host’s own tailnet address
|
||||
(so same‑host tailnet binds can still auto‑approve).
|
||||
- All WS clients must include `device` identity during `connect` (operator + node).
|
||||
Control UI can omit it **only** when `gateway.controlUi.dangerouslyDisableDeviceAuth`
|
||||
is enabled for break-glass use.
|
||||
Control UI can omit it only in these modes:
|
||||
- `gateway.controlUi.allowInsecureAuth=true` for localhost-only insecure HTTP compatibility.
|
||||
- `gateway.controlUi.dangerouslyDisableDeviceAuth=true` (break-glass, severe security downgrade).
|
||||
- All connections must sign the server-provided `connect.challenge` nonce.
|
||||
|
||||
### Device auth migration diagnostics
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ Secrets are resolved into an in-memory runtime snapshot.
|
|||
- Startup fails fast when an effectively active SecretRef cannot be resolved.
|
||||
- Reload uses atomic swap: full success, or keep the last-known-good snapshot.
|
||||
- Runtime requests read from the active in-memory snapshot only.
|
||||
- Outbound delivery paths also read from that active snapshot (for example Discord reply/thread delivery and Telegram action sends); they do not re-resolve SecretRefs on each send.
|
||||
|
||||
This keeps secret-provider outages off hot request paths.
|
||||
|
||||
|
|
@ -113,6 +114,7 @@ Validation:
|
|||
|
||||
- `provider` must match `^[a-z][a-z0-9_-]{0,63}$`
|
||||
- `id` must match `^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$`
|
||||
- `id` must not contain `.` or `..` as slash-delimited path segments (for example `a/../b` is rejected)
|
||||
|
||||
## Provider config
|
||||
|
||||
|
|
@ -321,6 +323,7 @@ Activation contract:
|
|||
- Success swaps the snapshot atomically.
|
||||
- Startup failure aborts gateway startup.
|
||||
- Runtime reload failure keeps the last-known-good snapshot.
|
||||
- Providing an explicit per-call channel token to an outbound helper/tool call does not trigger SecretRef activation; activation points remain startup, reload, and explicit `secrets.reload`.
|
||||
|
||||
## Degraded and recovered signals
|
||||
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ If you run `--deep`, OpenClaw also attempts a best-effort live Gateway probe.
|
|||
Use this when auditing access or deciding what to back up:
|
||||
|
||||
- **WhatsApp**: `~/.openclaw/credentials/whatsapp/<accountId>/creds.json`
|
||||
- **Telegram bot token**: config/env or `channels.telegram.tokenFile`
|
||||
- **Telegram bot token**: config/env or `channels.telegram.tokenFile` (regular file only; symlinks rejected)
|
||||
- **Discord bot token**: config/env or SecretRef (env/file/exec providers)
|
||||
- **Slack tokens**: config/env (`channels.slack.*`)
|
||||
- **Pairing allowlists**:
|
||||
|
|
@ -262,9 +262,14 @@ High-signal `checkId` values you will most likely see in real deployments (not e
|
|||
## Control UI over HTTP
|
||||
|
||||
The Control UI needs a **secure context** (HTTPS or localhost) to generate device
|
||||
identity. `gateway.controlUi.allowInsecureAuth` does **not** bypass secure-context,
|
||||
device-identity, or device-pairing checks. Prefer HTTPS (Tailscale Serve) or open
|
||||
the UI on `127.0.0.1`.
|
||||
identity. `gateway.controlUi.allowInsecureAuth` is a local compatibility toggle:
|
||||
|
||||
- On localhost, it allows Control UI auth without device identity when the page
|
||||
is loaded over non-secure HTTP.
|
||||
- It does not bypass pairing checks.
|
||||
- It does not relax remote (non-localhost) device identity requirements.
|
||||
|
||||
Prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`.
|
||||
|
||||
For break-glass scenarios only, `gateway.controlUi.dangerouslyDisableDeviceAuth`
|
||||
disables device identity checks entirely. This is a severe security downgrade;
|
||||
|
|
|
|||
|
|
@ -113,9 +113,21 @@ Common signatures:
|
|||
challenge-based device auth flow (`connect.challenge` + `device.nonce`).
|
||||
- `device signature invalid` / `device signature expired` → client signed the wrong
|
||||
payload (or stale timestamp) for the current handshake.
|
||||
- `unauthorized` / reconnect loop → token/password mismatch.
|
||||
- `AUTH_TOKEN_MISMATCH` with `canRetryWithDeviceToken=true` → client can do one trusted retry with cached device token.
|
||||
- repeated `unauthorized` after that retry → shared token/device token drift; refresh token config and re-approve/rotate device token if needed.
|
||||
- `gateway connect failed:` → wrong host/port/url target.
|
||||
|
||||
### Auth detail codes quick map
|
||||
|
||||
Use `error.details.code` from the failed `connect` response to pick the next action:
|
||||
|
||||
| Detail code | Meaning | Recommended action |
|
||||
| ---------------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `AUTH_TOKEN_MISSING` | Client did not send a required shared token. | Paste/set token in the client and retry. For dashboard paths: `openclaw config get gateway.auth.token` then paste into Control UI settings. |
|
||||
| `AUTH_TOKEN_MISMATCH` | Shared token did not match gateway auth token. | If `canRetryWithDeviceToken=true`, allow one trusted retry. If still failing, run the [token drift recovery checklist](/cli/devices#token-drift-recovery-checklist). |
|
||||
| `AUTH_DEVICE_TOKEN_MISMATCH` | Cached per-device token is stale or revoked. | Rotate/re-approve device token using [devices CLI](/cli/devices), then reconnect. |
|
||||
| `PAIRING_REQUIRED` | Device identity is known but not approved for this role. | Approve pending request: `openclaw devices list` then `openclaw devices approve <requestId>`. |
|
||||
|
||||
Device auth v2 migration check:
|
||||
|
||||
```bash
|
||||
|
|
@ -135,6 +147,7 @@ Related:
|
|||
- [/web/control-ui](/web/control-ui)
|
||||
- [/gateway/authentication](/gateway/authentication)
|
||||
- [/gateway/remote](/gateway/remote)
|
||||
- [/cli/devices](/cli/devices)
|
||||
|
||||
## Gateway service not running
|
||||
|
||||
|
|
|
|||
|
|
@ -2512,6 +2512,7 @@ Your gateway is running with auth enabled (`gateway.auth.*`), but the UI is not
|
|||
Facts (from code):
|
||||
|
||||
- The Control UI keeps the token in `sessionStorage` for the current browser tab session and selected gateway URL, so same-tab refreshes keep working without restoring long-lived localStorage token persistence.
|
||||
- On `AUTH_TOKEN_MISMATCH`, trusted clients can attempt one bounded retry with a cached device token when the gateway returns retry hints (`canRetryWithDeviceToken=true`, `recommendedNextStep=retry_with_device_token`).
|
||||
|
||||
Fix:
|
||||
|
||||
|
|
@ -2520,6 +2521,9 @@ Fix:
|
|||
- If remote, tunnel first: `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/`.
|
||||
- Set `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`) on the gateway host.
|
||||
- In the Control UI settings, paste the same token.
|
||||
- If mismatch persists after the one retry, rotate/re-approve the paired device token:
|
||||
- `openclaw devices list`
|
||||
- `openclaw devices rotate --device <id> --role operator`
|
||||
- Still stuck? Run `openclaw status --all` and follow [Troubleshooting](/gateway/troubleshooting). See [Dashboard](/web/dashboard) for auth details.
|
||||
|
||||
### I set gatewaybind tailnet but it can't bind nothing listens
|
||||
|
|
|
|||
|
|
@ -409,3 +409,6 @@ When you fix a provider/model issue discovered in live:
|
|||
- Prefer targeting the smallest layer that catches the bug:
|
||||
- provider request conversion/replay bug → direct models test
|
||||
- gateway session/history/tool pipeline bug → gateway live smoke or CI-safe gateway mock test
|
||||
- SecretRef traversal guardrail:
|
||||
- `src/secrets/exec-secret-ref-id-parity.test.ts` derives one sampled target per SecretRef class from registry metadata (`listSecretTargetRegistryEntries()`), then asserts traversal-segment exec ids are rejected.
|
||||
- If you add a new `includeInPlan` SecretRef target family in `src/secrets/target-registry-data.ts`, update `classifyTargetClass` in that test. The test intentionally fails on unclassified target ids so new classes cannot be skipped silently.
|
||||
|
|
|
|||
|
|
@ -136,7 +136,8 @@ flowchart TD
|
|||
Common log signatures:
|
||||
|
||||
- `device identity required` → HTTP/non-secure context cannot complete device auth.
|
||||
- `unauthorized` / reconnect loop → wrong token/password or auth mode mismatch.
|
||||
- `AUTH_TOKEN_MISMATCH` with retry hints (`canRetryWithDeviceToken=true`) → one trusted device-token retry may occur automatically.
|
||||
- repeated `unauthorized` after that retry → wrong token/password, auth mode mismatch, or stale paired device token.
|
||||
- `gateway connect failed:` → UI is targeting the wrong URL/port or unreachable gateway.
|
||||
|
||||
Deep pages:
|
||||
|
|
|
|||
|
|
@ -19,6 +19,32 @@ When the operator says “release”, immediately do this preflight (no extra qu
|
|||
- Load env from `~/.profile` and confirm `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect vars are set (SPARKLE_PRIVATE_KEY_FILE should live in `~/.profile`).
|
||||
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
|
||||
|
||||
## Versioning
|
||||
|
||||
Current OpenClaw releases use date-based versioning.
|
||||
|
||||
- Stable release version: `YYYY.M.D`
|
||||
- Git tag: `vYYYY.M.D`
|
||||
- Examples from repo history: `v2026.2.26`, `v2026.3.8`
|
||||
- Beta prerelease version: `YYYY.M.D-beta.N`
|
||||
- Git tag: `vYYYY.M.D-beta.N`
|
||||
- Examples from repo history: `v2026.2.15-beta.1`, `v2026.3.8-beta.1`
|
||||
- Use the same version string everywhere, minus the leading `v` where Git tags are not used:
|
||||
- `package.json`: `2026.3.8`
|
||||
- Git tag: `v2026.3.8`
|
||||
- GitHub release title: `openclaw 2026.3.8`
|
||||
- Do not zero-pad month or day. Use `2026.3.8`, not `2026.03.08`.
|
||||
- Stable and beta are npm dist-tags, not separate release lines:
|
||||
- `latest` = stable
|
||||
- `beta` = prerelease/testing
|
||||
- Dev is the moving head of `main`, not a normal git-tagged release.
|
||||
- The release workflow enforces the current stable/beta tag formats and rejects versions whose CalVer date is more than 2 UTC calendar days away from the release date.
|
||||
|
||||
Historical note:
|
||||
|
||||
- Older tags such as `v2026.1.11-1`, `v2026.2.6-3`, and `v2.0.0-beta2` exist in repo history.
|
||||
- Treat those as legacy tag patterns. New releases should use `vYYYY.M.D` for stable and `vYYYY.M.D-beta.N` for beta.
|
||||
|
||||
1. **Version & metadata**
|
||||
|
||||
- [ ] Bump `package.json` version (e.g., `2026.1.29`).
|
||||
|
|
@ -67,8 +93,11 @@ When the operator says “release”, immediately do this preflight (no extra qu
|
|||
6. **Publish (npm)**
|
||||
|
||||
- [ ] Confirm git status is clean; commit and push as needed.
|
||||
- [ ] `npm login` (verify 2FA) if needed.
|
||||
- [ ] `npm publish --access public` (use `--tag beta` for pre-releases).
|
||||
- [ ] Confirm npm trusted publishing is configured for the `openclaw` package.
|
||||
- [ ] Push the matching git tag to trigger `.github/workflows/openclaw-npm-release.yml`.
|
||||
- Stable tags publish to npm `latest`.
|
||||
- Beta tags publish to npm `beta`.
|
||||
- The workflow rejects tags that do not match `package.json`, are not on `main`, or whose CalVer date is more than 2 UTC calendar days away from the release date.
|
||||
- [ ] Verify the registry: `npm view openclaw version`, `npm view openclaw dist-tags`, and `npx -y openclaw@X.Y.Z --version` (or `--help`).
|
||||
|
||||
### Troubleshooting (notes from 2.0.0-beta2 release)
|
||||
|
|
@ -84,6 +113,7 @@ When the operator says “release”, immediately do this preflight (no extra qu
|
|||
7. **GitHub release + appcast**
|
||||
|
||||
- [ ] Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z` (or `git push --tags`).
|
||||
- Pushing the tag also triggers the npm release workflow.
|
||||
- [ ] Create/refresh the GitHub release for `vX.Y.Z` with **title `openclaw X.Y.Z`** (not just the tag); body should include the **full** changelog section for that version (Highlights + Changes + Fixes), inline (no bare links), and **must not repeat the title inside the body**.
|
||||
- [ ] Attach artifacts: `npm pack` tarball (optional), `OpenClaw-X.Y.Z.zip`, and `OpenClaw-X.Y.Z.dSYM.zip` (if generated).
|
||||
- [ ] Commit the updated `appcast.xml` and push it (Sparkle feeds from main).
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ openclaw health
|
|||
Use this when debugging auth or deciding what to back up:
|
||||
|
||||
- **WhatsApp**: `~/.openclaw/credentials/whatsapp/<accountId>/creds.json`
|
||||
- **Telegram bot token**: config/env or `channels.telegram.tokenFile`
|
||||
- **Telegram bot token**: config/env or `channels.telegram.tokenFile` (regular file only; symlinks rejected)
|
||||
- **Discord bot token**: config/env or SecretRef (env/file/exec providers)
|
||||
- **Slack tokens**: config/env (`channels.slack.*`)
|
||||
- **Pairing allowlists**:
|
||||
|
|
|
|||
|
|
@ -243,9 +243,36 @@ Interface details:
|
|||
- `mode: "session"` requires `thread: true`
|
||||
- `cwd` (optional): requested runtime working directory (validated by backend/runtime policy).
|
||||
- `label` (optional): operator-facing label used in session/banner text.
|
||||
- `resumeSessionId` (optional): resume an existing ACP session instead of creating a new one. The agent replays its conversation history via `session/load`. Requires `runtime: "acp"`.
|
||||
- `streamTo` (optional): `"parent"` streams initial ACP run progress summaries back to the requester session as system events.
|
||||
- When available, accepted responses include `streamLogPath` pointing to a session-scoped JSONL log (`<sessionId>.acp-stream.jsonl`) you can tail for full relay history.
|
||||
|
||||
### Resume an existing session
|
||||
|
||||
Use `resumeSessionId` to continue a previous ACP session instead of starting fresh. The agent replays its conversation history via `session/load`, so it picks up with full context of what came before.
|
||||
|
||||
```json
|
||||
{
|
||||
"task": "Continue where we left off — fix the remaining test failures",
|
||||
"runtime": "acp",
|
||||
"agentId": "codex",
|
||||
"resumeSessionId": "<previous-session-id>"
|
||||
}
|
||||
```
|
||||
|
||||
Common use cases:
|
||||
|
||||
- Hand off a Codex session from your laptop to your phone — tell your agent to pick up where you left off
|
||||
- Continue a coding session you started interactively in the CLI, now headlessly through your agent
|
||||
- Pick up work that was interrupted by a gateway restart or idle timeout
|
||||
|
||||
Notes:
|
||||
|
||||
- `resumeSessionId` requires `runtime: "acp"` — returns an error if used with the sub-agent runtime.
|
||||
- `resumeSessionId` restores the upstream ACP conversation history; `thread` and `mode` still apply normally to the new OpenClaw session you are creating, so `mode: "session"` still requires `thread: true`.
|
||||
- The target agent must support `session/load` (Codex and Claude Code do).
|
||||
- If the session ID isn't found, the spawn fails with a clear error — no silent fallback to a new session.
|
||||
|
||||
### Operator smoke test
|
||||
|
||||
Use this after a gateway deploy when you want a quick live check that ACP spawn
|
||||
|
|
|
|||
|
|
@ -174,7 +174,12 @@ OpenClaw **blocks** Control UI connections without device identity.
|
|||
}
|
||||
```
|
||||
|
||||
`allowInsecureAuth` does not bypass Control UI device identity or pairing checks.
|
||||
`allowInsecureAuth` is a local compatibility toggle only:
|
||||
|
||||
- It allows localhost Control UI sessions to proceed without device identity in
|
||||
non-secure HTTP contexts.
|
||||
- It does not bypass pairing checks.
|
||||
- It does not relax remote (non-localhost) device identity requirements.
|
||||
|
||||
**Break-glass only:**
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ Prefer localhost, Tailscale Serve, or an SSH tunnel.
|
|||
## If you see “unauthorized” / 1008
|
||||
|
||||
- Ensure the gateway is reachable (local: `openclaw status`; remote: SSH tunnel `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/`).
|
||||
- For `AUTH_TOKEN_MISMATCH`, clients may do one trusted retry with a cached device token when the gateway returns retry hints. If auth still fails after that retry, resolve token drift manually.
|
||||
- For token drift repair steps, follow [Token drift recovery checklist](/cli/devices#token-drift-recovery-checklist).
|
||||
- Retrieve or supply the token from the gateway host:
|
||||
- Plaintext config: `openclaw config get gateway.auth.token`
|
||||
- SecretRef-managed config: resolve the external secret provider or export `OPENCLAW_GATEWAY_TOKEN` in this shell, then rerun `openclaw dashboard`
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import {
|
|||
ACPX_PINNED_VERSION,
|
||||
createAcpxPluginConfigSchema,
|
||||
resolveAcpxPluginConfig,
|
||||
toAcpMcpServers,
|
||||
} from "./config.js";
|
||||
|
||||
describe("acpx plugin config parsing", () => {
|
||||
|
|
@ -20,9 +19,9 @@ describe("acpx plugin config parsing", () => {
|
|||
expect(resolved.command).toBe(ACPX_BUNDLED_BIN);
|
||||
expect(resolved.expectedVersion).toBe(ACPX_PINNED_VERSION);
|
||||
expect(resolved.allowPluginLocalInstall).toBe(true);
|
||||
expect(resolved.stripProviderAuthEnvVars).toBe(true);
|
||||
expect(resolved.cwd).toBe(path.resolve("/tmp/workspace"));
|
||||
expect(resolved.strictWindowsCmdWrapper).toBe(true);
|
||||
expect(resolved.mcpServers).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts command override and disables plugin-local auto-install", () => {
|
||||
|
|
@ -37,6 +36,7 @@ describe("acpx plugin config parsing", () => {
|
|||
expect(resolved.command).toBe(path.resolve(command));
|
||||
expect(resolved.expectedVersion).toBeUndefined();
|
||||
expect(resolved.allowPluginLocalInstall).toBe(false);
|
||||
expect(resolved.stripProviderAuthEnvVars).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves relative command paths against workspace directory", () => {
|
||||
|
|
@ -50,6 +50,7 @@ describe("acpx plugin config parsing", () => {
|
|||
expect(resolved.command).toBe(path.resolve("/home/user/repos/openclaw", "../acpx/dist/cli.js"));
|
||||
expect(resolved.expectedVersion).toBeUndefined();
|
||||
expect(resolved.allowPluginLocalInstall).toBe(false);
|
||||
expect(resolved.stripProviderAuthEnvVars).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps bare command names as-is", () => {
|
||||
|
|
@ -63,6 +64,7 @@ describe("acpx plugin config parsing", () => {
|
|||
expect(resolved.command).toBe("acpx");
|
||||
expect(resolved.expectedVersion).toBeUndefined();
|
||||
expect(resolved.allowPluginLocalInstall).toBe(false);
|
||||
expect(resolved.stripProviderAuthEnvVars).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts exact expectedVersion override", () => {
|
||||
|
|
@ -78,6 +80,7 @@ describe("acpx plugin config parsing", () => {
|
|||
expect(resolved.command).toBe(path.resolve(command));
|
||||
expect(resolved.expectedVersion).toBe("0.1.99");
|
||||
expect(resolved.allowPluginLocalInstall).toBe(false);
|
||||
expect(resolved.stripProviderAuthEnvVars).toBe(false);
|
||||
});
|
||||
|
||||
it("treats expectedVersion=any as no version constraint", () => {
|
||||
|
|
@ -134,97 +137,4 @@ describe("acpx plugin config parsing", () => {
|
|||
}),
|
||||
).toThrow("strictWindowsCmdWrapper must be a boolean");
|
||||
});
|
||||
|
||||
it("accepts mcp server maps", () => {
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
mcpServers: {
|
||||
canva: {
|
||||
command: "npx",
|
||||
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
|
||||
env: {
|
||||
CANVA_TOKEN: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
|
||||
expect(resolved.mcpServers).toEqual({
|
||||
canva: {
|
||||
command: "npx",
|
||||
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
|
||||
env: {
|
||||
CANVA_TOKEN: "secret",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid mcp server definitions", () => {
|
||||
expect(() =>
|
||||
resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
mcpServers: {
|
||||
canva: {
|
||||
command: "npx",
|
||||
args: ["-y", 1],
|
||||
},
|
||||
},
|
||||
},
|
||||
workspaceDir: "/tmp/workspace",
|
||||
}),
|
||||
).toThrow(
|
||||
"mcpServers.canva must have a command string, optional args array, and optional env object",
|
||||
);
|
||||
});
|
||||
|
||||
it("schema accepts mcp server config", () => {
|
||||
const schema = createAcpxPluginConfigSchema();
|
||||
if (!schema.safeParse) {
|
||||
throw new Error("acpx config schema missing safeParse");
|
||||
}
|
||||
const parsed = schema.safeParse({
|
||||
mcpServers: {
|
||||
canva: {
|
||||
command: "npx",
|
||||
args: ["-y", "mcp-remote@latest"],
|
||||
env: {
|
||||
CANVA_TOKEN: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toAcpMcpServers", () => {
|
||||
it("converts plugin config maps into ACP stdio MCP entries", () => {
|
||||
expect(
|
||||
toAcpMcpServers({
|
||||
canva: {
|
||||
command: "npx",
|
||||
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
|
||||
env: {
|
||||
CANVA_TOKEN: "secret",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
name: "canva",
|
||||
command: "npx",
|
||||
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
|
||||
env: [
|
||||
{
|
||||
name: "CANVA_TOKEN",
|
||||
value: "secret",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export type ResolvedAcpxPluginConfig = {
|
|||
command: string;
|
||||
expectedVersion?: string;
|
||||
allowPluginLocalInstall: boolean;
|
||||
stripProviderAuthEnvVars: boolean;
|
||||
installCommand: string;
|
||||
cwd: string;
|
||||
permissionMode: AcpxPermissionMode;
|
||||
|
|
@ -332,6 +333,7 @@ export function resolveAcpxPluginConfig(params: {
|
|||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
const allowPluginLocalInstall = command === ACPX_BUNDLED_BIN;
|
||||
const stripProviderAuthEnvVars = command === ACPX_BUNDLED_BIN;
|
||||
const configuredExpectedVersion = normalized.expectedVersion;
|
||||
const expectedVersion =
|
||||
configuredExpectedVersion === ACPX_VERSION_ANY
|
||||
|
|
@ -343,6 +345,7 @@ export function resolveAcpxPluginConfig(params: {
|
|||
command,
|
||||
expectedVersion,
|
||||
allowPluginLocalInstall,
|
||||
stripProviderAuthEnvVars,
|
||||
installCommand,
|
||||
cwd,
|
||||
permissionMode: normalized.permissionMode ?? DEFAULT_PERMISSION_MODE,
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ describe("acpx ensure", () => {
|
|||
command: "/plugin/node_modules/.bin/acpx",
|
||||
args: ["--version"],
|
||||
cwd: "/plugin",
|
||||
stripProviderAuthEnvVars: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -148,6 +149,30 @@ describe("acpx ensure", () => {
|
|||
command: "/custom/acpx",
|
||||
args: ["--help"],
|
||||
cwd: "/custom",
|
||||
stripProviderAuthEnvVars: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards stripProviderAuthEnvVars to version checks", async () => {
|
||||
spawnAndCollectMock.mockResolvedValueOnce({
|
||||
stdout: "Usage: acpx [options]\n",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
error: null,
|
||||
});
|
||||
|
||||
await checkAcpxVersion({
|
||||
command: "/plugin/node_modules/.bin/acpx",
|
||||
cwd: "/plugin",
|
||||
expectedVersion: undefined,
|
||||
stripProviderAuthEnvVars: true,
|
||||
});
|
||||
|
||||
expect(spawnAndCollectMock).toHaveBeenCalledWith({
|
||||
command: "/plugin/node_modules/.bin/acpx",
|
||||
args: ["--help"],
|
||||
cwd: "/plugin",
|
||||
stripProviderAuthEnvVars: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -186,6 +211,54 @@ describe("acpx ensure", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("threads stripProviderAuthEnvVars through version probes and install", async () => {
|
||||
spawnAndCollectMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: "acpx 0.0.9\n",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
error: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: "added 1 package\n",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
error: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: `acpx ${ACPX_PINNED_VERSION}\n`,
|
||||
stderr: "",
|
||||
code: 0,
|
||||
error: null,
|
||||
});
|
||||
|
||||
await ensureAcpx({
|
||||
command: "/plugin/node_modules/.bin/acpx",
|
||||
pluginRoot: "/plugin",
|
||||
expectedVersion: ACPX_PINNED_VERSION,
|
||||
stripProviderAuthEnvVars: true,
|
||||
});
|
||||
|
||||
expect(spawnAndCollectMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
command: "/plugin/node_modules/.bin/acpx",
|
||||
args: ["--version"],
|
||||
cwd: "/plugin",
|
||||
stripProviderAuthEnvVars: true,
|
||||
});
|
||||
expect(spawnAndCollectMock.mock.calls[1]?.[0]).toMatchObject({
|
||||
command: "npm",
|
||||
args: ["install", "--omit=dev", "--no-save", `acpx@${ACPX_PINNED_VERSION}`],
|
||||
cwd: "/plugin",
|
||||
stripProviderAuthEnvVars: true,
|
||||
});
|
||||
expect(spawnAndCollectMock.mock.calls[2]?.[0]).toMatchObject({
|
||||
command: "/plugin/node_modules/.bin/acpx",
|
||||
args: ["--version"],
|
||||
cwd: "/plugin",
|
||||
stripProviderAuthEnvVars: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails with actionable error when npm install fails", async () => {
|
||||
spawnAndCollectMock
|
||||
.mockResolvedValueOnce({
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ export async function checkAcpxVersion(params: {
|
|||
command: string;
|
||||
cwd?: string;
|
||||
expectedVersion?: string;
|
||||
stripProviderAuthEnvVars?: boolean;
|
||||
spawnOptions?: SpawnCommandOptions;
|
||||
}): Promise<AcpxVersionCheckResult> {
|
||||
const expectedVersion = params.expectedVersion?.trim() || undefined;
|
||||
|
|
@ -113,6 +114,7 @@ export async function checkAcpxVersion(params: {
|
|||
command: params.command,
|
||||
args: probeArgs,
|
||||
cwd,
|
||||
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
|
||||
};
|
||||
let result: Awaited<ReturnType<typeof spawnAndCollect>>;
|
||||
try {
|
||||
|
|
@ -198,6 +200,7 @@ export async function ensureAcpx(params: {
|
|||
pluginRoot?: string;
|
||||
expectedVersion?: string;
|
||||
allowInstall?: boolean;
|
||||
stripProviderAuthEnvVars?: boolean;
|
||||
spawnOptions?: SpawnCommandOptions;
|
||||
}): Promise<void> {
|
||||
if (pendingEnsure) {
|
||||
|
|
@ -214,6 +217,7 @@ export async function ensureAcpx(params: {
|
|||
command: params.command,
|
||||
cwd: pluginRoot,
|
||||
expectedVersion,
|
||||
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
|
||||
spawnOptions: params.spawnOptions,
|
||||
});
|
||||
if (precheck.ok) {
|
||||
|
|
@ -231,6 +235,7 @@ export async function ensureAcpx(params: {
|
|||
command: "npm",
|
||||
args: ["install", "--omit=dev", "--no-save", `acpx@${installVersion}`],
|
||||
cwd: pluginRoot,
|
||||
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
|
||||
});
|
||||
|
||||
if (install.error) {
|
||||
|
|
@ -252,6 +257,7 @@ export async function ensureAcpx(params: {
|
|||
command: params.command,
|
||||
cwd: pluginRoot,
|
||||
expectedVersion,
|
||||
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
|
||||
spawnOptions: params.spawnOptions,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { spawnAndCollectMock } = vi.hoisted(() => ({
|
||||
spawnAndCollectMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./process.js", () => ({
|
||||
spawnAndCollect: spawnAndCollectMock,
|
||||
}));
|
||||
|
||||
import { __testing, resolveAcpxAgentCommand } from "./mcp-agent-command.js";
|
||||
|
||||
describe("resolveAcpxAgentCommand", () => {
|
||||
it("threads stripProviderAuthEnvVars through the config show probe", async () => {
|
||||
spawnAndCollectMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
agents: {
|
||||
codex: {
|
||||
command: "custom-codex",
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const command = await resolveAcpxAgentCommand({
|
||||
acpxCommand: "/plugin/node_modules/.bin/acpx",
|
||||
cwd: "/plugin",
|
||||
agent: "codex",
|
||||
stripProviderAuthEnvVars: true,
|
||||
});
|
||||
|
||||
expect(command).toBe("custom-codex");
|
||||
expect(spawnAndCollectMock).toHaveBeenCalledWith(
|
||||
{
|
||||
command: "/plugin/node_modules/.bin/acpx",
|
||||
args: ["--cwd", "/plugin", "config", "show"],
|
||||
cwd: "/plugin",
|
||||
stripProviderAuthEnvVars: true,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMcpProxyAgentCommand", () => {
|
||||
it("escapes Windows-style proxy paths without double-escaping backslashes", () => {
|
||||
const quoted = __testing.quoteCommandPart(
|
||||
"C:\\repo\\extensions\\acpx\\src\\runtime-internals\\mcp-proxy.mjs",
|
||||
);
|
||||
|
||||
expect(quoted).toBe(
|
||||
'"C:\\\\repo\\\\extensions\\\\acpx\\\\src\\\\runtime-internals\\\\mcp-proxy.mjs"',
|
||||
);
|
||||
expect(quoted).not.toContain("\\\\\\");
|
||||
});
|
||||
});
|
||||
|
|
@ -37,6 +37,10 @@ function quoteCommandPart(value: string): string {
|
|||
return `"${value.replace(/["\\]/g, "\\$&")}"`;
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
quoteCommandPart,
|
||||
};
|
||||
|
||||
function toCommandLine(parts: string[]): string {
|
||||
return parts.map(quoteCommandPart).join(" ");
|
||||
}
|
||||
|
|
@ -62,6 +66,7 @@ function readConfiguredAgentOverrides(value: unknown): Record<string, string> {
|
|||
async function loadAgentOverrides(params: {
|
||||
acpxCommand: string;
|
||||
cwd: string;
|
||||
stripProviderAuthEnvVars?: boolean;
|
||||
spawnOptions?: SpawnCommandOptions;
|
||||
}): Promise<Record<string, string>> {
|
||||
const result = await spawnAndCollect(
|
||||
|
|
@ -69,6 +74,7 @@ async function loadAgentOverrides(params: {
|
|||
command: params.acpxCommand,
|
||||
args: ["--cwd", params.cwd, "config", "show"],
|
||||
cwd: params.cwd,
|
||||
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
|
||||
},
|
||||
params.spawnOptions,
|
||||
);
|
||||
|
|
@ -87,12 +93,14 @@ export async function resolveAcpxAgentCommand(params: {
|
|||
acpxCommand: string;
|
||||
cwd: string;
|
||||
agent: string;
|
||||
stripProviderAuthEnvVars?: boolean;
|
||||
spawnOptions?: SpawnCommandOptions;
|
||||
}): Promise<string> {
|
||||
const normalizedAgent = normalizeAgentName(params.agent);
|
||||
const overrides = await loadAgentOverrides({
|
||||
acpxCommand: params.acpxCommand,
|
||||
cwd: params.cwd,
|
||||
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
|
||||
spawnOptions: params.spawnOptions,
|
||||
});
|
||||
return overrides[normalizedAgent] ?? ACPX_BUILTIN_AGENT_COMMANDS[normalizedAgent] ?? params.agent;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
|
|||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createWindowsCmdShimFixture } from "../../../shared/windows-cmd-shim-test-fixtures.js";
|
||||
import {
|
||||
resolveSpawnCommand,
|
||||
|
|
@ -28,6 +28,7 @@ async function createTempDir(): Promise<string> {
|
|||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (!dir) {
|
||||
|
|
@ -289,4 +290,99 @@ describe("spawnAndCollect", () => {
|
|||
const result = await resultPromise;
|
||||
expect(result.error?.name).toBe("AbortError");
|
||||
});
|
||||
|
||||
it("strips shared provider auth env vars from spawned acpx children", async () => {
|
||||
vi.stubEnv("OPENAI_API_KEY", "openai-secret");
|
||||
vi.stubEnv("GITHUB_TOKEN", "gh-secret");
|
||||
vi.stubEnv("HF_TOKEN", "hf-secret");
|
||||
vi.stubEnv("OPENCLAW_API_KEY", "keep-me");
|
||||
|
||||
const result = await spawnAndCollect({
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
"process.stdout.write(JSON.stringify({openai:process.env.OPENAI_API_KEY,github:process.env.GITHUB_TOKEN,hf:process.env.HF_TOKEN,openclaw:process.env.OPENCLAW_API_KEY,shell:process.env.OPENCLAW_SHELL}))",
|
||||
],
|
||||
cwd: process.cwd(),
|
||||
stripProviderAuthEnvVars: true,
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.error).toBeNull();
|
||||
|
||||
const parsed = JSON.parse(result.stdout) as {
|
||||
openai?: string;
|
||||
github?: string;
|
||||
hf?: string;
|
||||
openclaw?: string;
|
||||
shell?: string;
|
||||
};
|
||||
expect(parsed.openai).toBeUndefined();
|
||||
expect(parsed.github).toBeUndefined();
|
||||
expect(parsed.hf).toBeUndefined();
|
||||
expect(parsed.openclaw).toBe("keep-me");
|
||||
expect(parsed.shell).toBe("acp");
|
||||
});
|
||||
|
||||
it("strips provider auth env vars case-insensitively", async () => {
|
||||
vi.stubEnv("OpenAI_Api_Key", "openai-secret");
|
||||
vi.stubEnv("Github_Token", "gh-secret");
|
||||
vi.stubEnv("OPENCLAW_API_KEY", "keep-me");
|
||||
|
||||
const result = await spawnAndCollect({
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
"process.stdout.write(JSON.stringify({openai:process.env.OpenAI_Api_Key,github:process.env.Github_Token,openclaw:process.env.OPENCLAW_API_KEY,shell:process.env.OPENCLAW_SHELL}))",
|
||||
],
|
||||
cwd: process.cwd(),
|
||||
stripProviderAuthEnvVars: true,
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.error).toBeNull();
|
||||
|
||||
const parsed = JSON.parse(result.stdout) as {
|
||||
openai?: string;
|
||||
github?: string;
|
||||
openclaw?: string;
|
||||
shell?: string;
|
||||
};
|
||||
expect(parsed.openai).toBeUndefined();
|
||||
expect(parsed.github).toBeUndefined();
|
||||
expect(parsed.openclaw).toBe("keep-me");
|
||||
expect(parsed.shell).toBe("acp");
|
||||
});
|
||||
|
||||
it("preserves provider auth env vars for explicit custom commands by default", async () => {
|
||||
vi.stubEnv("OPENAI_API_KEY", "openai-secret");
|
||||
vi.stubEnv("GITHUB_TOKEN", "gh-secret");
|
||||
vi.stubEnv("HF_TOKEN", "hf-secret");
|
||||
vi.stubEnv("OPENCLAW_API_KEY", "keep-me");
|
||||
|
||||
const result = await spawnAndCollect({
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
"process.stdout.write(JSON.stringify({openai:process.env.OPENAI_API_KEY,github:process.env.GITHUB_TOKEN,hf:process.env.HF_TOKEN,openclaw:process.env.OPENCLAW_API_KEY,shell:process.env.OPENCLAW_SHELL}))",
|
||||
],
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.error).toBeNull();
|
||||
|
||||
const parsed = JSON.parse(result.stdout) as {
|
||||
openai?: string;
|
||||
github?: string;
|
||||
hf?: string;
|
||||
openclaw?: string;
|
||||
shell?: string;
|
||||
};
|
||||
expect(parsed.openai).toBe("openai-secret");
|
||||
expect(parsed.github).toBe("gh-secret");
|
||||
expect(parsed.hf).toBe("hf-secret");
|
||||
expect(parsed.openclaw).toBe("keep-me");
|
||||
expect(parsed.shell).toBe("acp");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import type {
|
|||
} from "openclaw/plugin-sdk/acpx";
|
||||
import {
|
||||
applyWindowsSpawnProgramPolicy,
|
||||
listKnownProviderAuthEnvVarNames,
|
||||
materializeWindowsSpawnProgram,
|
||||
omitEnvKeysCaseInsensitive,
|
||||
resolveWindowsSpawnProgramCandidate,
|
||||
} from "openclaw/plugin-sdk/acpx";
|
||||
|
||||
|
|
@ -125,6 +127,7 @@ export function spawnWithResolvedCommand(
|
|||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
stripProviderAuthEnvVars?: boolean;
|
||||
},
|
||||
options?: SpawnCommandOptions,
|
||||
): ChildProcessWithoutNullStreams {
|
||||
|
|
@ -136,9 +139,15 @@ export function spawnWithResolvedCommand(
|
|||
options,
|
||||
);
|
||||
|
||||
const childEnv = omitEnvKeysCaseInsensitive(
|
||||
process.env,
|
||||
params.stripProviderAuthEnvVars ? listKnownProviderAuthEnvVarNames() : [],
|
||||
);
|
||||
childEnv.OPENCLAW_SHELL = "acp";
|
||||
|
||||
return spawn(resolved.command, resolved.args, {
|
||||
cwd: params.cwd,
|
||||
env: { ...process.env, OPENCLAW_SHELL: "acp" },
|
||||
env: childEnv,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
shell: resolved.shell,
|
||||
windowsHide: resolved.windowsHide,
|
||||
|
|
@ -180,6 +189,7 @@ export async function spawnAndCollect(
|
|||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
stripProviderAuthEnvVars?: boolean;
|
||||
},
|
||||
options?: SpawnCommandOptions,
|
||||
runtime?: {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { runAcpRuntimeAdapterContract } from "../../../src/acp/runtime/adapter-contract.testkit.js";
|
||||
import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
|
||||
import {
|
||||
|
|
@ -19,13 +19,14 @@ beforeAll(async () => {
|
|||
{
|
||||
command: "/definitely/missing/acpx",
|
||||
allowPluginLocalInstall: false,
|
||||
stripProviderAuthEnvVars: false,
|
||||
installCommand: "n/a",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: {},
|
||||
permissionMode: "approve-reads",
|
||||
nonInteractivePermissions: "fail",
|
||||
strictWindowsCmdWrapper: true,
|
||||
queueOwnerTtlSeconds: 0.1,
|
||||
mcpServers: {},
|
||||
},
|
||||
{ logger: NOOP_LOGGER },
|
||||
);
|
||||
|
|
@ -165,7 +166,7 @@ describe("AcpxRuntime", () => {
|
|||
for await (const _event of runtime.runTurn({
|
||||
handle,
|
||||
text: "describe this image",
|
||||
attachments: [{ mediaType: "image/png", data: "aW1hZ2UtYnl0ZXM=" }],
|
||||
attachments: [{ mediaType: "image/png", data: "aW1hZ2UtYnl0ZXM=" }], // pragma: allowlist secret
|
||||
mode: "prompt",
|
||||
requestId: "req-image",
|
||||
})) {
|
||||
|
|
@ -186,6 +187,40 @@ describe("AcpxRuntime", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("preserves provider auth env vars when runtime uses a custom acpx command", async () => {
|
||||
vi.stubEnv("OPENAI_API_KEY", "openai-secret"); // pragma: allowlist secret
|
||||
vi.stubEnv("GITHUB_TOKEN", "gh-secret"); // pragma: allowlist secret
|
||||
|
||||
try {
|
||||
const { runtime, logPath } = await createMockRuntimeFixture();
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:acp:custom-env",
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
});
|
||||
|
||||
for await (const _event of runtime.runTurn({
|
||||
handle,
|
||||
text: "custom-env",
|
||||
mode: "prompt",
|
||||
requestId: "req-custom-env",
|
||||
})) {
|
||||
// Drain events; assertions inspect the mock runtime log.
|
||||
}
|
||||
|
||||
const logs = await readMockRuntimeLogEntries(logPath);
|
||||
const prompt = logs.find(
|
||||
(entry) =>
|
||||
entry.kind === "prompt" &&
|
||||
String(entry.sessionName ?? "") === "agent:codex:acp:custom-env",
|
||||
);
|
||||
expect(prompt?.openaiApiKey).toBe("openai-secret");
|
||||
expect(prompt?.githubToken).toBe("gh-secret");
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves leading spaces across streamed text deltas", async () => {
|
||||
const runtime = sharedFixture?.runtime;
|
||||
expect(runtime).toBeDefined();
|
||||
|
|
@ -395,7 +430,7 @@ describe("AcpxRuntime", () => {
|
|||
command: "npx",
|
||||
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
|
||||
env: {
|
||||
CANVA_TOKEN: "secret",
|
||||
CANVA_TOKEN: "secret", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -170,6 +170,7 @@ export class AcpxRuntime implements AcpRuntime {
|
|||
command: this.config.command,
|
||||
cwd: this.config.cwd,
|
||||
expectedVersion: this.config.expectedVersion,
|
||||
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
|
||||
spawnOptions: this.spawnCommandOptions,
|
||||
});
|
||||
if (!versionCheck.ok) {
|
||||
|
|
@ -183,6 +184,7 @@ export class AcpxRuntime implements AcpRuntime {
|
|||
command: this.config.command,
|
||||
args: ["--help"],
|
||||
cwd: this.config.cwd,
|
||||
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
|
||||
},
|
||||
this.spawnCommandOptions,
|
||||
);
|
||||
|
|
@ -309,6 +311,7 @@ export class AcpxRuntime implements AcpRuntime {
|
|||
command: this.config.command,
|
||||
args,
|
||||
cwd: state.cwd,
|
||||
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
|
||||
},
|
||||
this.spawnCommandOptions,
|
||||
);
|
||||
|
|
@ -495,6 +498,7 @@ export class AcpxRuntime implements AcpRuntime {
|
|||
command: this.config.command,
|
||||
cwd: this.config.cwd,
|
||||
expectedVersion: this.config.expectedVersion,
|
||||
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
|
||||
spawnOptions: this.spawnCommandOptions,
|
||||
});
|
||||
if (!versionCheck.ok) {
|
||||
|
|
@ -518,6 +522,7 @@ export class AcpxRuntime implements AcpRuntime {
|
|||
command: this.config.command,
|
||||
args: ["--help"],
|
||||
cwd: this.config.cwd,
|
||||
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
|
||||
},
|
||||
this.spawnCommandOptions,
|
||||
);
|
||||
|
|
@ -683,6 +688,7 @@ export class AcpxRuntime implements AcpRuntime {
|
|||
acpxCommand: this.config.command,
|
||||
cwd: params.cwd,
|
||||
agent: params.agent,
|
||||
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
|
||||
spawnOptions: this.spawnCommandOptions,
|
||||
});
|
||||
const resolved = buildMcpProxyAgentCommand({
|
||||
|
|
@ -705,6 +711,7 @@ export class AcpxRuntime implements AcpRuntime {
|
|||
command: this.config.command,
|
||||
args: params.args,
|
||||
cwd: params.cwd,
|
||||
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
|
||||
},
|
||||
this.spawnCommandOptions,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -89,6 +89,11 @@ describe("createAcpxRuntimeService", () => {
|
|||
|
||||
await vi.waitFor(() => {
|
||||
expect(ensureAcpxSpy).toHaveBeenCalledOnce();
|
||||
expect(ensureAcpxSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stripProviderAuthEnvVars: true,
|
||||
}),
|
||||
);
|
||||
expect(probeAvailabilitySpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -59,9 +59,8 @@ export function createAcpxRuntimeService(
|
|||
});
|
||||
const expectedVersionLabel = pluginConfig.expectedVersion ?? "any";
|
||||
const installLabel = pluginConfig.allowPluginLocalInstall ? "enabled" : "disabled";
|
||||
const mcpServerCount = Object.keys(pluginConfig.mcpServers).length;
|
||||
ctx.logger.info(
|
||||
`acpx runtime backend registered (command: ${pluginConfig.command}, expectedVersion: ${expectedVersionLabel}, pluginLocalInstall: ${installLabel}${mcpServerCount > 0 ? `, mcpServers: ${mcpServerCount}` : ""})`,
|
||||
`acpx runtime backend registered (command: ${pluginConfig.command}, expectedVersion: ${expectedVersionLabel}, pluginLocalInstall: ${installLabel})`,
|
||||
);
|
||||
|
||||
lifecycleRevision += 1;
|
||||
|
|
@ -73,6 +72,7 @@ export function createAcpxRuntimeService(
|
|||
logger: ctx.logger,
|
||||
expectedVersion: pluginConfig.expectedVersion,
|
||||
allowInstall: pluginConfig.allowPluginLocalInstall,
|
||||
stripProviderAuthEnvVars: pluginConfig.stripProviderAuthEnvVars,
|
||||
spawnOptions: {
|
||||
strictWindowsCmdWrapper: pluginConfig.strictWindowsCmdWrapper,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -204,6 +204,8 @@ if (command === "prompt") {
|
|||
sessionName: sessionFromOption,
|
||||
stdinText,
|
||||
openclawShell,
|
||||
openaiApiKey: process.env.OPENAI_API_KEY || "",
|
||||
githubToken: process.env.GITHUB_TOKEN || "",
|
||||
});
|
||||
const requestId = "req-1";
|
||||
|
||||
|
|
@ -326,6 +328,7 @@ export async function createMockRuntimeFixture(params?: {
|
|||
const config: ResolvedAcpxPluginConfig = {
|
||||
command: scriptPath,
|
||||
allowPluginLocalInstall: false,
|
||||
stripProviderAuthEnvVars: false,
|
||||
installCommand: "n/a",
|
||||
cwd: dir,
|
||||
permissionMode: params?.permissionMode ?? "approve-all",
|
||||
|
|
@ -378,6 +381,7 @@ export async function readMockRuntimeLogEntries(
|
|||
|
||||
export async function cleanupMockRuntimeFixtures(): Promise<void> {
|
||||
delete process.env.MOCK_ACPX_LOG;
|
||||
delete process.env.MOCK_ACPX_CONFIG_SHOW_AGENTS;
|
||||
sharedMockCliScriptPath = null;
|
||||
logFileSequence = 0;
|
||||
while (tempDirs.length > 0) {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
import {
|
||||
buildAccountScopedDmSecurityPolicy,
|
||||
collectOpenGroupPolicyRestrictSendersWarnings,
|
||||
createAccountStatusSink,
|
||||
formatNormalizedAllowFromEntries,
|
||||
mapAllowFromEntries,
|
||||
} from "openclaw/plugin-sdk/compat";
|
||||
|
|
@ -369,8 +370,11 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
const webhookPath = resolveWebhookPathFromConfig(account.config);
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
const statusSink = createAccountStatusSink({
|
||||
accountId: ctx.accountId,
|
||||
setStatus: ctx.setStatus,
|
||||
});
|
||||
statusSink({
|
||||
baseUrl: account.baseUrl,
|
||||
});
|
||||
ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`);
|
||||
|
|
@ -379,7 +383,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
||||
statusSink,
|
||||
webhookPath,
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import {
|
||||
AllowFromEntrySchema,
|
||||
AllowFromListSchema,
|
||||
buildCatchallMultiAccountChannelSchema,
|
||||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
} from "openclaw/plugin-sdk/compat";
|
||||
import { z } from "zod";
|
||||
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
|
||||
|
|
@ -35,10 +37,10 @@ const bluebubblesAccountSchema = z
|
|||
serverUrl: z.string().optional(),
|
||||
password: buildSecretInputSchema().optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
||||
allowFrom: z.array(AllowFromEntrySchema).optional(),
|
||||
groupAllowFrom: z.array(AllowFromEntrySchema).optional(),
|
||||
groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
|
||||
dmPolicy: DmPolicySchema.optional(),
|
||||
allowFrom: AllowFromListSchema,
|
||||
groupAllowFrom: AllowFromListSchema,
|
||||
groupPolicy: GroupPolicySchema.optional(),
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
formatDocsLink,
|
||||
mergeAllowFromEntries,
|
||||
normalizeAccountId,
|
||||
patchScopedAccountConfig,
|
||||
resolveAccountIdForConfigure,
|
||||
setTopLevelChannelDmPolicyWithAllowFrom,
|
||||
} from "openclaw/plugin-sdk/bluebubbles";
|
||||
|
|
@ -38,34 +39,14 @@ function setBlueBubblesAllowFrom(
|
|||
accountId: string,
|
||||
allowFrom: string[],
|
||||
): OpenClawConfig {
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
bluebubbles: {
|
||||
...cfg.channels?.bluebubbles,
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
bluebubbles: {
|
||||
...cfg.channels?.bluebubbles,
|
||||
accounts: {
|
||||
...cfg.channels?.bluebubbles?.accounts,
|
||||
[accountId]: {
|
||||
...cfg.channels?.bluebubbles?.accounts?.[accountId],
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
return patchScopedAccountConfig({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
patch: { allowFrom },
|
||||
ensureChannelEnabled: false,
|
||||
ensureAccountEnabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
function parseBlueBubblesAllowFromInput(raw: string): string[] {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/diffs";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTempDiffRoot } from "./test-helpers.js";
|
||||
|
||||
const { launchMock } = vi.hoisted(() => ({
|
||||
launchMock: vi.fn(),
|
||||
|
|
@ -17,10 +17,11 @@ vi.mock("playwright-core", () => ({
|
|||
describe("PlaywrightDiffScreenshotter", () => {
|
||||
let rootDir: string;
|
||||
let outputPath: string;
|
||||
let cleanupRootDir: () => Promise<void>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-diffs-browser-"));
|
||||
({ rootDir, cleanup: cleanupRootDir } = await createTempDiffRoot("openclaw-diffs-browser-"));
|
||||
outputPath = path.join(rootDir, "preview.png");
|
||||
launchMock.mockReset();
|
||||
const browserModule = await import("./browser.js");
|
||||
|
|
@ -31,7 +32,7 @@ describe("PlaywrightDiffScreenshotter", () => {
|
|||
const browserModule = await import("./browser.js");
|
||||
await browserModule.resetSharedBrowserStateForTests();
|
||||
vi.useRealTimers();
|
||||
await fs.rm(rootDir, { recursive: true, force: true });
|
||||
await cleanupRootDir();
|
||||
});
|
||||
|
||||
it("reuses the same browser across renders and closes it after the idle window", async () => {
|
||||
|
|
|
|||
|
|
@ -1,32 +1,24 @@
|
|||
import fs from "node:fs/promises";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createMockServerResponse } from "../../../src/test-utils/mock-http-response.js";
|
||||
import { createDiffsHttpHandler } from "./http.js";
|
||||
import { DiffArtifactStore } from "./store.js";
|
||||
import { createDiffStoreHarness } from "./test-helpers.js";
|
||||
|
||||
describe("createDiffsHttpHandler", () => {
|
||||
let rootDir: string;
|
||||
let store: DiffArtifactStore;
|
||||
let cleanupRootDir: () => Promise<void>;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-diffs-http-"));
|
||||
store = new DiffArtifactStore({ rootDir });
|
||||
({ store, cleanup: cleanupRootDir } = await createDiffStoreHarness("openclaw-diffs-http-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(rootDir, { recursive: true, force: true });
|
||||
await cleanupRootDir();
|
||||
});
|
||||
|
||||
it("serves a stored diff document", async () => {
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>viewer</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
const artifact = await createViewerArtifact(store);
|
||||
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
|
|
@ -45,12 +37,7 @@ describe("createDiffsHttpHandler", () => {
|
|||
});
|
||||
|
||||
it("rejects invalid tokens", async () => {
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>viewer</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
const artifact = await createViewerArtifact(store);
|
||||
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
|
|
@ -113,96 +100,52 @@ describe("createDiffsHttpHandler", () => {
|
|||
expect(String(res.body)).toContain("openclawDiffsReady");
|
||||
});
|
||||
|
||||
it("blocks non-loopback viewer access by default", async () => {
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>viewer</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
it.each([
|
||||
{
|
||||
name: "blocks non-loopback viewer access by default",
|
||||
request: remoteReq,
|
||||
allowRemoteViewer: false,
|
||||
expectedStatusCode: 404,
|
||||
},
|
||||
{
|
||||
name: "blocks loopback requests that carry proxy forwarding headers by default",
|
||||
request: localReq,
|
||||
headers: { "x-forwarded-for": "203.0.113.10" },
|
||||
allowRemoteViewer: false,
|
||||
expectedStatusCode: 404,
|
||||
},
|
||||
{
|
||||
name: "allows remote access when allowRemoteViewer is enabled",
|
||||
request: remoteReq,
|
||||
allowRemoteViewer: true,
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
{
|
||||
name: "allows proxied loopback requests when allowRemoteViewer is enabled",
|
||||
request: localReq,
|
||||
headers: { "x-forwarded-for": "203.0.113.10" },
|
||||
allowRemoteViewer: true,
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
])("$name", async ({ request, headers, allowRemoteViewer, expectedStatusCode }) => {
|
||||
const artifact = await createViewerArtifact(store);
|
||||
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const handler = createDiffsHttpHandler({ store, allowRemoteViewer });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
remoteReq({
|
||||
request({
|
||||
method: "GET",
|
||||
url: artifact.viewerPath,
|
||||
headers,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("blocks loopback requests that carry proxy forwarding headers by default", async () => {
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>viewer</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: artifact.viewerPath,
|
||||
headers: { "x-forwarded-for": "203.0.113.10" },
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("allows remote access when allowRemoteViewer is enabled", async () => {
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>viewer</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
|
||||
const handler = createDiffsHttpHandler({ store, allowRemoteViewer: true });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
remoteReq({
|
||||
method: "GET",
|
||||
url: artifact.viewerPath,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe("<html>viewer</html>");
|
||||
});
|
||||
|
||||
it("allows proxied loopback requests when allowRemoteViewer is enabled", async () => {
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>viewer</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
|
||||
const handler = createDiffsHttpHandler({ store, allowRemoteViewer: true });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: artifact.viewerPath,
|
||||
headers: { "x-forwarded-for": "203.0.113.10" },
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe("<html>viewer</html>");
|
||||
expect(res.statusCode).toBe(expectedStatusCode);
|
||||
if (expectedStatusCode === 200) {
|
||||
expect(res.body).toBe("<html>viewer</html>");
|
||||
}
|
||||
});
|
||||
|
||||
it("rate-limits repeated remote misses", async () => {
|
||||
|
|
@ -232,6 +175,15 @@ describe("createDiffsHttpHandler", () => {
|
|||
});
|
||||
});
|
||||
|
||||
async function createViewerArtifact(store: DiffArtifactStore) {
|
||||
return await store.createArtifact({
|
||||
html: "<html>viewer</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
}
|
||||
|
||||
function localReq(input: {
|
||||
method: string;
|
||||
url: string;
|
||||
|
|
|
|||
|
|
@ -1,21 +1,25 @@
|
|||
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 { DiffArtifactStore } from "./store.js";
|
||||
import { createDiffStoreHarness } from "./test-helpers.js";
|
||||
|
||||
describe("DiffArtifactStore", () => {
|
||||
let rootDir: string;
|
||||
let store: DiffArtifactStore;
|
||||
let cleanupRootDir: () => Promise<void>;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-diffs-store-"));
|
||||
store = new DiffArtifactStore({ rootDir });
|
||||
({
|
||||
rootDir,
|
||||
store,
|
||||
cleanup: cleanupRootDir,
|
||||
} = await createDiffStoreHarness("openclaw-diffs-store-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await fs.rm(rootDir, { recursive: true, force: true });
|
||||
await cleanupRootDir();
|
||||
});
|
||||
|
||||
it("creates and retrieves an artifact", async () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { DiffArtifactStore } from "./store.js";
|
||||
|
||||
export async function createTempDiffRoot(prefix: string): Promise<{
|
||||
rootDir: string;
|
||||
cleanup: () => Promise<void>;
|
||||
}> {
|
||||
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
return {
|
||||
rootDir,
|
||||
cleanup: async () => {
|
||||
await fs.rm(rootDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function createDiffStoreHarness(prefix: string): Promise<{
|
||||
rootDir: string;
|
||||
store: DiffArtifactStore;
|
||||
cleanup: () => Promise<void>;
|
||||
}> {
|
||||
const { rootDir, cleanup } = await createTempDiffRoot(prefix);
|
||||
return {
|
||||
rootDir,
|
||||
store: new DiffArtifactStore({ rootDir }),
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,25 +1,24 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/diffs";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DiffScreenshotter } from "./browser.js";
|
||||
import { DEFAULT_DIFFS_TOOL_DEFAULTS } from "./config.js";
|
||||
import { DiffArtifactStore } from "./store.js";
|
||||
import { createDiffStoreHarness } from "./test-helpers.js";
|
||||
import { createDiffsTool } from "./tool.js";
|
||||
import type { DiffRenderOptions } from "./types.js";
|
||||
|
||||
describe("diffs tool", () => {
|
||||
let rootDir: string;
|
||||
let store: DiffArtifactStore;
|
||||
let cleanupRootDir: () => Promise<void>;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-diffs-tool-"));
|
||||
store = new DiffArtifactStore({ rootDir });
|
||||
({ store, cleanup: cleanupRootDir } = await createDiffStoreHarness("openclaw-diffs-tool-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(rootDir, { recursive: true, force: true });
|
||||
await cleanupRootDir();
|
||||
});
|
||||
|
||||
it("returns a viewer URL in view mode", async () => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
|
||||
import {
|
||||
buildAccountScopedDmSecurityPolicy,
|
||||
buildOpenGroupPolicyConfigureRouteAllowlistWarning,
|
||||
collectAllowlistProviderGroupPolicyWarnings,
|
||||
createScopedAccountConfigAccessors,
|
||||
createScopedDmSecurityResolver,
|
||||
formatNormalizedAllowFromEntries,
|
||||
} from "openclaw/plugin-sdk/compat";
|
||||
import {
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
buildComputedAccountStatusSnapshot,
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
createAccountStatusSink,
|
||||
getChatChannelMeta,
|
||||
listDirectoryGroupEntriesFromMapKeys,
|
||||
listDirectoryUserEntriesFromAllowFrom,
|
||||
|
|
@ -21,6 +22,7 @@ import {
|
|||
PAIRING_APPROVED_MESSAGE,
|
||||
resolveChannelMediaMaxBytes,
|
||||
resolveGoogleChatGroupRequireMention,
|
||||
runPassiveAccountLifecycle,
|
||||
type ChannelDock,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelPlugin,
|
||||
|
|
@ -84,6 +86,14 @@ const googleChatConfigBase = createScopedChannelConfigBase<ResolvedGoogleChatAcc
|
|||
],
|
||||
});
|
||||
|
||||
const resolveGoogleChatDmPolicy = createScopedDmSecurityResolver<ResolvedGoogleChatAccount>({
|
||||
channelKey: "googlechat",
|
||||
resolvePolicy: (account) => account.config.dm?.policy,
|
||||
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
|
||||
allowFromPathSuffix: "dm.",
|
||||
normalizeEntry: (raw) => formatAllowFromEntry(raw),
|
||||
});
|
||||
|
||||
export const googlechatDock: ChannelDock = {
|
||||
id: "googlechat",
|
||||
capabilities: {
|
||||
|
|
@ -170,18 +180,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
|||
...googleChatConfigAccessors,
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||
return buildAccountScopedDmSecurityPolicy({
|
||||
cfg,
|
||||
channelKey: "googlechat",
|
||||
accountId,
|
||||
fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
|
||||
policy: account.config.dm?.policy,
|
||||
allowFrom: account.config.dm?.allowFrom ?? [],
|
||||
allowFromPathSuffix: "dm.",
|
||||
normalizeEntry: (raw) => formatAllowFromEntry(raw),
|
||||
});
|
||||
},
|
||||
resolveDmPolicy: resolveGoogleChatDmPolicy,
|
||||
collectWarnings: ({ account, cfg }) => {
|
||||
const warnings = collectAllowlistProviderGroupPolicyWarnings({
|
||||
cfg,
|
||||
|
|
@ -512,37 +511,39 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
|||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
ctx.log?.info(`[${account.accountId}] starting Google Chat webhook`);
|
||||
ctx.setStatus({
|
||||
const statusSink = createAccountStatusSink({
|
||||
accountId: account.accountId,
|
||||
setStatus: ctx.setStatus,
|
||||
});
|
||||
ctx.log?.info(`[${account.accountId}] starting Google Chat webhook`);
|
||||
statusSink({
|
||||
running: true,
|
||||
lastStartAt: Date.now(),
|
||||
webhookPath: resolveGoogleChatWebhookPath({ account }),
|
||||
audienceType: account.config.audienceType,
|
||||
audience: account.config.audience,
|
||||
});
|
||||
const unregister = await startGoogleChatMonitor({
|
||||
account,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
await runPassiveAccountLifecycle({
|
||||
abortSignal: ctx.abortSignal,
|
||||
webhookPath: account.config.webhookPath,
|
||||
webhookUrl: account.config.webhookUrl,
|
||||
statusSink: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }),
|
||||
});
|
||||
// Keep the promise pending until abort (webhook mode is passive).
|
||||
await new Promise<void>((resolve) => {
|
||||
if (ctx.abortSignal.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
ctx.abortSignal.addEventListener("abort", () => resolve(), { once: true });
|
||||
});
|
||||
unregister?.();
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
running: false,
|
||||
lastStopAt: Date.now(),
|
||||
start: async () =>
|
||||
await startGoogleChatMonitor({
|
||||
account,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
webhookPath: account.config.webhookPath,
|
||||
webhookUrl: account.config.webhookUrl,
|
||||
statusSink,
|
||||
}),
|
||||
stop: async (unregister) => {
|
||||
unregister?.();
|
||||
},
|
||||
onStop: async () => {
|
||||
statusSink({
|
||||
running: false,
|
||||
lastStopAt: Date.now(),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type { OpenClawConfig, DmPolicy } from "openclaw/plugin-sdk/googlechat";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
applySetupAccountConfigPatch,
|
||||
addWildcardAllowFrom,
|
||||
formatDocsLink,
|
||||
mergeAllowFromEntries,
|
||||
|
|
@ -8,7 +10,6 @@ import {
|
|||
type ChannelOnboardingAdapter,
|
||||
type ChannelOnboardingDmPolicy,
|
||||
type WizardPrompter,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
} from "openclaw/plugin-sdk/googlechat";
|
||||
import {
|
||||
|
|
@ -83,45 +84,6 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
|
|||
promptAllowFrom,
|
||||
};
|
||||
|
||||
function applyAccountConfig(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
patch: Record<string, unknown>;
|
||||
}): OpenClawConfig {
|
||||
const { cfg, accountId, patch } = params;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
googlechat: {
|
||||
...cfg.channels?.["googlechat"],
|
||||
enabled: true,
|
||||
...patch,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
googlechat: {
|
||||
...cfg.channels?.["googlechat"],
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...cfg.channels?.["googlechat"]?.accounts,
|
||||
[accountId]: {
|
||||
...cfg.channels?.["googlechat"]?.accounts?.[accountId],
|
||||
enabled: true,
|
||||
...patch,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function promptCredentials(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prompter: WizardPrompter;
|
||||
|
|
@ -137,7 +99,7 @@ async function promptCredentials(params: {
|
|||
initialValue: true,
|
||||
});
|
||||
if (useEnv) {
|
||||
return applyAccountConfig({ cfg, accountId, patch: {} });
|
||||
return applySetupAccountConfigPatch({ cfg, channelKey: channel, accountId, patch: {} });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -156,8 +118,9 @@ async function promptCredentials(params: {
|
|||
placeholder: "/path/to/service-account.json",
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
return applyAccountConfig({
|
||||
return applySetupAccountConfigPatch({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
patch: { serviceAccountFile: String(path).trim() },
|
||||
});
|
||||
|
|
@ -168,8 +131,9 @@ async function promptCredentials(params: {
|
|||
placeholder: '{"type":"service_account", ... }',
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
return applyAccountConfig({
|
||||
return applySetupAccountConfigPatch({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
patch: { serviceAccount: String(json).trim() },
|
||||
});
|
||||
|
|
@ -200,8 +164,9 @@ async function promptAudience(params: {
|
|||
initialValue: currentAudience || undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
return applyAccountConfig({
|
||||
return applySetupAccountConfigPatch({
|
||||
cfg: params.cfg,
|
||||
channelKey: channel,
|
||||
accountId: params.accountId,
|
||||
patch: { audienceType, audience: String(audience).trim() },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { listIrcAccountIds, resolveDefaultIrcAccountId } from "./accounts.js";
|
||||
import { listIrcAccountIds, resolveDefaultIrcAccountId, resolveIrcAccount } from "./accounts.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
function asConfig(value: unknown): CoreConfig {
|
||||
|
|
@ -76,3 +79,28 @@ describe("resolveDefaultIrcAccountId", () => {
|
|||
expect(resolveDefaultIrcAccountId(cfg)).toBe("aaa");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveIrcAccount", () => {
|
||||
it.runIf(process.platform !== "win32")("rejects symlinked password files", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-irc-account-"));
|
||||
const passwordFile = path.join(dir, "password.txt");
|
||||
const passwordLink = path.join(dir, "password-link.txt");
|
||||
fs.writeFileSync(passwordFile, "secret-pass\n", "utf8");
|
||||
fs.symlinkSync(passwordFile, passwordLink);
|
||||
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
irc: {
|
||||
host: "irc.example.com",
|
||||
nick: "claw",
|
||||
passwordFile: passwordLink,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const account = resolveIrcAccount({ cfg });
|
||||
expect(account.password).toBe("");
|
||||
expect(account.passwordSource).toBe("none");
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/core";
|
||||
import {
|
||||
createAccountListHelpers,
|
||||
normalizeResolvedSecretInputString,
|
||||
|
|
@ -100,13 +100,11 @@ function resolvePassword(accountId: string, merged: IrcAccountConfig) {
|
|||
}
|
||||
|
||||
if (merged.passwordFile?.trim()) {
|
||||
try {
|
||||
const filePassword = readFileSync(merged.passwordFile.trim(), "utf-8").trim();
|
||||
if (filePassword) {
|
||||
return { password: filePassword, source: "passwordFile" as const };
|
||||
}
|
||||
} catch {
|
||||
// Ignore unreadable files here; status will still surface missing configuration.
|
||||
const filePassword = tryReadSecretFileSync(merged.passwordFile, "IRC password file", {
|
||||
rejectSymlink: true,
|
||||
});
|
||||
if (filePassword) {
|
||||
return { password: filePassword, source: "passwordFile" as const };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -137,11 +135,10 @@ function resolveNickServConfig(accountId: string, nickserv?: IrcNickServConfig):
|
|||
envPassword ||
|
||||
"";
|
||||
if (!resolvedPassword && passwordFile) {
|
||||
try {
|
||||
resolvedPassword = readFileSync(passwordFile, "utf-8").trim();
|
||||
} catch {
|
||||
// Ignore unreadable files; monitor/probe status will surface failures.
|
||||
}
|
||||
resolvedPassword =
|
||||
tryReadSecretFileSync(passwordFile, "IRC NickServ password file", {
|
||||
rejectSymlink: true,
|
||||
}) ?? "";
|
||||
}
|
||||
|
||||
const merged: IrcNickServConfig = {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStartAccountContext } from "../../test-utils/start-account-context.js";
|
||||
import type { ResolvedIrcAccount } from "./accounts.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
monitorIrcProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./monitor.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./monitor.js")>("./monitor.js");
|
||||
return {
|
||||
...actual,
|
||||
monitorIrcProvider: hoisted.monitorIrcProvider,
|
||||
};
|
||||
});
|
||||
|
||||
import { ircPlugin } from "./channel.js";
|
||||
|
||||
describe("ircPlugin gateway.startAccount", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("keeps startAccount pending until abort, then stops the monitor", async () => {
|
||||
const stop = vi.fn();
|
||||
hoisted.monitorIrcProvider.mockResolvedValue({ stop });
|
||||
|
||||
const account: ResolvedIrcAccount = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
name: "default",
|
||||
configured: true,
|
||||
host: "irc.example.com",
|
||||
port: 6697,
|
||||
tls: true,
|
||||
nick: "openclaw",
|
||||
username: "openclaw",
|
||||
realname: "OpenClaw",
|
||||
password: "",
|
||||
passwordSource: "none",
|
||||
config: {} as ResolvedIrcAccount["config"],
|
||||
};
|
||||
|
||||
const abort = new AbortController();
|
||||
const task = ircPlugin.gateway!.startAccount!(
|
||||
createStartAccountContext({
|
||||
account,
|
||||
abortSignal: abort.signal,
|
||||
}),
|
||||
);
|
||||
let settled = false;
|
||||
void task.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(hoisted.monitorIrcProvider).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(settled).toBe(false);
|
||||
expect(stop).not.toHaveBeenCalled();
|
||||
|
||||
abort.abort();
|
||||
await task;
|
||||
|
||||
expect(stop).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
|
@ -9,10 +9,12 @@ import {
|
|||
buildBaseAccountStatusSnapshot,
|
||||
buildBaseChannelStatusSummary,
|
||||
buildChannelConfigSchema,
|
||||
createAccountStatusSink,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
getChatChannelMeta,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
runPassiveAccountLifecycle,
|
||||
setAccountEnabledInConfigSection,
|
||||
type ChannelPlugin,
|
||||
} from "openclaw/plugin-sdk/irc";
|
||||
|
|
@ -353,6 +355,10 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = {
|
|||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
const statusSink = createAccountStatusSink({
|
||||
accountId: ctx.accountId,
|
||||
setStatus: ctx.setStatus,
|
||||
});
|
||||
if (!account.configured) {
|
||||
throw new Error(
|
||||
`IRC is not configured for account "${account.accountId}" (need host and nick in channels.irc).`,
|
||||
|
|
@ -361,14 +367,20 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = {
|
|||
ctx.log?.info(
|
||||
`[${account.accountId}] starting IRC provider (${account.host}:${account.port}${account.tls ? " tls" : ""})`,
|
||||
);
|
||||
const { stop } = await monitorIrcProvider({
|
||||
accountId: account.accountId,
|
||||
config: ctx.cfg as CoreConfig,
|
||||
runtime: ctx.runtime,
|
||||
await runPassiveAccountLifecycle({
|
||||
abortSignal: ctx.abortSignal,
|
||||
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
||||
start: async () =>
|
||||
await monitorIrcProvider({
|
||||
accountId: account.accountId,
|
||||
config: ctx.cfg as CoreConfig,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
statusSink,
|
||||
}),
|
||||
stop: async (monitor) => {
|
||||
monitor.stop();
|
||||
},
|
||||
});
|
||||
return { stop };
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
formatDocsLink,
|
||||
patchScopedAccountConfig,
|
||||
promptChannelAccessConfig,
|
||||
resolveAccountIdForConfigure,
|
||||
setTopLevelChannelAllowFrom,
|
||||
|
|
@ -59,35 +60,14 @@ function updateIrcAccountConfig(
|
|||
accountId: string,
|
||||
patch: Partial<IrcAccountConfig>,
|
||||
): CoreConfig {
|
||||
const current = cfg.channels?.irc ?? {};
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
irc: {
|
||||
...current,
|
||||
...patch,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
irc: {
|
||||
...current,
|
||||
accounts: {
|
||||
...current.accounts,
|
||||
[accountId]: {
|
||||
...current.accounts?.[accountId],
|
||||
...patch,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
return patchScopedAccountConfig({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
patch,
|
||||
ensureChannelEnabled: false,
|
||||
ensureAccountEnabled: false,
|
||||
}) as CoreConfig;
|
||||
}
|
||||
|
||||
function setIrcDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import {
|
||||
buildAccountScopedDmSecurityPolicy,
|
||||
createScopedAccountConfigAccessors,
|
||||
collectAllowlistProviderRestrictSendersWarnings,
|
||||
createScopedAccountConfigAccessors,
|
||||
createScopedChannelConfigBase,
|
||||
createScopedDmSecurityResolver,
|
||||
} from "openclaw/plugin-sdk/compat";
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
|
|
@ -43,6 +44,24 @@ const lineConfigAccessors = createScopedAccountConfigAccessors({
|
|||
.map((entry) => entry.replace(/^line:(?:user:)?/i, "")),
|
||||
});
|
||||
|
||||
const lineConfigBase = createScopedChannelConfigBase<ResolvedLineAccount, OpenClawConfig>({
|
||||
sectionKey: "line",
|
||||
listAccountIds: (cfg) => getLineRuntime().channel.line.listLineAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) =>
|
||||
getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId: accountId ?? undefined }),
|
||||
defaultAccountId: (cfg) => getLineRuntime().channel.line.resolveDefaultLineAccountId(cfg),
|
||||
clearBaseFields: ["channelSecret", "tokenFile", "secretFile"],
|
||||
});
|
||||
|
||||
const resolveLineDmPolicy = createScopedDmSecurityResolver<ResolvedLineAccount>({
|
||||
channelKey: "line",
|
||||
resolvePolicy: (account) => account.config.dmPolicy,
|
||||
resolveAllowFrom: (account) => account.config.allowFrom,
|
||||
policyPathSuffix: "dmPolicy",
|
||||
approveHint: "openclaw pairing approve line <code>",
|
||||
normalizeEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""),
|
||||
});
|
||||
|
||||
function patchLineAccountConfig(
|
||||
cfg: OpenClawConfig,
|
||||
lineConfig: LineConfig,
|
||||
|
|
@ -113,40 +132,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
|||
reload: { configPrefixes: ["channels.line"] },
|
||||
configSchema: buildChannelConfigSchema(LineConfigSchema),
|
||||
config: {
|
||||
listAccountIds: (cfg) => getLineRuntime().channel.line.listLineAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) =>
|
||||
getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId: accountId ?? undefined }),
|
||||
defaultAccountId: (cfg) => getLineRuntime().channel.line.resolveDefaultLineAccountId(cfg),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) => {
|
||||
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||
return patchLineAccountConfig(cfg, lineConfig, accountId, { enabled });
|
||||
},
|
||||
deleteAccount: ({ cfg, accountId }) => {
|
||||
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
// oxlint-disable-next-line no-unused-vars
|
||||
const { channelSecret, tokenFile, secretFile, ...rest } = lineConfig;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
line: rest,
|
||||
},
|
||||
};
|
||||
}
|
||||
const accounts = { ...lineConfig.accounts };
|
||||
delete accounts[accountId];
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
line: {
|
||||
...lineConfig,
|
||||
accounts: Object.keys(accounts).length > 0 ? accounts : undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
...lineConfigBase,
|
||||
isConfigured: (account) =>
|
||||
Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()),
|
||||
describeAccount: (account) => ({
|
||||
|
|
@ -159,19 +145,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
|||
...lineConfigAccessors,
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||
return buildAccountScopedDmSecurityPolicy({
|
||||
cfg,
|
||||
channelKey: "line",
|
||||
accountId,
|
||||
fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
|
||||
policy: account.config.dmPolicy,
|
||||
allowFrom: account.config.allowFrom ?? [],
|
||||
policyPathSuffix: "dmPolicy",
|
||||
approveHint: "openclaw pairing approve line <code>",
|
||||
normalizeEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""),
|
||||
});
|
||||
},
|
||||
resolveDmPolicy: resolveLineDmPolicy,
|
||||
collectWarnings: ({ account, cfg }) => {
|
||||
return collectAllowlistProviderRestrictSendersWarnings({
|
||||
cfg,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import {
|
||||
buildAccountScopedDmSecurityPolicy,
|
||||
buildOpenGroupPolicyWarning,
|
||||
collectAllowlistProviderGroupPolicyWarnings,
|
||||
createScopedAccountConfigAccessors,
|
||||
createScopedChannelConfigBase,
|
||||
createScopedDmSecurityResolver,
|
||||
} from "openclaw/plugin-sdk/compat";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
|
|
@ -10,10 +11,8 @@ import {
|
|||
buildProbeChannelStatusSummary,
|
||||
collectStatusIssuesFromLastError,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
normalizeAccountId,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
setAccountEnabledInConfigSection,
|
||||
type ChannelPlugin,
|
||||
} from "openclaw/plugin-sdk/matrix";
|
||||
import { matrixMessageActions } from "./actions.js";
|
||||
|
|
@ -106,6 +105,30 @@ const matrixConfigAccessors = createScopedAccountConfigAccessors({
|
|||
formatAllowFrom: (allowFrom) => normalizeMatrixAllowList(allowFrom),
|
||||
});
|
||||
|
||||
const matrixConfigBase = createScopedChannelConfigBase<ResolvedMatrixAccount, CoreConfig>({
|
||||
sectionKey: "matrix",
|
||||
listAccountIds: listMatrixAccountIds,
|
||||
resolveAccount: (cfg, accountId) => resolveMatrixAccount({ cfg, accountId }),
|
||||
defaultAccountId: resolveDefaultMatrixAccountId,
|
||||
clearBaseFields: [
|
||||
"name",
|
||||
"homeserver",
|
||||
"userId",
|
||||
"accessToken",
|
||||
"password",
|
||||
"deviceName",
|
||||
"initialSyncLimit",
|
||||
],
|
||||
});
|
||||
|
||||
const resolveMatrixDmPolicy = createScopedDmSecurityResolver<ResolvedMatrixAccount>({
|
||||
channelKey: "matrix",
|
||||
resolvePolicy: (account) => account.config.dm?.policy,
|
||||
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
|
||||
allowFromPathSuffix: "dm.",
|
||||
normalizeEntry: (raw) => normalizeMatrixUserId(raw),
|
||||
});
|
||||
|
||||
export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
id: "matrix",
|
||||
meta,
|
||||
|
|
@ -127,32 +150,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||
reload: { configPrefixes: ["channels.matrix"] },
|
||||
configSchema: buildChannelConfigSchema(MatrixConfigSchema),
|
||||
config: {
|
||||
listAccountIds: (cfg) => listMatrixAccountIds(cfg as CoreConfig),
|
||||
resolveAccount: (cfg, accountId) => resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultMatrixAccountId(cfg as CoreConfig),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg: cfg as CoreConfig,
|
||||
sectionKey: "matrix",
|
||||
accountId,
|
||||
enabled,
|
||||
allowTopLevel: true,
|
||||
}),
|
||||
deleteAccount: ({ cfg, accountId }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg: cfg as CoreConfig,
|
||||
sectionKey: "matrix",
|
||||
accountId,
|
||||
clearBaseFields: [
|
||||
"name",
|
||||
"homeserver",
|
||||
"userId",
|
||||
"accessToken",
|
||||
"password",
|
||||
"deviceName",
|
||||
"initialSyncLimit",
|
||||
],
|
||||
}),
|
||||
...matrixConfigBase,
|
||||
isConfigured: (account) => account.configured,
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
|
|
@ -164,18 +162,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||
...matrixConfigAccessors,
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||
return buildAccountScopedDmSecurityPolicy({
|
||||
cfg: cfg as CoreConfig,
|
||||
channelKey: "matrix",
|
||||
accountId,
|
||||
fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
|
||||
policy: account.config.dm?.policy,
|
||||
allowFrom: account.config.dm?.allowFrom ?? [],
|
||||
allowFromPathSuffix: "dm.",
|
||||
normalizeEntry: (raw) => normalizeMatrixUserId(raw),
|
||||
});
|
||||
},
|
||||
resolveDmPolicy: resolveMatrixDmPolicy,
|
||||
collectWarnings: ({ account, cfg }) => {
|
||||
return collectAllowlistProviderGroupPolicyWarnings({
|
||||
cfg: cfg as CoreConfig,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import {
|
||||
AllowFromListSchema,
|
||||
buildNestedDmConfigSchema,
|
||||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
} from "openclaw/plugin-sdk/compat";
|
||||
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/matrix";
|
||||
import { z } from "zod";
|
||||
import { buildSecretInputSchema } from "./secret-input.js";
|
||||
|
||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
|
||||
const matrixActionSchema = z
|
||||
.object({
|
||||
reactions: z.boolean().optional(),
|
||||
|
|
@ -14,14 +18,6 @@ const matrixActionSchema = z
|
|||
})
|
||||
.optional();
|
||||
|
||||
const matrixDmSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
policy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
||||
allowFrom: z.array(allowFromEntry).optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const matrixRoomSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
|
|
@ -29,7 +25,7 @@ const matrixRoomSchema = z
|
|||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
autoReply: z.boolean().optional(),
|
||||
users: z.array(allowFromEntry).optional(),
|
||||
users: AllowFromListSchema,
|
||||
skills: z.array(z.string()).optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
})
|
||||
|
|
@ -49,7 +45,7 @@ export const MatrixConfigSchema = z.object({
|
|||
initialSyncLimit: z.number().optional(),
|
||||
encryption: z.boolean().optional(),
|
||||
allowlistOnly: z.boolean().optional(),
|
||||
groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
|
||||
groupPolicy: GroupPolicySchema.optional(),
|
||||
replyToMode: z.enum(["off", "first", "all"]).optional(),
|
||||
threadReplies: z.enum(["off", "inbound", "always"]).optional(),
|
||||
textChunkLimit: z.number().optional(),
|
||||
|
|
@ -57,9 +53,9 @@ export const MatrixConfigSchema = z.object({
|
|||
responsePrefix: z.string().optional(),
|
||||
mediaMaxMb: z.number().optional(),
|
||||
autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
|
||||
autoJoinAllowlist: z.array(allowFromEntry).optional(),
|
||||
groupAllowFrom: z.array(allowFromEntry).optional(),
|
||||
dm: matrixDmSchema,
|
||||
autoJoinAllowlist: AllowFromListSchema,
|
||||
groupAllowFrom: AllowFromListSchema,
|
||||
dm: buildNestedDmConfigSchema(),
|
||||
groups: z.object({}).catchall(matrixRoomSchema).optional(),
|
||||
rooms: z.object({}).catchall(matrixRoomSchema).optional(),
|
||||
actions: matrixActionSchema,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
compileAllowlist,
|
||||
normalizeStringEntries,
|
||||
resolveAllowlistMatchByCandidates,
|
||||
resolveAllowlistCandidates,
|
||||
type AllowlistMatch,
|
||||
} from "openclaw/plugin-sdk/matrix";
|
||||
|
||||
|
|
@ -75,11 +76,11 @@ export function resolveMatrixAllowListMatch(params: {
|
|||
allowList: string[];
|
||||
userId?: string;
|
||||
}): MatrixAllowListMatch {
|
||||
const allowList = params.allowList;
|
||||
if (allowList.length === 0) {
|
||||
const compiledAllowList = compileAllowlist(params.allowList);
|
||||
if (compiledAllowList.set.size === 0) {
|
||||
return { allowed: false };
|
||||
}
|
||||
if (allowList.includes("*")) {
|
||||
if (compiledAllowList.wildcard) {
|
||||
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
|
||||
}
|
||||
const userId = normalizeMatrixUser(params.userId);
|
||||
|
|
@ -88,7 +89,10 @@ export function resolveMatrixAllowListMatch(params: {
|
|||
{ value: userId ? `matrix:${userId}` : "", source: "prefixed-id" },
|
||||
{ value: userId ? `user:${userId}` : "", source: "prefixed-user" },
|
||||
];
|
||||
return resolveAllowlistMatchByCandidates({ allowList, candidates });
|
||||
return resolveAllowlistCandidates({
|
||||
compiledAllowlist: compiledAllowList,
|
||||
candidates,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveMatrixAllowListMatches(params: { allowList: string[]; userId?: string }) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
applySetupAccountConfigPatch,
|
||||
buildComputedAccountStatusSnapshot,
|
||||
buildChannelConfigSchema,
|
||||
createAccountStatusSink,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
|
|
@ -500,8 +501,11 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
|||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
const statusSink = createAccountStatusSink({
|
||||
accountId: ctx.accountId,
|
||||
setStatus: ctx.setStatus,
|
||||
});
|
||||
statusSink({
|
||||
baseUrl: account.baseUrl,
|
||||
botTokenSource: account.botTokenSource,
|
||||
});
|
||||
|
|
@ -513,7 +517,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
|||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
||||
statusSink,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveNextcloudTalkAccount } from "./accounts.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
describe("resolveNextcloudTalkAccount", () => {
|
||||
it.runIf(process.platform !== "win32")("rejects symlinked botSecretFile paths", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-nextcloud-talk-"));
|
||||
const secretFile = path.join(dir, "secret.txt");
|
||||
const secretLink = path.join(dir, "secret-link.txt");
|
||||
fs.writeFileSync(secretFile, "bot-secret\n", "utf8");
|
||||
fs.symlinkSync(secretFile, secretLink);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
baseUrl: "https://cloud.example.com",
|
||||
botSecretFile: secretLink,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const account = resolveNextcloudTalkAccount({ cfg });
|
||||
expect(account.secret).toBe("");
|
||||
expect(account.secretSource).toBe("none");
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/core";
|
||||
import {
|
||||
createAccountListHelpers,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
|
|
@ -88,13 +88,13 @@ function resolveNextcloudTalkSecret(
|
|||
}
|
||||
|
||||
if (merged.botSecretFile) {
|
||||
try {
|
||||
const fileSecret = readFileSync(merged.botSecretFile, "utf-8").trim();
|
||||
if (fileSecret) {
|
||||
return { secret: fileSecret, source: "secretFile" };
|
||||
}
|
||||
} catch {
|
||||
// File not found or unreadable, fall through.
|
||||
const fileSecret = tryReadSecretFileSync(
|
||||
merged.botSecretFile,
|
||||
"Nextcloud Talk bot secret file",
|
||||
{ rejectSymlink: true },
|
||||
);
|
||||
if (fileSecret) {
|
||||
return { secret: fileSecret, source: "secretFile" };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ import {
|
|||
buildAccountScopedDmSecurityPolicy,
|
||||
collectAllowlistProviderGroupPolicyWarnings,
|
||||
collectOpenGroupPolicyRouteAllowlistWarnings,
|
||||
createAccountStatusSink,
|
||||
formatAllowFromLowercase,
|
||||
mapAllowFromEntries,
|
||||
runPassiveAccountLifecycle,
|
||||
} from "openclaw/plugin-sdk/compat";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
|
|
@ -15,7 +17,6 @@ import {
|
|||
deleteAccountFromConfigSection,
|
||||
normalizeAccountId,
|
||||
setAccountEnabledInConfigSection,
|
||||
waitForAbortSignal,
|
||||
type ChannelPlugin,
|
||||
type OpenClawConfig,
|
||||
type ChannelSetupInput,
|
||||
|
|
@ -338,17 +339,25 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
|
|||
|
||||
ctx.log?.info(`[${account.accountId}] starting Nextcloud Talk webhook server`);
|
||||
|
||||
const { stop } = await monitorNextcloudTalkProvider({
|
||||
accountId: account.accountId,
|
||||
config: ctx.cfg as CoreConfig,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
||||
const statusSink = createAccountStatusSink({
|
||||
accountId: ctx.accountId,
|
||||
setStatus: ctx.setStatus,
|
||||
});
|
||||
|
||||
// Keep webhook channels pending for the account lifecycle.
|
||||
await waitForAbortSignal(ctx.abortSignal);
|
||||
stop();
|
||||
await runPassiveAccountLifecycle({
|
||||
abortSignal: ctx.abortSignal,
|
||||
start: async () =>
|
||||
await monitorNextcloudTalkProvider({
|
||||
accountId: account.accountId,
|
||||
config: ctx.cfg as CoreConfig,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
statusSink,
|
||||
}),
|
||||
stop: async (monitor) => {
|
||||
monitor.stop();
|
||||
},
|
||||
});
|
||||
},
|
||||
logoutAccount: async ({ accountId, cfg }) => {
|
||||
const nextCfg = { ...cfg } as OpenClawConfig;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
import {
|
||||
buildSingleChannelSecretPromptState,
|
||||
formatDocsLink,
|
||||
hasConfiguredSecretInput,
|
||||
mapAllowFromEntries,
|
||||
mergeAllowFromEntries,
|
||||
promptSingleChannelSecretInput,
|
||||
patchScopedAccountConfig,
|
||||
runSingleChannelSecretStep,
|
||||
resolveAccountIdForConfigure,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
setTopLevelChannelDmPolicyWithAllowFrom,
|
||||
type SecretInput,
|
||||
type ChannelOnboardingAdapter,
|
||||
type ChannelOnboardingDmPolicy,
|
||||
type OpenClawConfig,
|
||||
|
|
@ -39,38 +38,12 @@ function setNextcloudTalkAccountConfig(
|
|||
accountId: string,
|
||||
updates: Record<string, unknown>,
|
||||
): CoreConfig {
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
"nextcloud-talk": {
|
||||
...cfg.channels?.["nextcloud-talk"],
|
||||
enabled: true,
|
||||
...updates,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
"nextcloud-talk": {
|
||||
...cfg.channels?.["nextcloud-talk"],
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...cfg.channels?.["nextcloud-talk"]?.accounts,
|
||||
[accountId]: {
|
||||
...cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId],
|
||||
enabled: cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true,
|
||||
...updates,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
return patchScopedAccountConfig({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
patch: updates,
|
||||
}) as CoreConfig;
|
||||
}
|
||||
|
||||
async function noteNextcloudTalkSecretHelp(prompter: WizardPrompter): Promise<void> {
|
||||
|
|
@ -215,12 +188,6 @@ export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||
hasConfiguredSecretInput(resolvedAccount.config.botSecret) ||
|
||||
resolvedAccount.config.botSecretFile,
|
||||
);
|
||||
const secretPromptState = buildSingleChannelSecretPromptState({
|
||||
accountConfigured,
|
||||
hasConfigToken: hasConfigSecret,
|
||||
allowEnv,
|
||||
envValue: process.env.NEXTCLOUD_TALK_BOT_SECRET,
|
||||
});
|
||||
|
||||
let baseUrl = resolvedAccount.baseUrl;
|
||||
if (!baseUrl) {
|
||||
|
|
@ -241,32 +208,35 @@ export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||
).trim();
|
||||
}
|
||||
|
||||
let secret: SecretInput | null = null;
|
||||
if (!accountConfigured) {
|
||||
await noteNextcloudTalkSecretHelp(prompter);
|
||||
}
|
||||
|
||||
const secretResult = await promptSingleChannelSecretInput({
|
||||
const secretStep = await runSingleChannelSecretStep({
|
||||
cfg: next,
|
||||
prompter,
|
||||
providerHint: "nextcloud-talk",
|
||||
credentialLabel: "bot secret",
|
||||
accountConfigured: secretPromptState.accountConfigured,
|
||||
canUseEnv: secretPromptState.canUseEnv,
|
||||
hasConfigToken: secretPromptState.hasConfigToken,
|
||||
accountConfigured,
|
||||
hasConfigToken: hasConfigSecret,
|
||||
allowEnv,
|
||||
envValue: process.env.NEXTCLOUD_TALK_BOT_SECRET,
|
||||
envPrompt: "NEXTCLOUD_TALK_BOT_SECRET detected. Use env var?",
|
||||
keepPrompt: "Nextcloud Talk bot secret already configured. Keep it?",
|
||||
inputPrompt: "Enter Nextcloud Talk bot secret",
|
||||
preferredEnvVar: "NEXTCLOUD_TALK_BOT_SECRET",
|
||||
onMissingConfigured: async () => await noteNextcloudTalkSecretHelp(prompter),
|
||||
applyUseEnv: async (cfg) =>
|
||||
setNextcloudTalkAccountConfig(cfg as CoreConfig, accountId, {
|
||||
baseUrl,
|
||||
}),
|
||||
applySet: async (cfg, value) =>
|
||||
setNextcloudTalkAccountConfig(cfg as CoreConfig, accountId, {
|
||||
baseUrl,
|
||||
botSecret: value,
|
||||
}),
|
||||
});
|
||||
if (secretResult.action === "set") {
|
||||
secret = secretResult.value;
|
||||
}
|
||||
next = secretStep.cfg as CoreConfig;
|
||||
|
||||
if (secretResult.action === "use-env" || secret || baseUrl !== resolvedAccount.baseUrl) {
|
||||
if (secretStep.action === "keep" && baseUrl !== resolvedAccount.baseUrl) {
|
||||
next = setNextcloudTalkAccountConfig(next, accountId, {
|
||||
baseUrl,
|
||||
...(secret ? { botSecret: secret } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -287,26 +257,28 @@ export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
const apiPasswordResult = await promptSingleChannelSecretInput({
|
||||
const apiPasswordStep = await runSingleChannelSecretStep({
|
||||
cfg: next,
|
||||
prompter,
|
||||
providerHint: "nextcloud-talk-api",
|
||||
credentialLabel: "API password",
|
||||
...buildSingleChannelSecretPromptState({
|
||||
accountConfigured: Boolean(existingApiUser && existingApiPasswordConfigured),
|
||||
hasConfigToken: existingApiPasswordConfigured,
|
||||
allowEnv: false,
|
||||
}),
|
||||
accountConfigured: Boolean(existingApiUser && existingApiPasswordConfigured),
|
||||
hasConfigToken: existingApiPasswordConfigured,
|
||||
allowEnv: false,
|
||||
envPrompt: "",
|
||||
keepPrompt: "Nextcloud Talk API password already configured. Keep it?",
|
||||
inputPrompt: "Enter Nextcloud Talk API password",
|
||||
preferredEnvVar: "NEXTCLOUD_TALK_API_PASSWORD",
|
||||
applySet: async (cfg, value) =>
|
||||
setNextcloudTalkAccountConfig(cfg as CoreConfig, accountId, {
|
||||
apiUser,
|
||||
apiPassword: value,
|
||||
}),
|
||||
});
|
||||
const apiPassword = apiPasswordResult.action === "set" ? apiPasswordResult.value : undefined;
|
||||
next = setNextcloudTalkAccountConfig(next, accountId, {
|
||||
apiUser,
|
||||
...(apiPassword ? { apiPassword } : {}),
|
||||
});
|
||||
next =
|
||||
apiPasswordStep.action === "keep"
|
||||
? setNextcloudTalkAccountConfig(next, accountId, { apiUser })
|
||||
: (apiPasswordStep.cfg as CoreConfig);
|
||||
}
|
||||
|
||||
if (forceAllowFrom) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { AllowFromListSchema, DmPolicySchema } from "openclaw/plugin-sdk/compat";
|
||||
import { MarkdownConfigSchema, buildChannelConfigSchema } from "openclaw/plugin-sdk/nostr";
|
||||
import { z } from "zod";
|
||||
|
||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
|
||||
/**
|
||||
* Validates https:// URLs only (no javascript:, data:, file:, etc.)
|
||||
*/
|
||||
|
|
@ -76,10 +75,10 @@ export const NostrConfigSchema = z.object({
|
|||
relays: z.array(z.string()).optional(),
|
||||
|
||||
/** DM access policy: pairing, allowlist, open, or disabled */
|
||||
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
||||
dmPolicy: DmPolicySchema.optional(),
|
||||
|
||||
/** Allowed sender pubkeys (npub or hex format) */
|
||||
allowFrom: z.array(allowFromEntry).optional(),
|
||||
allowFrom: AllowFromListSchema,
|
||||
|
||||
/** Profile metadata (NIP-01 kind:0 content) */
|
||||
profile: NostrProfileSchema.optional(),
|
||||
|
|
|
|||
|
|
@ -313,6 +313,68 @@ describe("telegramPlugin duplicate token guard", () => {
|
|||
expect(result).toMatchObject({ channel: "telegram", messageId: "tg-2" });
|
||||
});
|
||||
|
||||
it("sends outbound payload media lists and keeps buttons on the first message only", async () => {
|
||||
const sendMessageTelegram = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ messageId: "tg-3", chatId: "12345" })
|
||||
.mockResolvedValueOnce({ messageId: "tg-4", chatId: "12345" });
|
||||
setTelegramRuntime({
|
||||
channel: {
|
||||
telegram: {
|
||||
sendMessageTelegram,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
|
||||
const result = await telegramPlugin.outbound!.sendPayload!({
|
||||
cfg: createCfg(),
|
||||
to: "12345",
|
||||
text: "",
|
||||
payload: {
|
||||
text: "Approval required",
|
||||
mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
|
||||
channelData: {
|
||||
telegram: {
|
||||
quoteText: "quoted",
|
||||
buttons: [[{ text: "Allow Once", callback_data: "/approve abc allow-once" }]],
|
||||
},
|
||||
},
|
||||
},
|
||||
mediaLocalRoots: ["/tmp/media"],
|
||||
accountId: "ops",
|
||||
silent: true,
|
||||
});
|
||||
|
||||
expect(sendMessageTelegram).toHaveBeenCalledTimes(2);
|
||||
expect(sendMessageTelegram).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"12345",
|
||||
"Approval required",
|
||||
expect.objectContaining({
|
||||
mediaUrl: "https://example.com/1.jpg",
|
||||
mediaLocalRoots: ["/tmp/media"],
|
||||
quoteText: "quoted",
|
||||
silent: true,
|
||||
buttons: [[{ text: "Allow Once", callback_data: "/approve abc allow-once" }]],
|
||||
}),
|
||||
);
|
||||
expect(sendMessageTelegram).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"12345",
|
||||
"",
|
||||
expect.objectContaining({
|
||||
mediaUrl: "https://example.com/2.jpg",
|
||||
mediaLocalRoots: ["/tmp/media"],
|
||||
quoteText: "quoted",
|
||||
silent: true,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
(sendMessageTelegram.mock.calls[1]?.[2] as Record<string, unknown>)?.buttons,
|
||||
).toBeUndefined();
|
||||
expect(result).toMatchObject({ channel: "telegram", messageId: "tg-4" });
|
||||
});
|
||||
|
||||
it("ignores accounts with missing tokens during duplicate-token checks", async () => {
|
||||
const cfg = createCfg();
|
||||
cfg.channels!.telegram!.accounts!.ops = {} as never;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
|
||||
import {
|
||||
collectAllowlistProviderGroupPolicyWarnings,
|
||||
buildAccountScopedDmSecurityPolicy,
|
||||
collectOpenGroupPolicyRouteAllowlistWarnings,
|
||||
createScopedAccountConfigAccessors,
|
||||
createScopedDmSecurityResolver,
|
||||
formatAllowFromLowercase,
|
||||
} from "openclaw/plugin-sdk/compat";
|
||||
import {
|
||||
|
|
@ -31,6 +31,7 @@ import {
|
|||
resolveTelegramAccount,
|
||||
resolveTelegramGroupRequireMention,
|
||||
resolveTelegramGroupToolPolicy,
|
||||
sendTelegramPayloadMessages,
|
||||
telegramOnboardingAdapter,
|
||||
TelegramConfigSchema,
|
||||
type ChannelMessageActionAdapter,
|
||||
|
|
@ -91,10 +92,6 @@ const telegramMessageActions: ChannelMessageActionAdapter = {
|
|||
},
|
||||
};
|
||||
|
||||
type TelegramInlineButtons = ReadonlyArray<
|
||||
ReadonlyArray<{ text: string; callback_data: string; style?: "danger" | "success" | "primary" }>
|
||||
>;
|
||||
|
||||
const telegramConfigAccessors = createScopedAccountConfigAccessors({
|
||||
resolveAccount: ({ cfg, accountId }) => resolveTelegramAccount({ cfg, accountId }),
|
||||
resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom,
|
||||
|
|
@ -112,6 +109,14 @@ const telegramConfigBase = createScopedChannelConfigBase<ResolvedTelegramAccount
|
|||
clearBaseFields: ["botToken", "tokenFile", "name"],
|
||||
});
|
||||
|
||||
const resolveTelegramDmPolicy = createScopedDmSecurityResolver<ResolvedTelegramAccount>({
|
||||
channelKey: "telegram",
|
||||
resolvePolicy: (account) => account.config.dmPolicy,
|
||||
resolveAllowFrom: (account) => account.config.allowFrom,
|
||||
policyPathSuffix: "dmPolicy",
|
||||
normalizeEntry: (raw) => raw.replace(/^(telegram|tg):/i, ""),
|
||||
});
|
||||
|
||||
export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProbe> = {
|
||||
id: "telegram",
|
||||
meta: {
|
||||
|
|
@ -180,18 +185,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
|||
...telegramConfigAccessors,
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||
return buildAccountScopedDmSecurityPolicy({
|
||||
cfg,
|
||||
channelKey: "telegram",
|
||||
accountId,
|
||||
fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
|
||||
policy: account.config.dmPolicy,
|
||||
allowFrom: account.config.allowFrom ?? [],
|
||||
policyPathSuffix: "dmPolicy",
|
||||
normalizeEntry: (raw) => raw.replace(/^(telegram|tg):/i, ""),
|
||||
});
|
||||
},
|
||||
resolveDmPolicy: resolveTelegramDmPolicy,
|
||||
collectWarnings: ({ account, cfg }) => {
|
||||
const groupAllowlistConfigured =
|
||||
account.config.groups && Object.keys(account.config.groups).length > 0;
|
||||
|
|
@ -335,47 +329,21 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
|||
const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;
|
||||
const replyToMessageId = parseTelegramReplyToMessageId(replyToId);
|
||||
const messageThreadId = parseTelegramThreadId(threadId);
|
||||
const telegramData = payload.channelData?.telegram as
|
||||
| { buttons?: TelegramInlineButtons; quoteText?: string }
|
||||
| undefined;
|
||||
const quoteText =
|
||||
typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined;
|
||||
const text = payload.text ?? "";
|
||||
const mediaUrls = payload.mediaUrls?.length
|
||||
? payload.mediaUrls
|
||||
: payload.mediaUrl
|
||||
? [payload.mediaUrl]
|
||||
: [];
|
||||
const baseOpts = {
|
||||
verbose: false,
|
||||
cfg,
|
||||
mediaLocalRoots,
|
||||
messageThreadId,
|
||||
replyToMessageId,
|
||||
quoteText,
|
||||
accountId: accountId ?? undefined,
|
||||
silent: silent ?? undefined,
|
||||
};
|
||||
|
||||
if (mediaUrls.length === 0) {
|
||||
const result = await send(to, text, {
|
||||
...baseOpts,
|
||||
buttons: telegramData?.buttons,
|
||||
});
|
||||
return { channel: "telegram", ...result };
|
||||
}
|
||||
|
||||
let finalResult: Awaited<ReturnType<typeof send>> | undefined;
|
||||
for (let i = 0; i < mediaUrls.length; i += 1) {
|
||||
const mediaUrl = mediaUrls[i];
|
||||
const isFirst = i === 0;
|
||||
finalResult = await send(to, isFirst ? text : "", {
|
||||
...baseOpts,
|
||||
mediaUrl,
|
||||
...(isFirst ? { buttons: telegramData?.buttons } : {}),
|
||||
});
|
||||
}
|
||||
return { channel: "telegram", ...(finalResult ?? { messageId: "unknown", chatId: to }) };
|
||||
const result = await sendTelegramPayloadMessages({
|
||||
send,
|
||||
to,
|
||||
payload,
|
||||
baseOpts: {
|
||||
verbose: false,
|
||||
cfg,
|
||||
mediaLocalRoots,
|
||||
messageThreadId,
|
||||
replyToMessageId,
|
||||
accountId: accountId ?? undefined,
|
||||
silent: silent ?? undefined,
|
||||
},
|
||||
});
|
||||
return { channel: "telegram", ...result };
|
||||
},
|
||||
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, silent }) => {
|
||||
const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { OpenClawConfig } from "openclaw/plugin-sdk/tlon";
|
||||
import {
|
||||
formatDocsLink,
|
||||
patchScopedAccountConfig,
|
||||
resolveAccountIdForConfigure,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
type ChannelOnboardingAdapter,
|
||||
|
|
@ -32,46 +33,30 @@ function applyAccountConfig(params: {
|
|||
};
|
||||
}): OpenClawConfig {
|
||||
const { cfg, accountId, input } = params;
|
||||
const useDefault = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const base = cfg.channels?.tlon ?? {};
|
||||
const nextValues = {
|
||||
enabled: true,
|
||||
...(input.name ? { name: input.name } : {}),
|
||||
...buildTlonAccountFields(input),
|
||||
};
|
||||
|
||||
if (useDefault) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
tlon: {
|
||||
...base,
|
||||
...nextValues,
|
||||
},
|
||||
},
|
||||
};
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return patchScopedAccountConfig({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
patch: nextValues,
|
||||
ensureChannelEnabled: false,
|
||||
ensureAccountEnabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
tlon: {
|
||||
...base,
|
||||
enabled: base.enabled ?? true,
|
||||
accounts: {
|
||||
...(base as { accounts?: Record<string, unknown> }).accounts,
|
||||
[accountId]: {
|
||||
...(base as { accounts?: Record<string, Record<string, unknown>> }).accounts?.[
|
||||
accountId
|
||||
],
|
||||
...nextValues,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
return patchScopedAccountConfig({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
patch: { enabled: cfg.channels?.tlon?.enabled ?? true },
|
||||
accountPatch: nextValues,
|
||||
ensureChannelEnabled: false,
|
||||
ensureAccountEnabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function noteTlonHelp(prompter: WizardPrompter): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import type { ReplyPayload } from "openclaw/plugin-sdk/zalo";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
installSendPayloadContractSuite,
|
||||
primeSendMock,
|
||||
} from "../../../src/test-utils/send-payload-contract.js";
|
||||
import { zaloPlugin } from "./channel.js";
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
|
|
@ -25,78 +29,16 @@ describe("zaloPlugin outbound sendPayload", () => {
|
|||
mockedSend.mockResolvedValue({ ok: true, messageId: "zl-1" });
|
||||
});
|
||||
|
||||
it("text-only delegates to sendText", async () => {
|
||||
mockedSend.mockResolvedValue({ ok: true, messageId: "zl-t1" });
|
||||
|
||||
const result = await zaloPlugin.outbound!.sendPayload!(baseCtx({ text: "hello" }));
|
||||
|
||||
expect(mockedSend).toHaveBeenCalledWith("123456789", "hello", expect.any(Object));
|
||||
expect(result).toMatchObject({ channel: "zalo", messageId: "zl-t1" });
|
||||
});
|
||||
|
||||
it("single media delegates to sendMedia", async () => {
|
||||
mockedSend.mockResolvedValue({ ok: true, messageId: "zl-m1" });
|
||||
|
||||
const result = await zaloPlugin.outbound!.sendPayload!(
|
||||
baseCtx({ text: "cap", mediaUrl: "https://example.com/a.jpg" }),
|
||||
);
|
||||
|
||||
expect(mockedSend).toHaveBeenCalledWith(
|
||||
"123456789",
|
||||
"cap",
|
||||
expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }),
|
||||
);
|
||||
expect(result).toMatchObject({ channel: "zalo" });
|
||||
});
|
||||
|
||||
it("multi-media iterates URLs with caption on first", async () => {
|
||||
mockedSend
|
||||
.mockResolvedValueOnce({ ok: true, messageId: "zl-1" })
|
||||
.mockResolvedValueOnce({ ok: true, messageId: "zl-2" });
|
||||
|
||||
const result = await zaloPlugin.outbound!.sendPayload!(
|
||||
baseCtx({
|
||||
text: "caption",
|
||||
mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockedSend).toHaveBeenCalledTimes(2);
|
||||
expect(mockedSend).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"123456789",
|
||||
"caption",
|
||||
expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }),
|
||||
);
|
||||
expect(mockedSend).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"123456789",
|
||||
"",
|
||||
expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }),
|
||||
);
|
||||
expect(result).toMatchObject({ channel: "zalo", messageId: "zl-2" });
|
||||
});
|
||||
|
||||
it("empty payload returns no-op", async () => {
|
||||
const result = await zaloPlugin.outbound!.sendPayload!(baseCtx({}));
|
||||
|
||||
expect(mockedSend).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ channel: "zalo", messageId: "" });
|
||||
});
|
||||
|
||||
it("chunking splits long text", async () => {
|
||||
mockedSend
|
||||
.mockResolvedValueOnce({ ok: true, messageId: "zl-c1" })
|
||||
.mockResolvedValueOnce({ ok: true, messageId: "zl-c2" });
|
||||
|
||||
const longText = "a".repeat(3000);
|
||||
const result = await zaloPlugin.outbound!.sendPayload!(baseCtx({ text: longText }));
|
||||
|
||||
// textChunkLimit is 2000 with chunkTextForOutbound, so it should split
|
||||
expect(mockedSend.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
for (const call of mockedSend.mock.calls) {
|
||||
expect((call[1] as string).length).toBeLessThanOrEqual(2000);
|
||||
}
|
||||
expect(result).toMatchObject({ channel: "zalo" });
|
||||
installSendPayloadContractSuite({
|
||||
channel: "zalo",
|
||||
chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 },
|
||||
createHarness: ({ payload, sendResults }) => {
|
||||
primeSendMock(mockedSend, { ok: true, messageId: "zl-1" }, sendResults);
|
||||
return {
|
||||
run: async () => await zaloPlugin.outbound!.sendPayload!(baseCtx(payload)),
|
||||
sendMock: mockedSend,
|
||||
to: "123456789",
|
||||
};
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import {
|
||||
buildAccountScopedDmSecurityPolicy,
|
||||
collectOpenProviderGroupPolicyWarnings,
|
||||
buildOpenGroupPolicyRestrictSendersWarning,
|
||||
buildOpenGroupPolicyWarning,
|
||||
collectOpenProviderGroupPolicyWarnings,
|
||||
createAccountStatusSink,
|
||||
mapAllowFromEntries,
|
||||
} from "openclaw/plugin-sdk/compat";
|
||||
import type {
|
||||
|
|
@ -357,6 +358,10 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|||
`[${account.accountId}] Zalo probe threw before provider start: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`,
|
||||
);
|
||||
}
|
||||
const statusSink = createAccountStatusSink({
|
||||
accountId: ctx.accountId,
|
||||
setStatus: ctx.setStatus,
|
||||
});
|
||||
ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel} mode=${mode}`);
|
||||
const { monitorZaloProvider } = await import("./monitor.js");
|
||||
return monitorZaloProvider({
|
||||
|
|
@ -370,7 +375,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|||
webhookSecret: normalizeSecretInputString(account.config.webhookSecret),
|
||||
webhookPath: account.config.webhookPath,
|
||||
fetcher,
|
||||
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
||||
statusSink,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import {
|
||||
AllowFromEntrySchema,
|
||||
AllowFromListSchema,
|
||||
buildCatchallMultiAccountChannelSchema,
|
||||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
} from "openclaw/plugin-sdk/compat";
|
||||
import { MarkdownConfigSchema } from "openclaw/plugin-sdk/zalo";
|
||||
import { z } from "zod";
|
||||
|
|
@ -15,10 +17,10 @@ const zaloAccountSchema = z.object({
|
|||
webhookUrl: z.string().optional(),
|
||||
webhookSecret: buildSecretInputSchema().optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
||||
allowFrom: z.array(AllowFromEntrySchema).optional(),
|
||||
groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(),
|
||||
groupAllowFrom: z.array(AllowFromEntrySchema).optional(),
|
||||
dmPolicy: DmPolicySchema.optional(),
|
||||
allowFrom: AllowFromListSchema,
|
||||
groupPolicy: GroupPolicySchema.optional(),
|
||||
groupAllowFrom: AllowFromListSchema,
|
||||
mediaMaxMb: z.number().optional(),
|
||||
proxy: z.string().optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
mergeAllowFromEntries,
|
||||
normalizeAccountId,
|
||||
promptSingleChannelSecretInput,
|
||||
runSingleChannelSecretStep,
|
||||
resolveAccountIdForConfigure,
|
||||
setTopLevelChannelDmPolicyWithAllowFrom,
|
||||
} from "openclaw/plugin-sdk/zalo";
|
||||
|
|
@ -255,80 +256,66 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||
const hasConfigToken = Boolean(
|
||||
hasConfiguredSecretInput(resolvedAccount.config.botToken) || resolvedAccount.config.tokenFile,
|
||||
);
|
||||
const tokenPromptState = buildSingleChannelSecretPromptState({
|
||||
accountConfigured,
|
||||
hasConfigToken,
|
||||
allowEnv,
|
||||
envValue: process.env.ZALO_BOT_TOKEN,
|
||||
});
|
||||
|
||||
let token: SecretInput | null = null;
|
||||
if (!accountConfigured) {
|
||||
await noteZaloTokenHelp(prompter);
|
||||
}
|
||||
const tokenResult = await promptSingleChannelSecretInput({
|
||||
const tokenStep = await runSingleChannelSecretStep({
|
||||
cfg: next,
|
||||
prompter,
|
||||
providerHint: "zalo",
|
||||
credentialLabel: "bot token",
|
||||
accountConfigured: tokenPromptState.accountConfigured,
|
||||
canUseEnv: tokenPromptState.canUseEnv,
|
||||
hasConfigToken: tokenPromptState.hasConfigToken,
|
||||
accountConfigured,
|
||||
hasConfigToken,
|
||||
allowEnv,
|
||||
envValue: process.env.ZALO_BOT_TOKEN,
|
||||
envPrompt: "ZALO_BOT_TOKEN detected. Use env var?",
|
||||
keepPrompt: "Zalo token already configured. Keep it?",
|
||||
inputPrompt: "Enter Zalo bot token",
|
||||
preferredEnvVar: "ZALO_BOT_TOKEN",
|
||||
});
|
||||
if (tokenResult.action === "set") {
|
||||
token = tokenResult.value;
|
||||
}
|
||||
if (tokenResult.action === "use-env" && zaloAccountId === DEFAULT_ACCOUNT_ID) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
zalo: {
|
||||
...next.channels?.zalo,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
if (token) {
|
||||
if (zaloAccountId === DEFAULT_ACCOUNT_ID) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
zalo: {
|
||||
...next.channels?.zalo,
|
||||
enabled: true,
|
||||
botToken: token,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
} else {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
zalo: {
|
||||
...next.channels?.zalo,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...next.channels?.zalo?.accounts,
|
||||
[zaloAccountId]: {
|
||||
...next.channels?.zalo?.accounts?.[zaloAccountId],
|
||||
onMissingConfigured: async () => await noteZaloTokenHelp(prompter),
|
||||
applyUseEnv: async (cfg) =>
|
||||
zaloAccountId === DEFAULT_ACCOUNT_ID
|
||||
? ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalo: {
|
||||
...cfg.channels?.zalo,
|
||||
enabled: true,
|
||||
botToken: token,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
}
|
||||
} as OpenClawConfig)
|
||||
: cfg,
|
||||
applySet: async (cfg, value) =>
|
||||
zaloAccountId === DEFAULT_ACCOUNT_ID
|
||||
? ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalo: {
|
||||
...cfg.channels?.zalo,
|
||||
enabled: true,
|
||||
botToken: value,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig)
|
||||
: ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalo: {
|
||||
...cfg.channels?.zalo,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...cfg.channels?.zalo?.accounts,
|
||||
[zaloAccountId]: {
|
||||
...cfg.channels?.zalo?.accounts?.[zaloAccountId],
|
||||
enabled: true,
|
||||
botToken: value,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig),
|
||||
});
|
||||
next = tokenStep.cfg;
|
||||
|
||||
const wantsWebhook = await prompter.confirm({
|
||||
message: "Use webhook mode for Zalo?",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveZaloToken } from "./token.js";
|
||||
import type { ZaloConfig } from "./types.js";
|
||||
|
|
@ -55,4 +58,20 @@ describe("resolveZaloToken", () => {
|
|||
expect(res.token).toBe("work-token");
|
||||
expect(res.source).toBe("config");
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")("rejects symlinked token files", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-zalo-token-"));
|
||||
const tokenFile = path.join(dir, "token.txt");
|
||||
const tokenLink = path.join(dir, "token-link.txt");
|
||||
fs.writeFileSync(tokenFile, "file-token\n", "utf8");
|
||||
fs.symlinkSync(tokenFile, tokenLink);
|
||||
|
||||
const cfg = {
|
||||
tokenFile: tokenLink,
|
||||
} as ZaloConfig;
|
||||
const res = resolveZaloToken(cfg);
|
||||
expect(res.token).toBe("");
|
||||
expect(res.source).toBe("none");
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/core";
|
||||
import type { BaseTokenResolution } from "openclaw/plugin-sdk/zalo";
|
||||
import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "./secret-input.js";
|
||||
import type { ZaloConfig } from "./types.js";
|
||||
|
|
@ -9,16 +9,7 @@ export type ZaloTokenResolution = BaseTokenResolution & {
|
|||
};
|
||||
|
||||
function readTokenFromFile(tokenFile: string | undefined): string {
|
||||
const trimmedPath = tokenFile?.trim();
|
||||
if (!trimmedPath) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return readFileSync(trimmedPath, "utf8").trim();
|
||||
} catch {
|
||||
// ignore read failures
|
||||
return "";
|
||||
}
|
||||
return tryReadSecretFileSync(tokenFile, "Zalo token file", { rejectSymlink: true }) ?? "";
|
||||
}
|
||||
|
||||
export function resolveZaloToken(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import type { ReplyPayload } from "openclaw/plugin-sdk/zalouser";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
installSendPayloadContractSuite,
|
||||
primeSendMock,
|
||||
} from "../../../src/test-utils/send-payload-contract.js";
|
||||
import { zalouserPlugin } from "./channel.js";
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
|
|
@ -40,15 +44,6 @@ describe("zalouserPlugin outbound sendPayload", () => {
|
|||
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-1" });
|
||||
});
|
||||
|
||||
it("text-only delegates to sendText", async () => {
|
||||
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-t1" });
|
||||
|
||||
const result = await zalouserPlugin.outbound!.sendPayload!(baseCtx({ text: "hello" }));
|
||||
|
||||
expect(mockedSend).toHaveBeenCalledWith("987654321", "hello", expect.any(Object));
|
||||
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-t1" });
|
||||
});
|
||||
|
||||
it("group target delegates with isGroup=true and stripped threadId", async () => {
|
||||
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-g1" });
|
||||
|
||||
|
|
@ -65,21 +60,6 @@ describe("zalouserPlugin outbound sendPayload", () => {
|
|||
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-g1" });
|
||||
});
|
||||
|
||||
it("single media delegates to sendMedia", async () => {
|
||||
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-m1" });
|
||||
|
||||
const result = await zalouserPlugin.outbound!.sendPayload!(
|
||||
baseCtx({ text: "cap", mediaUrl: "https://example.com/a.jpg" }),
|
||||
);
|
||||
|
||||
expect(mockedSend).toHaveBeenCalledWith(
|
||||
"987654321",
|
||||
"cap",
|
||||
expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }),
|
||||
);
|
||||
expect(result).toMatchObject({ channel: "zalouser" });
|
||||
});
|
||||
|
||||
it("treats bare numeric targets as direct chats for backward compatibility", async () => {
|
||||
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-d1" });
|
||||
|
||||
|
|
@ -112,55 +92,17 @@ describe("zalouserPlugin outbound sendPayload", () => {
|
|||
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-g-native" });
|
||||
});
|
||||
|
||||
it("multi-media iterates URLs with caption on first", async () => {
|
||||
mockedSend
|
||||
.mockResolvedValueOnce({ ok: true, messageId: "zlu-1" })
|
||||
.mockResolvedValueOnce({ ok: true, messageId: "zlu-2" });
|
||||
|
||||
const result = await zalouserPlugin.outbound!.sendPayload!(
|
||||
baseCtx({
|
||||
text: "caption",
|
||||
mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockedSend).toHaveBeenCalledTimes(2);
|
||||
expect(mockedSend).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"987654321",
|
||||
"caption",
|
||||
expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }),
|
||||
);
|
||||
expect(mockedSend).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"987654321",
|
||||
"",
|
||||
expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }),
|
||||
);
|
||||
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-2" });
|
||||
});
|
||||
|
||||
it("empty payload returns no-op", async () => {
|
||||
const result = await zalouserPlugin.outbound!.sendPayload!(baseCtx({}));
|
||||
|
||||
expect(mockedSend).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ channel: "zalouser", messageId: "" });
|
||||
});
|
||||
|
||||
it("chunking splits long text", async () => {
|
||||
mockedSend
|
||||
.mockResolvedValueOnce({ ok: true, messageId: "zlu-c1" })
|
||||
.mockResolvedValueOnce({ ok: true, messageId: "zlu-c2" });
|
||||
|
||||
const longText = "a".repeat(3000);
|
||||
const result = await zalouserPlugin.outbound!.sendPayload!(baseCtx({ text: longText }));
|
||||
|
||||
// textChunkLimit is 2000 with chunkTextForOutbound, so it should split
|
||||
expect(mockedSend.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
for (const call of mockedSend.mock.calls) {
|
||||
expect((call[1] as string).length).toBeLessThanOrEqual(2000);
|
||||
}
|
||||
expect(result).toMatchObject({ channel: "zalouser" });
|
||||
installSendPayloadContractSuite({
|
||||
channel: "zalouser",
|
||||
chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 },
|
||||
createHarness: ({ payload, sendResults }) => {
|
||||
primeSendMock(mockedSend, { ok: true, messageId: "zlu-1" }, sendResults);
|
||||
return {
|
||||
run: async () => await zalouserPlugin.outbound!.sendPayload!(baseCtx(payload)),
|
||||
sendMock: mockedSend,
|
||||
to: "987654321",
|
||||
};
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
buildAccountScopedDmSecurityPolicy,
|
||||
createAccountStatusSink,
|
||||
mapAllowFromEntries,
|
||||
} from "openclaw/plugin-sdk/compat";
|
||||
import type {
|
||||
|
|
@ -682,6 +683,10 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||
} catch {
|
||||
// ignore probe errors
|
||||
}
|
||||
const statusSink = createAccountStatusSink({
|
||||
accountId: ctx.accountId,
|
||||
setStatus: ctx.setStatus,
|
||||
});
|
||||
ctx.log?.info(`[${account.accountId}] starting zalouser provider${userLabel}`);
|
||||
const { monitorZalouserProvider } = await import("./monitor.js");
|
||||
return monitorZalouserProvider({
|
||||
|
|
@ -689,7 +694,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
||||
statusSink,
|
||||
});
|
||||
},
|
||||
loginWithQrStart: async (params) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import {
|
||||
AllowFromEntrySchema,
|
||||
AllowFromListSchema,
|
||||
buildCatchallMultiAccountChannelSchema,
|
||||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
} from "openclaw/plugin-sdk/compat";
|
||||
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/zalouser";
|
||||
import { z } from "zod";
|
||||
|
|
@ -17,11 +19,11 @@ const zalouserAccountSchema = z.object({
|
|||
enabled: z.boolean().optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
profile: z.string().optional(),
|
||||
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
||||
allowFrom: z.array(AllowFromEntrySchema).optional(),
|
||||
dmPolicy: DmPolicySchema.optional(),
|
||||
allowFrom: AllowFromListSchema,
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
groupAllowFrom: z.array(AllowFromEntrySchema).optional(),
|
||||
groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(),
|
||||
groupAllowFrom: AllowFromListSchema,
|
||||
groupPolicy: GroupPolicySchema.optional(),
|
||||
groups: z.object({}).catchall(groupConfigSchema).optional(),
|
||||
messagePrefix: z.string().optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
formatResolvedUnresolvedNote,
|
||||
mergeAllowFromEntries,
|
||||
normalizeAccountId,
|
||||
patchScopedAccountConfig,
|
||||
promptChannelAccessConfig,
|
||||
resolveAccountIdForConfigure,
|
||||
setTopLevelChannelDmPolicyWithAllowFrom,
|
||||
|
|
@ -36,37 +37,13 @@ function setZalouserAccountScopedConfig(
|
|||
defaultPatch: Record<string, unknown>,
|
||||
accountPatch: Record<string, unknown> = defaultPatch,
|
||||
): OpenClawConfig {
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalouser: {
|
||||
...cfg.channels?.zalouser,
|
||||
enabled: true,
|
||||
...defaultPatch,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalouser: {
|
||||
...cfg.channels?.zalouser,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...cfg.channels?.zalouser?.accounts,
|
||||
[accountId]: {
|
||||
...cfg.channels?.zalouser?.accounts?.[accountId],
|
||||
enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true,
|
||||
...accountPatch,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
return patchScopedAccountConfig({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
patch: defaultPatch,
|
||||
accountPatch,
|
||||
}) as OpenClawConfig;
|
||||
}
|
||||
|
||||
function setZalouserDmPolicy(
|
||||
|
|
|
|||
|
|
@ -295,9 +295,11 @@
|
|||
"protocol:gen": "node --import tsx scripts/protocol-gen.ts",
|
||||
"protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts",
|
||||
"release:check": "node --import tsx scripts/release-check.ts",
|
||||
"release:openclaw:npm:check": "node --import tsx scripts/openclaw-npm-release-check.ts",
|
||||
"start": "node scripts/run-node.mjs",
|
||||
"test": "node scripts/test-parallel.mjs",
|
||||
"test:all": "pnpm lint && pnpm build && pnpm test && pnpm test:e2e && pnpm test:live && pnpm test:docker:all",
|
||||
"test:auth:compat": "vitest run --config vitest.gateway.config.ts src/gateway/server.auth.compat-baseline.test.ts src/gateway/client.test.ts src/gateway/reconnect-gating.test.ts src/gateway/protocol/connect-error-details.test.ts",
|
||||
"test:channels": "vitest run --config vitest.channels.config.ts",
|
||||
"test:coverage": "vitest run --config vitest.unit.config.ts --coverage",
|
||||
"test:docker:all": "pnpm test:docker:live-models && pnpm test:docker:live-gateway && pnpm test:docker:onboard && pnpm test:docker:gateway-network && pnpm test:docker:qr && pnpm test:docker:doctor-switch && pnpm test:docker:plugins && pnpm test:docker:cleanup",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,251 @@
|
|||
#!/usr/bin/env -S node --import tsx
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
type PackageJson = {
|
||||
name?: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
license?: string;
|
||||
repository?: { url?: string } | string;
|
||||
bin?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type ParsedReleaseVersion = {
|
||||
version: string;
|
||||
channel: "stable" | "beta";
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
betaNumber?: number;
|
||||
date: Date;
|
||||
};
|
||||
|
||||
const STABLE_VERSION_REGEX = /^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<day>[1-9]\d?)$/;
|
||||
const BETA_VERSION_REGEX =
|
||||
/^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<day>[1-9]\d?)-beta\.(?<beta>[1-9]\d*)$/;
|
||||
const EXPECTED_REPOSITORY_URL = "https://github.com/openclaw/openclaw";
|
||||
const MAX_CALVER_DISTANCE_DAYS = 2;
|
||||
|
||||
function normalizeRepoUrl(value: unknown): string {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return value
|
||||
.trim()
|
||||
.replace(/^git\+/, "")
|
||||
.replace(/\.git$/i, "")
|
||||
.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function parseDateParts(
|
||||
version: string,
|
||||
groups: Record<string, string | undefined>,
|
||||
channel: "stable" | "beta",
|
||||
): ParsedReleaseVersion | null {
|
||||
const year = Number.parseInt(groups.year ?? "", 10);
|
||||
const month = Number.parseInt(groups.month ?? "", 10);
|
||||
const day = Number.parseInt(groups.day ?? "", 10);
|
||||
const betaNumber = channel === "beta" ? Number.parseInt(groups.beta ?? "", 10) : undefined;
|
||||
|
||||
if (
|
||||
!Number.isInteger(year) ||
|
||||
!Number.isInteger(month) ||
|
||||
!Number.isInteger(day) ||
|
||||
month < 1 ||
|
||||
month > 12 ||
|
||||
day < 1 ||
|
||||
day > 31
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (channel === "beta" && (!Number.isInteger(betaNumber) || (betaNumber ?? 0) < 1)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
if (
|
||||
date.getUTCFullYear() !== year ||
|
||||
date.getUTCMonth() !== month - 1 ||
|
||||
date.getUTCDate() !== day
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
version,
|
||||
channel,
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
betaNumber,
|
||||
date,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseReleaseVersion(version: string): ParsedReleaseVersion | null {
|
||||
const trimmed = version.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stableMatch = STABLE_VERSION_REGEX.exec(trimmed);
|
||||
if (stableMatch?.groups) {
|
||||
return parseDateParts(trimmed, stableMatch.groups, "stable");
|
||||
}
|
||||
|
||||
const betaMatch = BETA_VERSION_REGEX.exec(trimmed);
|
||||
if (betaMatch?.groups) {
|
||||
return parseDateParts(trimmed, betaMatch.groups, "beta");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function startOfUtcDay(date: Date): number {
|
||||
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
||||
}
|
||||
|
||||
export function utcCalendarDayDistance(left: Date, right: Date): number {
|
||||
return Math.round(Math.abs(startOfUtcDay(left) - startOfUtcDay(right)) / 86_400_000);
|
||||
}
|
||||
|
||||
export function collectReleasePackageMetadataErrors(pkg: PackageJson): string[] {
|
||||
const actualRepositoryUrl = normalizeRepoUrl(
|
||||
typeof pkg.repository === "string" ? pkg.repository : pkg.repository?.url,
|
||||
);
|
||||
const errors: string[] = [];
|
||||
|
||||
if (pkg.name !== "openclaw") {
|
||||
errors.push(`package.json name must be "openclaw"; found "${pkg.name ?? ""}".`);
|
||||
}
|
||||
if (!pkg.description?.trim()) {
|
||||
errors.push("package.json description must be non-empty.");
|
||||
}
|
||||
if (pkg.license !== "MIT") {
|
||||
errors.push(`package.json license must be "MIT"; found "${pkg.license ?? ""}".`);
|
||||
}
|
||||
if (actualRepositoryUrl !== EXPECTED_REPOSITORY_URL) {
|
||||
errors.push(
|
||||
`package.json repository.url must resolve to ${EXPECTED_REPOSITORY_URL}; found ${
|
||||
actualRepositoryUrl || "<missing>"
|
||||
}.`,
|
||||
);
|
||||
}
|
||||
if (pkg.bin?.openclaw !== "openclaw.mjs") {
|
||||
errors.push(
|
||||
`package.json bin.openclaw must be "openclaw.mjs"; found "${pkg.bin?.openclaw ?? ""}".`,
|
||||
);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function collectReleaseTagErrors(params: {
|
||||
packageVersion: string;
|
||||
releaseTag: string;
|
||||
releaseSha?: string;
|
||||
releaseMainRef?: string;
|
||||
now?: Date;
|
||||
}): string[] {
|
||||
const errors: string[] = [];
|
||||
const releaseTag = params.releaseTag.trim();
|
||||
const packageVersion = params.packageVersion.trim();
|
||||
const now = params.now ?? new Date();
|
||||
|
||||
const parsedVersion = parseReleaseVersion(packageVersion);
|
||||
if (parsedVersion === null) {
|
||||
errors.push(
|
||||
`package.json version must match YYYY.M.D or YYYY.M.D-beta.N; found "${packageVersion || "<missing>"}".`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!releaseTag.startsWith("v")) {
|
||||
errors.push(`Release tag must start with "v"; found "${releaseTag || "<missing>"}".`);
|
||||
}
|
||||
|
||||
const tagVersion = releaseTag.startsWith("v") ? releaseTag.slice(1) : releaseTag;
|
||||
const parsedTag = parseReleaseVersion(tagVersion);
|
||||
if (parsedTag === null) {
|
||||
errors.push(
|
||||
`Release tag must match vYYYY.M.D or vYYYY.M.D-beta.N; found "${releaseTag || "<missing>"}".`,
|
||||
);
|
||||
}
|
||||
|
||||
const expectedTag = packageVersion ? `v${packageVersion}` : "";
|
||||
if (releaseTag !== expectedTag) {
|
||||
errors.push(
|
||||
`Release tag ${releaseTag || "<missing>"} does not match package.json version ${
|
||||
packageVersion || "<missing>"
|
||||
}; expected ${expectedTag || "<missing>"}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (parsedVersion !== null) {
|
||||
const dayDistance = utcCalendarDayDistance(parsedVersion.date, now);
|
||||
if (dayDistance > MAX_CALVER_DISTANCE_DAYS) {
|
||||
const nowLabel = now.toISOString().slice(0, 10);
|
||||
const versionDate = parsedVersion.date.toISOString().slice(0, 10);
|
||||
errors.push(
|
||||
`Release version ${packageVersion} is ${dayDistance} days away from current UTC date ${nowLabel}; release CalVer date ${versionDate} must be within ${MAX_CALVER_DISTANCE_DAYS} days.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (params.releaseSha?.trim() && params.releaseMainRef?.trim()) {
|
||||
try {
|
||||
execFileSync(
|
||||
"git",
|
||||
["merge-base", "--is-ancestor", params.releaseSha, params.releaseMainRef],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
} catch {
|
||||
errors.push(
|
||||
`Tagged commit ${params.releaseSha} is not contained in ${params.releaseMainRef}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function loadPackageJson(): PackageJson {
|
||||
return JSON.parse(readFileSync("package.json", "utf8")) as PackageJson;
|
||||
}
|
||||
|
||||
function main(): number {
|
||||
const pkg = loadPackageJson();
|
||||
const metadataErrors = collectReleasePackageMetadataErrors(pkg);
|
||||
const tagErrors = collectReleaseTagErrors({
|
||||
packageVersion: pkg.version ?? "",
|
||||
releaseTag: process.env.RELEASE_TAG ?? "",
|
||||
releaseSha: process.env.RELEASE_SHA,
|
||||
releaseMainRef: process.env.RELEASE_MAIN_REF,
|
||||
});
|
||||
const errors = [...metadataErrors, ...tagErrors];
|
||||
|
||||
if (errors.length > 0) {
|
||||
for (const error of errors) {
|
||||
console.error(`openclaw-npm-release-check: ${error}`);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
const parsedVersion = parseReleaseVersion(pkg.version ?? "");
|
||||
const channel = parsedVersion?.channel ?? "unknown";
|
||||
const dayDistance =
|
||||
parsedVersion === null
|
||||
? "unknown"
|
||||
: String(utcCalendarDayDistance(parsedVersion.date, new Date()));
|
||||
console.log(
|
||||
`openclaw-npm-release-check: validated ${channel} release ${pkg.version} (${dayDistance} day UTC delta).`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
process.exit(main());
|
||||
}
|
||||
|
|
@ -4,9 +4,11 @@ import type { RequestPermissionRequest } from "@agentclientprotocol/sdk";
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
|
||||
import {
|
||||
buildAcpClientStripKeys,
|
||||
resolveAcpClientSpawnEnv,
|
||||
resolveAcpClientSpawnInvocation,
|
||||
resolvePermissionRequest,
|
||||
shouldStripProviderAuthEnvVarsForAcpServer,
|
||||
} from "./client.js";
|
||||
import { extractAttachmentsFromPrompt, extractTextFromPrompt } from "./event-mapper.js";
|
||||
|
||||
|
|
@ -110,6 +112,120 @@ describe("resolveAcpClientSpawnEnv", () => {
|
|||
expect(env.OPENCLAW_SHELL).toBe("acp-client");
|
||||
expect(env.OPENAI_API_KEY).toBeUndefined();
|
||||
});
|
||||
|
||||
it("strips provider auth env vars for the default OpenClaw bridge", () => {
|
||||
const stripKeys = new Set(["OPENAI_API_KEY", "GITHUB_TOKEN", "HF_TOKEN"]);
|
||||
const env = resolveAcpClientSpawnEnv(
|
||||
{
|
||||
OPENAI_API_KEY: "openai-secret", // pragma: allowlist secret
|
||||
GITHUB_TOKEN: "gh-secret", // pragma: allowlist secret
|
||||
HF_TOKEN: "hf-secret", // pragma: allowlist secret
|
||||
OPENCLAW_API_KEY: "keep-me",
|
||||
PATH: "/usr/bin",
|
||||
},
|
||||
{ stripKeys },
|
||||
);
|
||||
|
||||
expect(env.OPENAI_API_KEY).toBeUndefined();
|
||||
expect(env.GITHUB_TOKEN).toBeUndefined();
|
||||
expect(env.HF_TOKEN).toBeUndefined();
|
||||
expect(env.OPENCLAW_API_KEY).toBe("keep-me");
|
||||
expect(env.PATH).toBe("/usr/bin");
|
||||
expect(env.OPENCLAW_SHELL).toBe("acp-client");
|
||||
});
|
||||
|
||||
it("strips provider auth env vars case-insensitively", () => {
|
||||
const env = resolveAcpClientSpawnEnv(
|
||||
{
|
||||
OpenAI_Api_Key: "openai-secret", // pragma: allowlist secret
|
||||
Github_Token: "gh-secret", // pragma: allowlist secret
|
||||
OPENCLAW_API_KEY: "keep-me",
|
||||
},
|
||||
{ stripKeys: new Set(["OPENAI_API_KEY", "GITHUB_TOKEN"]) },
|
||||
);
|
||||
|
||||
expect(env.OpenAI_Api_Key).toBeUndefined();
|
||||
expect(env.Github_Token).toBeUndefined();
|
||||
expect(env.OPENCLAW_API_KEY).toBe("keep-me");
|
||||
expect(env.OPENCLAW_SHELL).toBe("acp-client");
|
||||
});
|
||||
|
||||
it("preserves provider auth env vars for explicit custom ACP servers", () => {
|
||||
const env = resolveAcpClientSpawnEnv({
|
||||
OPENAI_API_KEY: "openai-secret", // pragma: allowlist secret
|
||||
GITHUB_TOKEN: "gh-secret", // pragma: allowlist secret
|
||||
HF_TOKEN: "hf-secret", // pragma: allowlist secret
|
||||
OPENCLAW_API_KEY: "keep-me",
|
||||
});
|
||||
|
||||
expect(env.OPENAI_API_KEY).toBe("openai-secret");
|
||||
expect(env.GITHUB_TOKEN).toBe("gh-secret");
|
||||
expect(env.HF_TOKEN).toBe("hf-secret");
|
||||
expect(env.OPENCLAW_API_KEY).toBe("keep-me");
|
||||
expect(env.OPENCLAW_SHELL).toBe("acp-client");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldStripProviderAuthEnvVarsForAcpServer", () => {
|
||||
it("strips provider auth env vars for the default bridge", () => {
|
||||
expect(shouldStripProviderAuthEnvVarsForAcpServer()).toBe(true);
|
||||
expect(
|
||||
shouldStripProviderAuthEnvVarsForAcpServer({
|
||||
serverCommand: "openclaw",
|
||||
serverArgs: ["acp"],
|
||||
defaultServerCommand: "openclaw",
|
||||
defaultServerArgs: ["acp"],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves provider auth env vars for explicit custom ACP servers", () => {
|
||||
expect(
|
||||
shouldStripProviderAuthEnvVarsForAcpServer({
|
||||
serverCommand: "custom-acp-server",
|
||||
serverArgs: ["serve"],
|
||||
defaultServerCommand: "openclaw",
|
||||
defaultServerArgs: ["acp"],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves provider auth env vars when an explicit override uses the default executable with different args", () => {
|
||||
expect(
|
||||
shouldStripProviderAuthEnvVarsForAcpServer({
|
||||
serverCommand: process.execPath,
|
||||
serverArgs: ["custom-entry.js"],
|
||||
defaultServerCommand: process.execPath,
|
||||
defaultServerArgs: ["dist/entry.js", "acp"],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAcpClientStripKeys", () => {
|
||||
it("always includes active skill env keys", () => {
|
||||
const stripKeys = buildAcpClientStripKeys({
|
||||
stripProviderAuthEnvVars: false,
|
||||
activeSkillEnvKeys: ["SKILL_SECRET", "OPENAI_API_KEY"],
|
||||
});
|
||||
|
||||
expect(stripKeys.has("SKILL_SECRET")).toBe(true);
|
||||
expect(stripKeys.has("OPENAI_API_KEY")).toBe(true);
|
||||
expect(stripKeys.has("GITHUB_TOKEN")).toBe(false);
|
||||
});
|
||||
|
||||
it("adds provider auth env vars for the default bridge", () => {
|
||||
const stripKeys = buildAcpClientStripKeys({
|
||||
stripProviderAuthEnvVars: true,
|
||||
activeSkillEnvKeys: ["SKILL_SECRET"],
|
||||
});
|
||||
|
||||
expect(stripKeys.has("SKILL_SECRET")).toBe(true);
|
||||
expect(stripKeys.has("OPENAI_API_KEY")).toBe(true);
|
||||
expect(stripKeys.has("GITHUB_TOKEN")).toBe(true);
|
||||
expect(stripKeys.has("HF_TOKEN")).toBe(true);
|
||||
expect(stripKeys.has("OPENCLAW_API_KEY")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAcpClientSpawnInvocation", () => {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ import {
|
|||
materializeWindowsSpawnProgram,
|
||||
resolveWindowsSpawnProgram,
|
||||
} from "../plugin-sdk/windows-spawn.js";
|
||||
import {
|
||||
listKnownProviderAuthEnvVarNames,
|
||||
omitEnvKeysCaseInsensitive,
|
||||
} from "../secrets/provider-env-vars.js";
|
||||
import { DANGEROUS_ACP_TOOLS } from "../security/dangerous-tools.js";
|
||||
|
||||
const SAFE_AUTO_APPROVE_TOOL_IDS = new Set(["read", "search", "web_search", "memory_search"]);
|
||||
|
|
@ -346,20 +350,56 @@ function buildServerArgs(opts: AcpClientOptions): string[] {
|
|||
return args;
|
||||
}
|
||||
|
||||
type AcpClientSpawnEnvOptions = {
|
||||
stripKeys?: Iterable<string>;
|
||||
};
|
||||
|
||||
export function resolveAcpClientSpawnEnv(
|
||||
baseEnv: NodeJS.ProcessEnv = process.env,
|
||||
options?: { stripKeys?: ReadonlySet<string> },
|
||||
options: AcpClientSpawnEnvOptions = {},
|
||||
): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = { ...baseEnv };
|
||||
if (options?.stripKeys) {
|
||||
for (const key of options.stripKeys) {
|
||||
delete env[key];
|
||||
}
|
||||
}
|
||||
const env = omitEnvKeysCaseInsensitive(baseEnv, options.stripKeys ?? []);
|
||||
env.OPENCLAW_SHELL = "acp-client";
|
||||
return env;
|
||||
}
|
||||
|
||||
export function shouldStripProviderAuthEnvVarsForAcpServer(
|
||||
params: {
|
||||
serverCommand?: string;
|
||||
serverArgs?: string[];
|
||||
defaultServerCommand?: string;
|
||||
defaultServerArgs?: string[];
|
||||
} = {},
|
||||
): boolean {
|
||||
const serverCommand = params.serverCommand?.trim();
|
||||
if (!serverCommand) {
|
||||
return true;
|
||||
}
|
||||
const defaultServerCommand = params.defaultServerCommand?.trim();
|
||||
if (!defaultServerCommand || serverCommand !== defaultServerCommand) {
|
||||
return false;
|
||||
}
|
||||
const serverArgs = params.serverArgs ?? [];
|
||||
const defaultServerArgs = params.defaultServerArgs ?? [];
|
||||
return (
|
||||
serverArgs.length === defaultServerArgs.length &&
|
||||
serverArgs.every((arg, index) => arg === defaultServerArgs[index])
|
||||
);
|
||||
}
|
||||
|
||||
export function buildAcpClientStripKeys(params: {
|
||||
stripProviderAuthEnvVars?: boolean;
|
||||
activeSkillEnvKeys?: Iterable<string>;
|
||||
}): Set<string> {
|
||||
const stripKeys = new Set<string>(params.activeSkillEnvKeys ?? []);
|
||||
if (params.stripProviderAuthEnvVars) {
|
||||
for (const key of listKnownProviderAuthEnvVarNames()) {
|
||||
stripKeys.add(key);
|
||||
}
|
||||
}
|
||||
return stripKeys;
|
||||
}
|
||||
|
||||
type AcpSpawnRuntime = {
|
||||
platform: NodeJS.Platform;
|
||||
env: NodeJS.ProcessEnv;
|
||||
|
|
@ -456,12 +496,22 @@ export async function createAcpClient(opts: AcpClientOptions = {}): Promise<AcpC
|
|||
const serverArgs = buildServerArgs(opts);
|
||||
|
||||
const entryPath = resolveSelfEntryPath();
|
||||
const serverCommand = opts.serverCommand ?? (entryPath ? process.execPath : "openclaw");
|
||||
const effectiveArgs = opts.serverCommand || !entryPath ? serverArgs : [entryPath, ...serverArgs];
|
||||
const defaultServerCommand = entryPath ? process.execPath : "openclaw";
|
||||
const defaultServerArgs = entryPath ? [entryPath, ...serverArgs] : serverArgs;
|
||||
const serverCommand = opts.serverCommand ?? defaultServerCommand;
|
||||
const effectiveArgs = opts.serverCommand || !entryPath ? serverArgs : defaultServerArgs;
|
||||
const { getActiveSkillEnvKeys } = await import("../agents/skills/env-overrides.runtime.js");
|
||||
const spawnEnv = resolveAcpClientSpawnEnv(process.env, {
|
||||
stripKeys: getActiveSkillEnvKeys(),
|
||||
const stripProviderAuthEnvVars = shouldStripProviderAuthEnvVarsForAcpServer({
|
||||
serverCommand,
|
||||
serverArgs: effectiveArgs,
|
||||
defaultServerCommand,
|
||||
defaultServerArgs,
|
||||
});
|
||||
const stripKeys = buildAcpClientStripKeys({
|
||||
stripProviderAuthEnvVars,
|
||||
activeSkillEnvKeys: getActiveSkillEnvKeys(),
|
||||
});
|
||||
const spawnEnv = resolveAcpClientSpawnEnv(process.env, { stripKeys });
|
||||
const spawnInvocation = resolveAcpClientSpawnInvocation(
|
||||
{ serverCommand, serverArgs: effectiveArgs },
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,54 +1,12 @@
|
|||
import { mkdir, symlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MAX_SECRET_FILE_BYTES, readSecretFromFile } from "./secret-file.js";
|
||||
|
||||
const tempDirs = createTrackedTempDirs();
|
||||
const createTempDir = () => tempDirs.make("openclaw-secret-file-test-");
|
||||
|
||||
afterEach(async () => {
|
||||
await tempDirs.cleanup();
|
||||
});
|
||||
|
||||
describe("readSecretFromFile", () => {
|
||||
it("reads and trims a regular secret file", async () => {
|
||||
const dir = await createTempDir();
|
||||
const file = path.join(dir, "secret.txt");
|
||||
await writeFile(file, " top-secret \n", "utf8");
|
||||
|
||||
expect(readSecretFromFile(file, "Gateway password")).toBe("top-secret");
|
||||
it("keeps the shared secret-file limit", () => {
|
||||
expect(MAX_SECRET_FILE_BYTES).toBe(16 * 1024);
|
||||
});
|
||||
|
||||
it("rejects files larger than the secret-file limit", async () => {
|
||||
const dir = await createTempDir();
|
||||
const file = path.join(dir, "secret.txt");
|
||||
await writeFile(file, "x".repeat(MAX_SECRET_FILE_BYTES + 1), "utf8");
|
||||
|
||||
expect(() => readSecretFromFile(file, "Gateway password")).toThrow(
|
||||
`Gateway password file at ${file} exceeds ${MAX_SECRET_FILE_BYTES} bytes.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-regular files", async () => {
|
||||
const dir = await createTempDir();
|
||||
const nestedDir = path.join(dir, "secret-dir");
|
||||
await mkdir(nestedDir);
|
||||
|
||||
expect(() => readSecretFromFile(nestedDir, "Gateway password")).toThrow(
|
||||
`Gateway password file at ${nestedDir} must be a regular file.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects symlinks", async () => {
|
||||
const dir = await createTempDir();
|
||||
const target = path.join(dir, "target.txt");
|
||||
const link = path.join(dir, "secret-link.txt");
|
||||
await writeFile(target, "top-secret\n", "utf8");
|
||||
await symlink(target, link);
|
||||
|
||||
expect(() => readSecretFromFile(link, "Gateway password")).toThrow(
|
||||
`Gateway password file at ${link} must not be a symlink.`,
|
||||
);
|
||||
it("exposes the hardened secret reader", () => {
|
||||
expect(typeof readSecretFromFile).toBe("function");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,43 +1,10 @@
|
|||
import fs from "node:fs";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { DEFAULT_SECRET_FILE_MAX_BYTES, readSecretFileSync } from "../infra/secret-file.js";
|
||||
|
||||
export const MAX_SECRET_FILE_BYTES = 16 * 1024;
|
||||
export const MAX_SECRET_FILE_BYTES = DEFAULT_SECRET_FILE_MAX_BYTES;
|
||||
|
||||
export function readSecretFromFile(filePath: string, label: string): string {
|
||||
const resolvedPath = resolveUserPath(filePath.trim());
|
||||
if (!resolvedPath) {
|
||||
throw new Error(`${label} file path is empty.`);
|
||||
}
|
||||
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(resolvedPath);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to inspect ${label} file at ${resolvedPath}: ${String(err)}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new Error(`${label} file at ${resolvedPath} must not be a symlink.`);
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`${label} file at ${resolvedPath} must be a regular file.`);
|
||||
}
|
||||
if (stat.size > MAX_SECRET_FILE_BYTES) {
|
||||
throw new Error(`${label} file at ${resolvedPath} exceeds ${MAX_SECRET_FILE_BYTES} bytes.`);
|
||||
}
|
||||
|
||||
let raw = "";
|
||||
try {
|
||||
raw = fs.readFileSync(resolvedPath, "utf8");
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to read ${label} file at ${resolvedPath}: ${String(err)}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
const secret = raw.trim();
|
||||
if (!secret) {
|
||||
throw new Error(`${label} file at ${resolvedPath} is empty.`);
|
||||
}
|
||||
return secret;
|
||||
return readSecretFileSync(filePath, label, {
|
||||
maxBytes: MAX_SECRET_FILE_BYTES,
|
||||
rejectSymlink: true,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,274 @@
|
|||
import type { CancelNotification, PromptRequest, PromptResponse } from "@agentclientprotocol/sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayClient } from "../gateway/client.js";
|
||||
import type { EventFrame } from "../gateway/protocol/index.js";
|
||||
import { createInMemorySessionStore } from "./session.js";
|
||||
import { AcpGatewayAgent } from "./translator.js";
|
||||
import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js";
|
||||
|
||||
type Harness = {
|
||||
agent: AcpGatewayAgent;
|
||||
requestSpy: ReturnType<typeof vi.fn>;
|
||||
sessionUpdateSpy: ReturnType<typeof vi.fn>;
|
||||
sessionStore: ReturnType<typeof createInMemorySessionStore>;
|
||||
sentRunIds: string[];
|
||||
};
|
||||
|
||||
function createPromptRequest(sessionId: string): PromptRequest {
|
||||
return {
|
||||
sessionId,
|
||||
prompt: [{ type: "text", text: "hello" }],
|
||||
_meta: {},
|
||||
} as unknown as PromptRequest;
|
||||
}
|
||||
|
||||
function createChatEvent(payload: Record<string, unknown>): EventFrame {
|
||||
return {
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload,
|
||||
} as EventFrame;
|
||||
}
|
||||
|
||||
function createToolEvent(payload: Record<string, unknown>): EventFrame {
|
||||
return {
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload,
|
||||
} as EventFrame;
|
||||
}
|
||||
|
||||
function createHarness(sessions: Array<{ sessionId: string; sessionKey: string }>): Harness {
|
||||
const sentRunIds: string[] = [];
|
||||
const requestSpy = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
if (method === "chat.send") {
|
||||
const runId = params?.idempotencyKey;
|
||||
if (typeof runId === "string") {
|
||||
sentRunIds.push(runId);
|
||||
}
|
||||
return new Promise<never>(() => {});
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const connection = createAcpConnection();
|
||||
const sessionStore = createInMemorySessionStore();
|
||||
for (const session of sessions) {
|
||||
sessionStore.createSession({
|
||||
sessionId: session.sessionId,
|
||||
sessionKey: session.sessionKey,
|
||||
cwd: "/tmp",
|
||||
});
|
||||
}
|
||||
|
||||
const agent = new AcpGatewayAgent(
|
||||
connection,
|
||||
createAcpGateway(requestSpy as unknown as GatewayClient["request"]),
|
||||
{ sessionStore },
|
||||
);
|
||||
|
||||
return {
|
||||
agent,
|
||||
requestSpy,
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
sessionUpdateSpy: connection.sessionUpdate as unknown as ReturnType<typeof vi.fn>,
|
||||
sessionStore,
|
||||
sentRunIds,
|
||||
};
|
||||
}
|
||||
|
||||
async function startPendingPrompt(
|
||||
harness: Harness,
|
||||
sessionId: string,
|
||||
): Promise<{ promptPromise: Promise<PromptResponse>; runId: string }> {
|
||||
const before = harness.sentRunIds.length;
|
||||
const promptPromise = harness.agent.prompt(createPromptRequest(sessionId));
|
||||
await vi.waitFor(() => {
|
||||
expect(harness.sentRunIds.length).toBe(before + 1);
|
||||
});
|
||||
return {
|
||||
promptPromise,
|
||||
runId: harness.sentRunIds[before],
|
||||
};
|
||||
}
|
||||
|
||||
describe("acp translator cancel and run scoping", () => {
|
||||
it("cancel passes active runId to chat.abort", async () => {
|
||||
const sessionKey = "agent:main:shared";
|
||||
const harness = createHarness([{ sessionId: "session-1", sessionKey }]);
|
||||
const pending = await startPendingPrompt(harness, "session-1");
|
||||
|
||||
await harness.agent.cancel({ sessionId: "session-1" } as CancelNotification);
|
||||
|
||||
expect(harness.requestSpy).toHaveBeenCalledWith("chat.abort", {
|
||||
sessionKey,
|
||||
runId: pending.runId,
|
||||
});
|
||||
await expect(pending.promptPromise).resolves.toEqual({ stopReason: "cancelled" });
|
||||
});
|
||||
|
||||
it("cancel uses pending runId when there is no active run", async () => {
|
||||
const sessionKey = "agent:main:shared";
|
||||
const harness = createHarness([{ sessionId: "session-1", sessionKey }]);
|
||||
const pending = await startPendingPrompt(harness, "session-1");
|
||||
harness.sessionStore.clearActiveRun("session-1");
|
||||
|
||||
await harness.agent.cancel({ sessionId: "session-1" } as CancelNotification);
|
||||
|
||||
expect(harness.requestSpy).toHaveBeenCalledWith("chat.abort", {
|
||||
sessionKey,
|
||||
runId: pending.runId,
|
||||
});
|
||||
await expect(pending.promptPromise).resolves.toEqual({ stopReason: "cancelled" });
|
||||
});
|
||||
|
||||
it("cancel skips chat.abort when there is no active run and no pending prompt", async () => {
|
||||
const sessionKey = "agent:main:shared";
|
||||
const harness = createHarness([{ sessionId: "session-1", sessionKey }]);
|
||||
|
||||
await harness.agent.cancel({ sessionId: "session-1" } as CancelNotification);
|
||||
|
||||
const abortCalls = harness.requestSpy.mock.calls.filter(([method]) => method === "chat.abort");
|
||||
expect(abortCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("cancel from a session without active run does not abort another session sharing the same key", async () => {
|
||||
const sessionKey = "agent:main:shared";
|
||||
const harness = createHarness([
|
||||
{ sessionId: "session-1", sessionKey },
|
||||
{ sessionId: "session-2", sessionKey },
|
||||
]);
|
||||
const pending2 = await startPendingPrompt(harness, "session-2");
|
||||
|
||||
await harness.agent.cancel({ sessionId: "session-1" } as CancelNotification);
|
||||
|
||||
const abortCalls = harness.requestSpy.mock.calls.filter(([method]) => method === "chat.abort");
|
||||
expect(abortCalls).toHaveLength(0);
|
||||
expect(harness.sessionStore.getSession("session-2")?.activeRunId).toBe(pending2.runId);
|
||||
|
||||
await harness.agent.handleGatewayEvent(
|
||||
createChatEvent({
|
||||
runId: pending2.runId,
|
||||
sessionKey,
|
||||
seq: 1,
|
||||
state: "final",
|
||||
}),
|
||||
);
|
||||
await expect(pending2.promptPromise).resolves.toEqual({ stopReason: "end_turn" });
|
||||
});
|
||||
|
||||
it("drops chat events when runId does not match the active prompt", async () => {
|
||||
const sessionKey = "agent:main:shared";
|
||||
const harness = createHarness([{ sessionId: "session-1", sessionKey }]);
|
||||
const pending = await startPendingPrompt(harness, "session-1");
|
||||
|
||||
await harness.agent.handleGatewayEvent(
|
||||
createChatEvent({
|
||||
runId: "run-other",
|
||||
sessionKey,
|
||||
seq: 1,
|
||||
state: "final",
|
||||
}),
|
||||
);
|
||||
expect(harness.sessionStore.getSession("session-1")?.activeRunId).toBe(pending.runId);
|
||||
|
||||
await harness.agent.handleGatewayEvent(
|
||||
createChatEvent({
|
||||
runId: pending.runId,
|
||||
sessionKey,
|
||||
seq: 2,
|
||||
state: "final",
|
||||
}),
|
||||
);
|
||||
await expect(pending.promptPromise).resolves.toEqual({ stopReason: "end_turn" });
|
||||
});
|
||||
|
||||
it("drops tool events when runId does not match the active prompt", async () => {
|
||||
const sessionKey = "agent:main:shared";
|
||||
const harness = createHarness([{ sessionId: "session-1", sessionKey }]);
|
||||
const pending = await startPendingPrompt(harness, "session-1");
|
||||
harness.sessionUpdateSpy.mockClear();
|
||||
|
||||
await harness.agent.handleGatewayEvent(
|
||||
createToolEvent({
|
||||
runId: "run-other",
|
||||
sessionKey,
|
||||
stream: "tool",
|
||||
data: {
|
||||
phase: "start",
|
||||
name: "read_file",
|
||||
toolCallId: "tool-1",
|
||||
args: { path: "README.md" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(harness.sessionUpdateSpy).not.toHaveBeenCalled();
|
||||
|
||||
await harness.agent.handleGatewayEvent(
|
||||
createChatEvent({
|
||||
runId: pending.runId,
|
||||
sessionKey,
|
||||
seq: 1,
|
||||
state: "final",
|
||||
}),
|
||||
);
|
||||
await expect(pending.promptPromise).resolves.toEqual({ stopReason: "end_turn" });
|
||||
});
|
||||
|
||||
it("routes events to the pending prompt that matches runId when session keys are shared", async () => {
|
||||
const sessionKey = "agent:main:shared";
|
||||
const harness = createHarness([
|
||||
{ sessionId: "session-1", sessionKey },
|
||||
{ sessionId: "session-2", sessionKey },
|
||||
]);
|
||||
const pending1 = await startPendingPrompt(harness, "session-1");
|
||||
const pending2 = await startPendingPrompt(harness, "session-2");
|
||||
harness.sessionUpdateSpy.mockClear();
|
||||
|
||||
await harness.agent.handleGatewayEvent(
|
||||
createToolEvent({
|
||||
runId: pending2.runId,
|
||||
sessionKey,
|
||||
stream: "tool",
|
||||
data: {
|
||||
phase: "start",
|
||||
name: "read_file",
|
||||
toolCallId: "tool-2",
|
||||
args: { path: "notes.txt" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(harness.sessionUpdateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: "session-2",
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "tool-2",
|
||||
status: "in_progress",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(harness.sessionUpdateSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
await harness.agent.handleGatewayEvent(
|
||||
createChatEvent({
|
||||
runId: pending2.runId,
|
||||
sessionKey,
|
||||
seq: 1,
|
||||
state: "final",
|
||||
}),
|
||||
);
|
||||
await expect(pending2.promptPromise).resolves.toEqual({ stopReason: "end_turn" });
|
||||
expect(harness.sessionStore.getSession("session-1")?.activeRunId).toBe(pending1.runId);
|
||||
|
||||
await harness.agent.handleGatewayEvent(
|
||||
createChatEvent({
|
||||
runId: pending1.runId,
|
||||
sessionKey,
|
||||
seq: 2,
|
||||
state: "final",
|
||||
}),
|
||||
);
|
||||
await expect(pending1.promptPromise).resolves.toEqual({ stopReason: "end_turn" });
|
||||
});
|
||||
});
|
||||
|
|
@ -633,14 +633,25 @@ export class AcpGatewayAgent implements Agent {
|
|||
if (!session) {
|
||||
return;
|
||||
}
|
||||
// Capture runId before cancelActiveRun clears session.activeRunId.
|
||||
const activeRunId = session.activeRunId;
|
||||
|
||||
this.sessionStore.cancelActiveRun(params.sessionId);
|
||||
const pending = this.pendingPrompts.get(params.sessionId);
|
||||
const scopedRunId = activeRunId ?? pending?.idempotencyKey;
|
||||
if (!scopedRunId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.gateway.request("chat.abort", { sessionKey: session.sessionKey });
|
||||
await this.gateway.request("chat.abort", {
|
||||
sessionKey: session.sessionKey,
|
||||
runId: scopedRunId,
|
||||
});
|
||||
} catch (err) {
|
||||
this.log(`cancel error: ${String(err)}`);
|
||||
}
|
||||
|
||||
const pending = this.pendingPrompts.get(params.sessionId);
|
||||
if (pending) {
|
||||
this.pendingPrompts.delete(params.sessionId);
|
||||
pending.resolve({ stopReason: "cancelled" });
|
||||
|
|
@ -672,6 +683,7 @@ export class AcpGatewayAgent implements Agent {
|
|||
return;
|
||||
}
|
||||
const stream = payload.stream as string | undefined;
|
||||
const runId = payload.runId as string | undefined;
|
||||
const data = payload.data as Record<string, unknown> | undefined;
|
||||
const sessionKey = payload.sessionKey as string | undefined;
|
||||
if (!stream || !data || !sessionKey) {
|
||||
|
|
@ -688,7 +700,7 @@ export class AcpGatewayAgent implements Agent {
|
|||
return;
|
||||
}
|
||||
|
||||
const pending = this.findPendingBySessionKey(sessionKey);
|
||||
const pending = this.findPendingBySessionKey(sessionKey, runId);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -774,13 +786,10 @@ export class AcpGatewayAgent implements Agent {
|
|||
return;
|
||||
}
|
||||
|
||||
const pending = this.findPendingBySessionKey(sessionKey);
|
||||
const pending = this.findPendingBySessionKey(sessionKey, runId);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
if (runId && pending.idempotencyKey !== runId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === "delta" && messageData) {
|
||||
await this.handleDeltaEvent(pending.sessionId, messageData);
|
||||
|
|
@ -853,11 +862,15 @@ export class AcpGatewayAgent implements Agent {
|
|||
pending.resolve({ stopReason });
|
||||
}
|
||||
|
||||
private findPendingBySessionKey(sessionKey: string): PendingPrompt | undefined {
|
||||
private findPendingBySessionKey(sessionKey: string, runId?: string): PendingPrompt | undefined {
|
||||
for (const pending of this.pendingPrompts.values()) {
|
||||
if (pending.sessionKey === sessionKey) {
|
||||
return pending;
|
||||
if (pending.sessionKey !== sessionKey) {
|
||||
continue;
|
||||
}
|
||||
if (runId && pending.idempotencyKey !== runId) {
|
||||
continue;
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -180,7 +180,9 @@ export function startAcpSpawnParentStreamRelay(params: {
|
|||
};
|
||||
const wake = () => {
|
||||
requestHeartbeatNow(
|
||||
scopedHeartbeatWakeOptions(parentSessionKey, { reason: "acp:spawn:stream" }),
|
||||
scopedHeartbeatWakeOptions(parentSessionKey, {
|
||||
reason: "acp:spawn:stream",
|
||||
}),
|
||||
);
|
||||
};
|
||||
const emit = (text: string, contextKey: string) => {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ const hoisted = vi.hoisted(() => {
|
|||
const loadSessionStoreMock = vi.fn();
|
||||
const resolveStorePathMock = vi.fn();
|
||||
const resolveSessionTranscriptFileMock = vi.fn();
|
||||
const areHeartbeatsEnabledMock = vi.fn();
|
||||
const state = {
|
||||
cfg: createDefaultSpawnConfig(),
|
||||
};
|
||||
|
|
@ -55,6 +56,7 @@ const hoisted = vi.hoisted(() => {
|
|||
loadSessionStoreMock,
|
||||
resolveStorePathMock,
|
||||
resolveSessionTranscriptFileMock,
|
||||
areHeartbeatsEnabledMock,
|
||||
state,
|
||||
};
|
||||
});
|
||||
|
|
@ -128,6 +130,14 @@ vi.mock("../infra/outbound/session-binding-service.js", async (importOriginal) =
|
|||
};
|
||||
});
|
||||
|
||||
vi.mock("../infra/heartbeat-wake.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../infra/heartbeat-wake.js")>();
|
||||
return {
|
||||
...actual,
|
||||
areHeartbeatsEnabled: () => hoisted.areHeartbeatsEnabledMock(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./acp-spawn-parent-stream.js", () => ({
|
||||
startAcpSpawnParentStreamRelay: (...args: unknown[]) =>
|
||||
hoisted.startAcpSpawnParentStreamRelayMock(...args),
|
||||
|
|
@ -192,6 +202,7 @@ function expectResolvedIntroTextInBindMetadata(): void {
|
|||
describe("spawnAcpDirect", () => {
|
||||
beforeEach(() => {
|
||||
hoisted.state.cfg = createDefaultSpawnConfig();
|
||||
hoisted.areHeartbeatsEnabledMock.mockReset().mockReturnValue(true);
|
||||
|
||||
hoisted.callGatewayMock.mockReset().mockImplementation(async (argsUnknown: unknown) => {
|
||||
const args = argsUnknown as { method?: string };
|
||||
|
|
@ -393,6 +404,8 @@ describe("spawnAcpDirect", () => {
|
|||
|
||||
expect(result.status).toBe("accepted");
|
||||
expect(result.mode).toBe("run");
|
||||
expect(result.streamLogPath).toBeUndefined();
|
||||
expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled();
|
||||
expect(hoisted.resolveSessionTranscriptFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: "sess-123",
|
||||
|
|
@ -633,6 +646,290 @@ describe("spawnAcpDirect", () => {
|
|||
expect(secondHandle.notifyStarted).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("implicitly streams mode=run ACP spawns for subagent requester sessions", async () => {
|
||||
hoisted.state.cfg = {
|
||||
...hoisted.state.cfg,
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: {
|
||||
every: "30m",
|
||||
target: "last",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const firstHandle = createRelayHandle();
|
||||
const secondHandle = createRelayHandle();
|
||||
hoisted.startAcpSpawnParentStreamRelayMock
|
||||
.mockReset()
|
||||
.mockReturnValueOnce(firstHandle)
|
||||
.mockReturnValueOnce(secondHandle);
|
||||
hoisted.loadSessionStoreMock.mockReset().mockImplementation(() => {
|
||||
const store: Record<
|
||||
string,
|
||||
{ sessionId: string; updatedAt: number; deliveryContext?: unknown }
|
||||
> = {
|
||||
"agent:main:subagent:parent": {
|
||||
sessionId: "parent-sess-1",
|
||||
updatedAt: Date.now(),
|
||||
deliveryContext: {
|
||||
channel: "discord",
|
||||
to: "channel:parent-channel",
|
||||
accountId: "default",
|
||||
},
|
||||
},
|
||||
};
|
||||
return new Proxy(store, {
|
||||
get(target, prop) {
|
||||
if (typeof prop === "string" && prop.startsWith("agent:codex:acp:")) {
|
||||
return { sessionId: "sess-123", updatedAt: Date.now() };
|
||||
}
|
||||
return target[prop as keyof typeof target];
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const result = await spawnAcpDirect(
|
||||
{
|
||||
task: "Investigate flaky tests",
|
||||
agentId: "codex",
|
||||
},
|
||||
{
|
||||
agentSessionKey: "agent:main:subagent:parent",
|
||||
agentChannel: "discord",
|
||||
agentAccountId: "default",
|
||||
agentTo: "channel:parent-channel",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe("accepted");
|
||||
expect(result.mode).toBe("run");
|
||||
expect(result.streamLogPath).toBe("/tmp/sess-main.acp-stream.jsonl");
|
||||
const agentCall = hoisted.callGatewayMock.mock.calls
|
||||
.map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> })
|
||||
.find((request) => request.method === "agent");
|
||||
expect(agentCall?.params?.deliver).toBe(false);
|
||||
expect(agentCall?.params?.channel).toBeUndefined();
|
||||
expect(agentCall?.params?.to).toBeUndefined();
|
||||
expect(agentCall?.params?.threadId).toBeUndefined();
|
||||
expect(hoisted.startAcpSpawnParentStreamRelayMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
parentSessionKey: "agent:main:subagent:parent",
|
||||
agentId: "codex",
|
||||
logPath: "/tmp/sess-main.acp-stream.jsonl",
|
||||
emitStartNotice: false,
|
||||
}),
|
||||
);
|
||||
expect(firstHandle.dispose).toHaveBeenCalledTimes(1);
|
||||
expect(secondHandle.notifyStarted).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not implicitly stream when heartbeat target is not session-local", async () => {
|
||||
hoisted.state.cfg = {
|
||||
...hoisted.state.cfg,
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: {
|
||||
every: "30m",
|
||||
target: "discord",
|
||||
to: "channel:ops-room",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await spawnAcpDirect(
|
||||
{
|
||||
task: "Investigate flaky tests",
|
||||
agentId: "codex",
|
||||
},
|
||||
{
|
||||
agentSessionKey: "agent:main:subagent:fixed-target",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe("accepted");
|
||||
expect(result.mode).toBe("run");
|
||||
expect(result.streamLogPath).toBeUndefined();
|
||||
expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not implicitly stream when session scope is global", async () => {
|
||||
hoisted.state.cfg = {
|
||||
...hoisted.state.cfg,
|
||||
session: {
|
||||
...hoisted.state.cfg.session,
|
||||
scope: "global",
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: {
|
||||
every: "30m",
|
||||
target: "last",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await spawnAcpDirect(
|
||||
{
|
||||
task: "Investigate flaky tests",
|
||||
agentId: "codex",
|
||||
},
|
||||
{
|
||||
agentSessionKey: "agent:main:subagent:global-scope",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe("accepted");
|
||||
expect(result.mode).toBe("run");
|
||||
expect(result.streamLogPath).toBeUndefined();
|
||||
expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not implicitly stream for subagent requester sessions when heartbeat is disabled", async () => {
|
||||
hoisted.state.cfg = {
|
||||
...hoisted.state.cfg,
|
||||
agents: {
|
||||
list: [{ id: "main", heartbeat: { every: "30m" } }, { id: "research" }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await spawnAcpDirect(
|
||||
{
|
||||
task: "Investigate flaky tests",
|
||||
agentId: "codex",
|
||||
},
|
||||
{
|
||||
agentSessionKey: "agent:research:subagent:orchestrator",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe("accepted");
|
||||
expect(result.mode).toBe("run");
|
||||
expect(result.streamLogPath).toBeUndefined();
|
||||
expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not implicitly stream for subagent requester sessions when heartbeat cadence is invalid", async () => {
|
||||
hoisted.state.cfg = {
|
||||
...hoisted.state.cfg,
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "research",
|
||||
heartbeat: { every: "0m" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await spawnAcpDirect(
|
||||
{
|
||||
task: "Investigate flaky tests",
|
||||
agentId: "codex",
|
||||
},
|
||||
{
|
||||
agentSessionKey: "agent:research:subagent:invalid-heartbeat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe("accepted");
|
||||
expect(result.mode).toBe("run");
|
||||
expect(result.streamLogPath).toBeUndefined();
|
||||
expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not implicitly stream when heartbeats are runtime-disabled", async () => {
|
||||
hoisted.areHeartbeatsEnabledMock.mockReturnValue(false);
|
||||
|
||||
const result = await spawnAcpDirect(
|
||||
{
|
||||
task: "Investigate flaky tests",
|
||||
agentId: "codex",
|
||||
},
|
||||
{
|
||||
agentSessionKey: "agent:main:subagent:runtime-disabled",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe("accepted");
|
||||
expect(result.mode).toBe("run");
|
||||
expect(result.streamLogPath).toBeUndefined();
|
||||
expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not implicitly stream for legacy subagent requester session keys", async () => {
|
||||
const result = await spawnAcpDirect(
|
||||
{
|
||||
task: "Investigate flaky tests",
|
||||
agentId: "codex",
|
||||
},
|
||||
{
|
||||
agentSessionKey: "subagent:legacy-worker",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe("accepted");
|
||||
expect(result.mode).toBe("run");
|
||||
expect(result.streamLogPath).toBeUndefined();
|
||||
expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not implicitly stream for subagent requester sessions with thread context", async () => {
|
||||
const result = await spawnAcpDirect(
|
||||
{
|
||||
task: "Investigate flaky tests",
|
||||
agentId: "codex",
|
||||
},
|
||||
{
|
||||
agentSessionKey: "agent:main:subagent:thread-context",
|
||||
agentChannel: "discord",
|
||||
agentAccountId: "default",
|
||||
agentTo: "channel:parent-channel",
|
||||
agentThreadId: "requester-thread",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe("accepted");
|
||||
expect(result.mode).toBe("run");
|
||||
expect(result.streamLogPath).toBeUndefined();
|
||||
expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not implicitly stream for thread-bound subagent requester sessions", async () => {
|
||||
hoisted.sessionBindingListBySessionMock.mockImplementation((targetSessionKey: string) => {
|
||||
if (targetSessionKey === "agent:main:subagent:thread-bound") {
|
||||
return [
|
||||
createSessionBinding({
|
||||
targetSessionKey,
|
||||
targetKind: "subagent",
|
||||
status: "active",
|
||||
}),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const result = await spawnAcpDirect(
|
||||
{
|
||||
task: "Investigate flaky tests",
|
||||
agentId: "codex",
|
||||
},
|
||||
{
|
||||
agentSessionKey: "agent:main:subagent:thread-bound",
|
||||
agentChannel: "discord",
|
||||
agentAccountId: "default",
|
||||
agentTo: "channel:parent-channel",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe("accepted");
|
||||
expect(result.mode).toBe("run");
|
||||
expect(result.streamLogPath).toBeUndefined();
|
||||
expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("announces parent relay start only after successful child dispatch", async () => {
|
||||
const firstHandle = createRelayHandle();
|
||||
const secondHandle = createRelayHandle();
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue