mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-22 06:52:07 +08:00
Compare commits
191 Commits
feat/matte
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a39e548ede | ||
|
|
328a44695f | ||
|
|
c21dcfc7c2 | ||
|
|
2bdcc8314d | ||
|
|
464adfe5e5 | ||
|
|
b66b4504f8 | ||
|
|
f6d432e545 | ||
|
|
fd13192adc | ||
|
|
1c63da09d8 | ||
|
|
735505442c | ||
|
|
108d6d7eca | ||
|
|
984c8f6ea0 | ||
|
|
cd9060e06a | ||
|
|
5b96eb0172 | ||
|
|
b95b725c83 | ||
|
|
4b881509eb | ||
|
|
a03032a272 | ||
|
|
c94ebdbebd | ||
|
|
7b46167607 | ||
|
|
0e53358945 | ||
|
|
6b45e9af7a | ||
|
|
2ef61eb782 | ||
|
|
6823f56d8e | ||
|
|
3e1d3c5feb | ||
|
|
dfbc9ab246 | ||
|
|
179eb15554 | ||
|
|
c3b1e926e8 | ||
|
|
89768d456b | ||
|
|
609d7a14b1 | ||
|
|
3bae0d6b82 | ||
|
|
75a997dd7c | ||
|
|
ab8dc3af52 | ||
|
|
bebc5d847d | ||
|
|
a0f28bd3f5 | ||
|
|
c638617897 | ||
|
|
b77d6149e1 | ||
|
|
36db108fc1 | ||
|
|
e00c1eebc4 | ||
|
|
32d22d04cc | ||
|
|
648ef73bde | ||
|
|
beebb35de4 | ||
|
|
55959148ca | ||
|
|
4684bbba97 | ||
|
|
409adfbe10 | ||
|
|
2609b97222 | ||
|
|
03bc600e67 | ||
|
|
8102d5ebc3 | ||
|
|
6399eb8191 | ||
|
|
1a5839fbd8 | ||
|
|
d17045db6f | ||
|
|
2a8db1fc23 | ||
|
|
82f43f0a62 | ||
|
|
9adf3d92bd | ||
|
|
e21164933a | ||
|
|
1b17517969 | ||
|
|
86fea26797 | ||
|
|
2e7c3ace9c | ||
|
|
e34204a1e0 | ||
|
|
6daf9307e0 | ||
|
|
ebb670b208 | ||
|
|
52672c7af1 | ||
|
|
690efd2a16 | ||
|
|
102ab759e7 | ||
|
|
7da955fae4 | ||
|
|
06a0148072 | ||
|
|
edd1d3319c | ||
|
|
0ea39a2276 | ||
|
|
6fa944e80f | ||
|
|
4c453c931f | ||
|
|
13b0976c70 | ||
|
|
43e00c06c3 | ||
|
|
03ce3d41b1 | ||
|
|
bda05dbc2f | ||
|
|
7069d95720 | ||
|
|
8086cffd17 | ||
|
|
d91aee7220 | ||
|
|
c8ab37f6fe | ||
|
|
a823cb2b30 | ||
|
|
5230ec66ae | ||
|
|
eea777c9fc | ||
|
|
0b28a72be1 | ||
|
|
adcba85264 | ||
|
|
5b79fa13e2 | ||
|
|
124ea48549 | ||
|
|
12756fc4c8 | ||
|
|
5bf459e23b | ||
|
|
d64a27feeb | ||
|
|
6c42f73619 | ||
|
|
d460f00eb9 | ||
|
|
b83dce7b33 | ||
|
|
d3c907193f | ||
|
|
b47c930e7e | ||
|
|
0befd3c8f2 | ||
|
|
c2ee9b0be8 | ||
|
|
9d27583190 | ||
|
|
04c8c50cc4 | ||
|
|
5abf4ce2e2 | ||
|
|
07d5cdec99 | ||
|
|
f5f23e739e | ||
|
|
0c183283e5 | ||
|
|
514b3365b5 | ||
|
|
2804c24dc6 | ||
|
|
94c7b5a874 | ||
|
|
93ec8b8c5c | ||
|
|
9d83eeaccf | ||
|
|
33eb6ab9de | ||
|
|
757ab933f4 | ||
|
|
63fdc57b3a | ||
|
|
a39a3b74de | ||
|
|
77a859f4ae | ||
|
|
84cf64770f | ||
|
|
0ad48dad2c | ||
|
|
b50a5aebba | ||
|
|
b84665222c | ||
|
|
6441e56465 | ||
|
|
6daabd23f8 | ||
|
|
2f33999898 | ||
|
|
a60947fb3e | ||
|
|
ac5d219be3 | ||
|
|
ae41b00922 | ||
|
|
b28e68e0ce | ||
|
|
5d1e649aea | ||
|
|
d6d17709e8 | ||
|
|
6fd6bddb92 | ||
|
|
f4dee99574 | ||
|
|
db33402af0 | ||
|
|
088cab5ee4 | ||
|
|
bd74a62118 | ||
|
|
9f888d95e0 | ||
|
|
11a2e03bd4 | ||
|
|
5693fcda78 | ||
|
|
eb00d499d1 | ||
|
|
8a7906c716 | ||
|
|
c85113e30e | ||
|
|
bdf81a825f | ||
|
|
d9dfcd6c8a | ||
|
|
2cafbd0774 | ||
|
|
54b2836eab | ||
|
|
5b6f4b2919 | ||
|
|
c037a34ba7 | ||
|
|
12c34fc3a9 | ||
|
|
e366349730 | ||
|
|
89a73d08c8 | ||
|
|
e6f41a4df0 | ||
|
|
b7fef7fca6 | ||
|
|
d1cbe29f3d | ||
|
|
9dbc21d283 | ||
|
|
5e86c7eef4 | ||
|
|
6f3af56952 | ||
|
|
03ee22666b | ||
|
|
0d351b9875 | ||
|
|
f69ba12a37 | ||
|
|
b796890b97 | ||
|
|
b5fc9514c0 | ||
|
|
2a140e6e6a | ||
|
|
6e5f4d685e | ||
|
|
600bace853 | ||
|
|
b8a5dac1a2 | ||
|
|
15c880aeff | ||
|
|
3a53eb5d77 | ||
|
|
66e5cfdd86 | ||
|
|
c4facb2bb3 | ||
|
|
a70b34a3cb | ||
|
|
59bf85c586 | ||
|
|
e486a1d1cf | ||
|
|
72b9bc7303 | ||
|
|
d3b44442f6 | ||
|
|
6ddbcbd460 | ||
|
|
7bd4aab21f | ||
|
|
a4c8b17b9e | ||
|
|
d87f8325d0 | ||
|
|
a5fde9119c | ||
|
|
e6c899dfa5 | ||
|
|
425f512897 | ||
|
|
735d70d9db | ||
|
|
15300291ed | ||
|
|
eb7789c8cb | ||
|
|
f19052b3f3 | ||
|
|
61d1fd1f72 | ||
|
|
1435fc123f | ||
|
|
6c4028e073 | ||
|
|
9242137ca7 | ||
|
|
35d7cb0bff | ||
|
|
c000e4811d | ||
|
|
d25549f142 | ||
|
|
a192b2ea52 | ||
|
|
7975ec0b11 | ||
|
|
e9b694ef9c | ||
|
|
3b332fd0a4 | ||
|
|
7dd01d15c5 | ||
|
|
675c56692a |
30
.github/workflows/clawsweeper-dispatch.yml
vendored
30
.github/workflows/clawsweeper-dispatch.yml
vendored
@@ -81,9 +81,27 @@ jobs:
|
||||
repositories: clawsweeper
|
||||
permission-contents: write
|
||||
|
||||
- name: Pre-filter ClawSweeper comment
|
||||
id: comment_filter
|
||||
if: ${{ github.event_name == 'issue_comment' }}
|
||||
env:
|
||||
COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if grep -Eiq '(^|[[:space:]])@(clawsweeper|openclaw-clawsweeper)\b(\[bot\])?|(^|[[:space:]])/(clawsweeper|review|autoclose|auto([[:space:]]+|-)?merge)\b' <<< "$COMMENT_BODY"; then
|
||||
echo "is_command=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_command=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Create target comment token
|
||||
id: target_token
|
||||
if: ${{ github.event_name == 'issue_comment' && env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'issue_comment' &&
|
||||
steps.comment_filter.outputs.is_command == 'true' &&
|
||||
env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true'
|
||||
}}
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }}
|
||||
@@ -213,7 +231,11 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Acknowledge and dispatch ClawSweeper comment
|
||||
if: ${{ github.event_name == 'issue_comment' }}
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'issue_comment' &&
|
||||
steps.comment_filter.outputs.is_command == 'true'
|
||||
}}
|
||||
env:
|
||||
DISPATCH_TOKEN: ${{ steps.token.outputs.token }}
|
||||
TARGET_TOKEN: ${{ steps.target_token.outputs.token }}
|
||||
@@ -232,10 +254,6 @@ jobs:
|
||||
. "$RUNNER_TEMP/github-api-backoff.sh"
|
||||
body_file="$RUNNER_TEMP/clawsweeper-comment-body.txt"
|
||||
printf '%s\n' "$COMMENT_BODY" > "$body_file"
|
||||
if ! grep -Eiq '(^|[[:space:]])@(clawsweeper|openclaw-clawsweeper)\b(\[bot\])?|(^|[[:space:]])/(clawsweeper|review|automerge|autoclose)\b' "$body_file"; then
|
||||
echo "No ClawSweeper command found in comment."
|
||||
exit 0
|
||||
fi
|
||||
if [ -n "$TARGET_TOKEN" ]; then
|
||||
err="$(mktemp)"
|
||||
if GH_TOKEN="$TARGET_TOKEN" gh_api_with_retry -X POST \
|
||||
|
||||
@@ -6,7 +6,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Richer Telegram delivery:** Telegram now sends rich HTML, preserves rich markdown and sticker paths, renders progress drafts and command output more faithfully, normalizes HTML tables safely, and keeps mentions and spooled handlers on the right delivery path. (#93286, #93164, #93124, #93364, #93130, #93088, #93281, #94891, #94856) Thanks @obviyus, @vincentkoc, @goutamadwant, @kesslerio, @NianJiuZst, @SweetSophia, @Marvinthebored, @aaajiao, @zhangqueping, and @jairrab.
|
||||
- **Richer Telegram delivery:** Telegram now sends rich HTML, preserves rich markdown and sticker paths, renders progress drafts and command output more faithfully, normalizes HTML tables safely, and keeps mentions and spooled handlers on the right delivery path. (#93286, #93164, #93124, #93364, #93130, #93002, #93088, #93281, #94891, #94856) Thanks @obviyus, @vincentkoc, @goutamadwant, @kesslerio, @NianJiuZst, @SweetSophia, @Marvinthebored, @aaajiao, @zhangguiping-xydt, @zhangqueping, and @jairrab.
|
||||
- **More dependable agent recovery:** retries, terminal outcomes, usage after compaction, session history repair, and reply reconciliation now keep more interrupted or partial turns moving toward a visible final result. (#92191, #93073, #93228, #93084, #93469, #93291, #90943) Thanks @ai-hpc, @lml2468, @fuller-stack-dev, @Hollychou924, @leno23, @de1tydev, @425072024, @wuwahe3, @drvoss, @yetval, @sandieman2, and @vincentkoc.
|
||||
- **A stronger Codex integration:** Codex gains automatic plugin approvals, GPT-5.3 Spark OAuth routing, remote-node `exec` as a dynamic tool, and more reliable app-server teardown and terminal outcomes. (#92625, #89133, #93654, #91767, #93287) Thanks @kevinslin, @VACInc, @vincentkoc, @JPKay-AI, and @aliahnaf2013-max.
|
||||
- **Standalone official provider plugins:** external provider packages are now first-class npm releases, externally installed channel plugins load at Gateway startup, and StepFun is available from npm and ClawHub. (#93470) Thanks @sunlit-deng, @cxdnicole, and @vincentkoc.
|
||||
@@ -25,7 +25,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Security and privacy: redact secrets from debug/config output, block internal HTTP session overrides, audit open-DM tool exposure, and retain plugin write ownership checks. (#93333, #88496, #93443, #92883, #93353) Thanks @Alix-007, @jason-allen-oneal, @coygeek, @RichardCao, @yu-xin-c, @cjg20ss, @eleqtrizit, and @vincentkoc.
|
||||
- Agent and session runtime: retry thinking-only and empty post-tool turns, prevent duplicate hook execution, preserve pending subagent delivery, preserve fresh usage through compaction, and repair partial JSON/history artifacts. (#92191, #93073, #93009, #93084, #93469, #94349, #92383, #94257) Thanks @ai-hpc, @lml2468, @fuller-stack-dev, @zenglingbiao, @dertbv, @Hollychou924, @leno23, @de1tydev, @425072024, @wuwahe3, @drvoss, @vincentkoc, @sallyom, @oiGaDio, @Hidetsugu55, and @Nas01010101.
|
||||
- Channels and replies: fix Telegram rich delivery, table rendering, action-error handling, and ingress recovery; preserve command progress detail across channel adapters; retain WhatsApp opening text after a media failure; keep Mattermost thread replies intact; and harden Discord action handling. (#93286, #93364, #93281, #93076, #93334, #93424, #93488, #94868, #94891, #94856, #94810, #93823) Thanks @obviyus, @NianJiuZst, @mcaxtr, @rushindrasinha, @amknight, @lzyyzznl, @darealgege, @vincentkoc, @zhangqueping, @jairrab, @ZOOWH, @parveshsaini, and @yetval.
|
||||
- Channels and replies: fix Telegram rich delivery, table rendering, action-error handling, progress draft cleanup before visible tool output, and ingress recovery; preserve command progress detail across channel adapters; retain WhatsApp opening text after a media failure; keep Mattermost thread replies intact; and harden Discord action handling. (#93286, #93364, #93281, #93002, #93076, #93334, #93424, #93488, #94868, #94891, #94856, #94810, #93823) Thanks @obviyus, @NianJiuZst, @mcaxtr, @zhangguiping-xydt, @rushindrasinha, @amknight, @lzyyzznl, @darealgege, @vincentkoc, @zhangqueping, @jairrab, @ZOOWH, @parveshsaini, and @yetval.
|
||||
- Storage and migrations: avoid SQLite WAL on network filesystems, clean reindex artifacts, keep setup state out of workspace dot-directories, and import default-agent auth profiles into SQLite. (#93454, #92891, #93182, #93295, #93520, #93156) Thanks @vincentkoc, @ZengWen-DT, @Zeng-wen, @potterdigital, @Alix-007, @Pick-cat, @sallyom, @1qh, and @Tazio7.
|
||||
- Provider and model behavior: fix Gemini CLI proxy OAuth, restore Codex Spark OAuth routing, correct Bedrock embedding model IDs, and preserve configured defaults in embedded runs. (#92815, #89133, #93452, #93428) Thanks @yetval, @EvetteYoung, @VACInc, @LiuwqGit, @aleck31, @zenglingbiao, @danielgerlag, and @vincentkoc.
|
||||
- CLI, TUI, and apps: accept global flags after subcommands, keep terminal output and activity indicators visible, preserve CJK IME composition, and refresh stale UI state. (#93455, #93460, #93006, #93427, #93498, #93606) Thanks @ooiuuii, @Alix-007, @ZengWen-DT, @Zeng-wen, @AlethiaQuizForge, @Zhaoqj2016, @liuhao1024, @BrianClaw1955, @vincentkoc, and @NicoBoom13.
|
||||
@@ -33,7 +33,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Complete contribution record
|
||||
|
||||
This audited record covers the complete v2026.6.8..HEAD history: 422 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
|
||||
This audited record covers the complete v2026.6.8..HEAD history: 423 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
|
||||
|
||||
#### Pull requests
|
||||
|
||||
@@ -57,6 +57,7 @@ This audited record covers the complete v2026.6.8..HEAD history: 422 merged PRs.
|
||||
- **PR #88792** fix(state): harden sqlite path caching. Thanks @vincentkoc.
|
||||
- **PR #93022** fix(gateway): repair usage cost aggregation across agents. Thanks @luke-skywalker-open-claw and @stablegenius49.
|
||||
- **PR #93020** fix(telegram): cool down transient sendChatAction failures. Related #56096. Thanks @Boulea7 and @sumaiazaman and @Pick-cat and @cal-rufus.
|
||||
- **PR #93002** fix(telegram): clear progress drafts before visible tool output. Thanks @zhangguiping-xydt.
|
||||
- **PR #89160** fix(agents): detect truncated API responses to prevent silent session hang. Related #89051. Thanks @joelnishanth and @ArthurusDent.
|
||||
- **PR #93009** fix(agents): make wrapToolWithBeforeToolCallHook idempotent to prevent double hook execution (fixes #92973). Thanks @zenglingbiao and @dertbv.
|
||||
- **PR #92991** fix(agents): tolerate missing attribution baseUrl. Related #92974. Thanks @samrusani and @Haderach-Ram.
|
||||
|
||||
@@ -23,6 +23,10 @@ private struct WatchChatPreview {
|
||||
var statusText: String?
|
||||
}
|
||||
|
||||
private struct ExecApprovalGatewayEventPayload: Decodable {
|
||||
var id: String
|
||||
}
|
||||
|
||||
/// Ensures notification requests return promptly even if the system prompt blocks.
|
||||
private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
@@ -895,26 +899,49 @@ final class NodeAppModel {
|
||||
for await evt in stream {
|
||||
if Task.isCancelled { return }
|
||||
guard let payload = evt.payload else { continue }
|
||||
switch evt.event {
|
||||
case "voicewake.changed":
|
||||
struct Payload: Decodable { var triggers: [String] }
|
||||
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
|
||||
let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers)
|
||||
VoiceWakePreferences.saveTriggerWords(triggers)
|
||||
case "talk.mode":
|
||||
struct Payload: Decodable {
|
||||
var enabled: Bool
|
||||
var phase: String?
|
||||
}
|
||||
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
|
||||
self.applyTalkModeSync(enabled: decoded.enabled, phase: decoded.phase)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
await self.handleOperatorGatewayServerEvent(evt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleOperatorGatewayServerEvent(_ evt: EventFrame) async {
|
||||
guard let payload = evt.payload else { return }
|
||||
switch evt.event {
|
||||
case "voicewake.changed":
|
||||
struct Payload: Decodable { var triggers: [String] }
|
||||
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { return }
|
||||
let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers)
|
||||
VoiceWakePreferences.saveTriggerWords(triggers)
|
||||
case "talk.mode":
|
||||
struct Payload: Decodable {
|
||||
var enabled: Bool
|
||||
var phase: String?
|
||||
}
|
||||
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { return }
|
||||
self.applyTalkModeSync(enabled: decoded.enabled, phase: decoded.phase)
|
||||
case ExecApprovalNotificationBridge.requestedKind:
|
||||
guard let approvalId = Self.execApprovalEventID(from: payload) else { return }
|
||||
await self.presentExecApprovalNotificationPrompt(
|
||||
ExecApprovalNotificationPrompt(approvalId: approvalId))
|
||||
case ExecApprovalNotificationBridge.resolvedKind:
|
||||
guard let approvalId = Self.execApprovalEventID(from: payload) else { return }
|
||||
await self.handleExecApprovalResolvedRemotePush(approvalId: approvalId)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func execApprovalEventID(from payload: AnyCodable) -> String? {
|
||||
guard let decoded = try? GatewayPayloadDecoding.decode(
|
||||
payload,
|
||||
as: ExecApprovalGatewayEventPayload.self)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let approvalId = decoded.id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return approvalId.isEmpty ? nil : approvalId
|
||||
}
|
||||
|
||||
private func applyTalkModeSync(enabled: Bool, phase: String?) {
|
||||
_ = phase
|
||||
guard self.talkMode.isEnabled != enabled else { return }
|
||||
@@ -5139,6 +5166,14 @@ extension NodeAppModel {
|
||||
isBackgrounded: isBackgrounded)
|
||||
}
|
||||
|
||||
nonisolated static func _test_execApprovalEventID(from payload: AnyCodable) -> String? {
|
||||
self.execApprovalEventID(from: payload)
|
||||
}
|
||||
|
||||
func _test_handleOperatorGatewayServerEvent(_ event: EventFrame) async {
|
||||
await self.handleOperatorGatewayServerEvent(event)
|
||||
}
|
||||
|
||||
nonisolated static func _test_watchExecApprovalIDsNeedingFetch(
|
||||
candidateIDs: [String],
|
||||
cachedApprovalIDs: [String]) -> [String]
|
||||
|
||||
@@ -1160,6 +1160,35 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
isBackgrounded: false))
|
||||
}
|
||||
|
||||
@Test func execApprovalEventIDDecodesGatewayPayload() {
|
||||
#expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["id": " approval-1 "])) == "approval-1")
|
||||
#expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["id": " "])) == nil)
|
||||
#expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["other": "approval-1"])) == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor func operatorGatewayResolvedEventClearsPendingApprovalPrompt() async throws {
|
||||
let appModel = NodeAppModel()
|
||||
try appModel._test_presentExecApprovalPrompt(
|
||||
#require(
|
||||
NodeAppModel._test_makeExecApprovalPrompt(
|
||||
id: "approval-event-resolved",
|
||||
commandText: "echo clear",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
host: "gateway",
|
||||
nodeId: nil,
|
||||
agentId: nil,
|
||||
expiresAtMs: Int(Date().timeIntervalSince1970 * 1000) + 60000)))
|
||||
|
||||
await appModel._test_handleOperatorGatewayServerEvent(EventFrame(
|
||||
type: "event",
|
||||
event: ExecApprovalNotificationBridge.resolvedKind,
|
||||
payload: AnyCodable(["id": "approval-event-resolved"]),
|
||||
seq: nil,
|
||||
stateversion: nil))
|
||||
|
||||
#expect(appModel._test_pendingExecApprovalPrompt() == nil)
|
||||
}
|
||||
|
||||
@Test func watchExecApprovalHydrateFetchesOnlyMissingIDs() {
|
||||
let idsToFetch = NodeAppModel._test_watchExecApprovalIDsNeedingFetch(
|
||||
candidateIDs: ["cached", "pending", "cached", "other", "", " pending "],
|
||||
|
||||
@@ -6592,6 +6592,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
public let turnsourceto: AnyCodable?
|
||||
public let turnsourceaccountid: AnyCodable?
|
||||
public let turnsourcethreadid: AnyCodable?
|
||||
public let approvalreviewerdeviceids: [String]?
|
||||
public let requiredeliveryroute: Bool?
|
||||
public let suppressdelivery: Bool?
|
||||
public let timeoutms: Int?
|
||||
@@ -6618,6 +6619,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
turnsourceto: AnyCodable?,
|
||||
turnsourceaccountid: AnyCodable?,
|
||||
turnsourcethreadid: AnyCodable?,
|
||||
approvalreviewerdeviceids: [String]?,
|
||||
requiredeliveryroute: Bool? = nil,
|
||||
suppressdelivery: Bool? = nil,
|
||||
timeoutms: Int?,
|
||||
@@ -6643,6 +6645,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
self.turnsourceto = turnsourceto
|
||||
self.turnsourceaccountid = turnsourceaccountid
|
||||
self.turnsourcethreadid = turnsourcethreadid
|
||||
self.approvalreviewerdeviceids = approvalreviewerdeviceids
|
||||
self.requiredeliveryroute = requiredeliveryroute
|
||||
self.suppressdelivery = suppressdelivery
|
||||
self.timeoutms = timeoutms
|
||||
@@ -6670,6 +6673,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
case turnsourceto = "turnSourceTo"
|
||||
case turnsourceaccountid = "turnSourceAccountId"
|
||||
case turnsourcethreadid = "turnSourceThreadId"
|
||||
case approvalreviewerdeviceids = "approvalReviewerDeviceIds"
|
||||
case requiredeliveryroute = "requireDeliveryRoute"
|
||||
case suppressdelivery = "suppressDelivery"
|
||||
case timeoutms = "timeoutMs"
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
12393c35023a5bdddd276edc2b6669fa432454be9bee643138395e2106936945 plugin-sdk-api-baseline.json
|
||||
62ffb6cd4a433281f571fdf552be9c3f953f6fa055937f822b18de7dd4e20d23 plugin-sdk-api-baseline.jsonl
|
||||
172fe4e143964c0a20525428ff3e6c7631856a7d51c6ad48959a35c72363a410 plugin-sdk-api-baseline.json
|
||||
a4c18ea9f0b0d2c22183bf8c082e757b7f9852b4c518c8b8cb62a21a9dd766e9 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
summary: "Slack setup and runtime behavior (Socket Mode + HTTP Request URLs)"
|
||||
summary: "Slack setup and runtime behavior (Socket Mode, HTTP Request URLs, and relay mode)"
|
||||
read_when:
|
||||
- Setting up Slack or debugging Slack socket/HTTP mode
|
||||
- Setting up Slack or debugging Slack socket, HTTP, or relay mode
|
||||
title: "Slack"
|
||||
---
|
||||
|
||||
Production-ready for DMs and channels via Slack app integrations. Default mode is Socket Mode; HTTP Request URLs are also supported.
|
||||
Production-ready for DMs and channels via Slack app integrations. Default mode is Socket Mode; HTTP Request URLs are also supported. Relay mode is intended for managed deployments where a trusted router owns Slack ingress.
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Pairing" icon="link" href="/channels/pairing">
|
||||
@@ -41,6 +41,37 @@ Both transports are production-ready and reach feature parity for messaging, sla
|
||||
**Pick HTTP Request URLs** when running multiple Gateway replicas behind a load balancer, when outbound WSS is blocked but inbound HTTPS is allowed, or when you already terminate Slack webhooks at a reverse proxy.
|
||||
</Note>
|
||||
|
||||
### Relay mode
|
||||
|
||||
Relay mode separates Slack ingress from the OpenClaw gateway. A trusted router owns the
|
||||
single Slack Socket Mode connection, chooses a destination gateway, and forwards a typed
|
||||
event over an authenticated websocket. The gateway continues to use its bot token for
|
||||
outbound Slack Web API calls.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
slack: {
|
||||
mode: "relay",
|
||||
botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" },
|
||||
relay: {
|
||||
url: "wss://router.example.com/gateway/ws",
|
||||
authToken: { source: "env", provider: "default", id: "SLACK_RELAY_AUTH_TOKEN" },
|
||||
gatewayId: "team-gateway",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The relay URL must use `wss://` unless it targets localhost. Treat the bearer token and
|
||||
router route table as part of the Slack authorization boundary: routed events enter the
|
||||
normal Slack message handler as authorized activations. A router-provided `slack_identity`
|
||||
in the websocket `hello` frame can set the default outbound username and icon; an explicit
|
||||
identity supplied by the caller still wins. The relay connection reconnects with the same
|
||||
bounded backoff timing used by Socket Mode and clears the router-provided identity whenever
|
||||
it disconnects.
|
||||
|
||||
## Install
|
||||
|
||||
Install Slack before configuring the channel:
|
||||
@@ -863,7 +894,8 @@ The default manifest enables the Slack App Home **Home** tab and subscribes to `
|
||||
|
||||
- `botToken` + `appToken` are required for Socket Mode.
|
||||
- HTTP mode requires `botToken` + `signingSecret`.
|
||||
- `botToken`, `appToken`, `signingSecret`, and `userToken` accept plaintext
|
||||
- Relay mode requires `botToken` plus `relay.url`, `relay.authToken`, and `relay.gatewayId`; it does not use an app token or signing secret.
|
||||
- `botToken`, `appToken`, `signingSecret`, `relay.authToken`, and `userToken` accept plaintext
|
||||
strings or SecretRef objects.
|
||||
- Config tokens override env fallback.
|
||||
- `SLACK_BOT_TOKEN` / `SLACK_APP_TOKEN` env fallback applies only to the default account.
|
||||
|
||||
@@ -336,7 +336,8 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
Requirement:
|
||||
|
||||
- `channels.telegram.streaming` is `off | partial | block | progress` (default: `partial`)
|
||||
- `progress` keeps one editable status draft for tool progress, clears it at completion, and sends the final answer as a normal message
|
||||
- short initial answer previews are debounced, then materialized after a bounded delay if the run is still active
|
||||
- `progress` keeps one editable status draft for tool progress, shows the stable status label when answer activity arrives before tool progress, clears it at completion, and sends the final answer as a normal message
|
||||
- `streaming.preview.toolProgress` controls whether tool/progress updates reuse the same edited preview message (default: `true` when preview streaming is active)
|
||||
- `streaming.preview.commandText` controls command/exec detail inside those tool-progress lines: `raw` (default, preserves released behavior) or `status` (tool label only)
|
||||
- `streaming.progress.commentary` (default: `false`) opts into assistant commentary/preamble text in the temporary progress draft
|
||||
|
||||
@@ -230,8 +230,8 @@ canonical subscription `github-copilot` provider and is **never** selected by
|
||||
The harness claims its provider, runtime, CLI session key, and auth profile
|
||||
prefix in `extensions/copilot/doctor-contract-api.ts`, which
|
||||
`openclaw doctor` auto-loads. For configuration, auth, transcript mirroring,
|
||||
compaction, the doctor probe surface, and the broader PI vs Codex vs Copilot
|
||||
SDK decision, see [GitHub Copilot agent runtime](/plugins/copilot).
|
||||
compaction, the declarative doctor contract, and the broader PI vs Codex vs
|
||||
Copilot SDK decision, see [GitHub Copilot agent runtime](/plugins/copilot).
|
||||
|
||||
## Compatibility contract
|
||||
|
||||
|
||||
@@ -302,13 +302,13 @@ Live transport runners should import the shared scenario ids, baseline
|
||||
coverage helpers, and scenario-selection helper from
|
||||
`openclaw/plugin-sdk/qa-live-transport-scenarios`.
|
||||
|
||||
| Lane | Canary | Mention gating | Bot-to-bot | Allowlist block | Top-level reply | Restart resume | Thread follow-up | Thread isolation | Reaction observation | Help command | Native command registration |
|
||||
| -------- | ------ | -------------- | ---------- | --------------- | --------------- | -------------- | ---------------- | ---------------- | -------------------- | ------------ | --------------------------- |
|
||||
| Matrix | x | x | x | x | x | x | x | x | x | | |
|
||||
| Telegram | x | x | x | | | | | | | x | |
|
||||
| Discord | x | x | x | | | | | | | | x |
|
||||
| Slack | x | x | x | x | x | x | x | x | | | |
|
||||
| WhatsApp | x | x | | x | x | x | | | x | x | |
|
||||
| Lane | Canary | Mention gating | Bot-to-bot | Allowlist block | Top-level reply | Quote reply | Restart resume | Thread follow-up | Thread isolation | Reaction observation | Help command | Native command registration |
|
||||
| -------- | ------ | -------------- | ---------- | --------------- | --------------- | ----------- | -------------- | ---------------- | ---------------- | -------------------- | ------------ | --------------------------- |
|
||||
| Matrix | x | x | x | x | x | | x | x | x | x | | |
|
||||
| Telegram | x | x | x | | | | | | | | x | |
|
||||
| Discord | x | x | x | | | | | | | | | x |
|
||||
| Slack | x | x | x | x | x | | x | x | x | | | |
|
||||
| WhatsApp | x | x | | x | x | x | x | | | x | x | |
|
||||
|
||||
This keeps `qa-channel` as the broad product-behavior suite while Matrix,
|
||||
Telegram, and other live transports share one explicit transport-contract checklist.
|
||||
@@ -731,8 +731,9 @@ Scenario catalog (`extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.
|
||||
`whatsapp-whoami-command`, `whatsapp-context-command`,
|
||||
`whatsapp-native-new-command`.
|
||||
- Reply and final-output behavior: `whatsapp-tool-only-usage-footer`,
|
||||
`whatsapp-reply-to-message`, `whatsapp-reply-context-isolation`,
|
||||
`whatsapp-reply-delivery-shape`, `whatsapp-stream-final-message-accounting`.
|
||||
`whatsapp-reply-to-message`, `whatsapp-group-reply-to-message`,
|
||||
`whatsapp-reply-context-isolation`, `whatsapp-reply-delivery-shape`,
|
||||
`whatsapp-stream-final-message-accounting`.
|
||||
- Inbound media and structured messages: `whatsapp-inbound-image-caption`,
|
||||
`whatsapp-audio-preflight`, `whatsapp-inbound-structured-messages`,
|
||||
`whatsapp-group-audio-gating`. These send real WhatsApp image, audio,
|
||||
@@ -749,9 +750,9 @@ Scenario catalog (`extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.
|
||||
`whatsapp-approval-plugin-native`.
|
||||
- Status reactions: `whatsapp-status-reactions`.
|
||||
|
||||
The catalog currently contains 35 scenarios. The `live-frontier` default lane is
|
||||
kept small at 8 scenarios for fast smoke coverage. The `mock-openai` default
|
||||
lane runs 29 deterministic scenarios through the real WhatsApp transport while
|
||||
The catalog currently contains 36 scenarios. The `live-frontier` default lane is
|
||||
kept small at 10 scenarios for fast smoke coverage. The `mock-openai` default
|
||||
lane runs 31 deterministic scenarios through the real WhatsApp transport while
|
||||
mocking only model output. Approval scenarios and a few heavier/blocking checks
|
||||
remain explicit by scenario id.
|
||||
|
||||
|
||||
@@ -160,9 +160,10 @@ Legacy key migration:
|
||||
Telegram:
|
||||
|
||||
- Uses `sendMessage` + `editMessageText` preview updates across DMs and group/topics.
|
||||
- Short initial previews are still debounced for push-notification UX, but Telegram now materializes them after a bounded delay so active runs do not stay visually silent.
|
||||
- Final text edits the active preview in place; long finals reuse that message for the first chunk and send only the remaining chunks.
|
||||
- `block` mode rotates the preview into a new message at `streaming.preview.chunk.maxChars` (default 800, capped at Telegram's 4096 edit limit); other modes grow one preview up to 4096 characters.
|
||||
- `progress` mode keeps tool progress in an editable status draft, clears that draft at completion, and sends the final answer through normal delivery.
|
||||
- `progress` mode keeps tool progress in an editable status draft, materializes the status label when answer streaming is active but no tool line is available yet, clears that draft at completion, and sends the final answer through normal delivery.
|
||||
- If the final edit fails before the completed text is confirmed, OpenClaw uses normal final delivery and cleans up the stale preview.
|
||||
- Preview streaming is skipped when Telegram block streaming is explicitly enabled (to avoid double-streaming).
|
||||
- `/reasoning stream` can write reasoning to a transient preview that is deleted after final delivery.
|
||||
|
||||
@@ -249,9 +249,10 @@ Shared defaults for bounded runtime context surfaces.
|
||||
- `toolResultMaxChars`: advanced live tool-result ceiling used for persisted
|
||||
results and overflow recovery. Leave unset for the model-context auto cap:
|
||||
`16000` chars below 100K tokens, `32000` chars at 100K+ tokens, and `64000`
|
||||
chars at 200K+ tokens. The effective cap is still limited to about 30% of the
|
||||
model context window. `openclaw doctor --deep` prints the effective cap, and
|
||||
doctor warns only when an explicit override is stale or has no effect.
|
||||
chars at 200K+ tokens. Explicit values up to `1000000` are accepted for
|
||||
long-context models, but the effective cap is still limited to about 30% of
|
||||
the model context window. `openclaw doctor --deep` prints the effective cap,
|
||||
and doctor warns only when an explicit override is stale or has no effect.
|
||||
- `postCompactionMaxChars`: AGENTS.md excerpt cap used during post-compaction
|
||||
refresh injection.
|
||||
|
||||
|
||||
@@ -160,8 +160,6 @@ must be paired with `--lint`; regular doctor and repair runs reject them.
|
||||
- State integrity and permissions checks (sessions, transcripts, state dir).
|
||||
- Config file permission checks (chmod 600) when running locally.
|
||||
- Model auth health: checks OAuth expiry, can refresh expiring tokens, and reports auth-profile cooldown/disabled states.
|
||||
- Extra workspace dir detection (`~/openclaw`).
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Gateway, services, and supervisors">
|
||||
- Sandbox image repair when sandboxing is enabled.
|
||||
@@ -469,14 +467,14 @@ That stages grounded durable candidates into the short-term dreaming store while
|
||||
<Accordion title="10. systemd linger (Linux)">
|
||||
If running as a systemd user service, doctor ensures lingering is enabled so the gateway stays alive after logout.
|
||||
</Accordion>
|
||||
<Accordion title="11. Workspace status (skills, plugins, and legacy dirs)">
|
||||
<Accordion title="11. Workspace status (skills, plugins, and TaskFlows)">
|
||||
Doctor prints a summary of the workspace state for the default agent:
|
||||
|
||||
- **Skills status**: counts eligible, missing-requirements, and allowlist-blocked skills.
|
||||
- **Legacy workspace dirs**: warns when `~/openclaw` or other legacy workspace directories exist alongside the current workspace.
|
||||
- **Plugin status**: counts enabled/disabled/errored plugins; lists plugin IDs for any errors; reports bundle plugin capabilities.
|
||||
- **Plugin compatibility warnings**: flags plugins that have compatibility issues with the current runtime.
|
||||
- **Plugin diagnostics**: surfaces any load-time warnings or errors emitted by the plugin registry.
|
||||
- **TaskFlow recovery**: surfaces suspicious managed TaskFlows that need manual inspection or cancellation.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="11b. Bootstrap file size">
|
||||
|
||||
@@ -15,15 +15,18 @@ OpenClaw treats **wake words as a single global list** owned by the **Gateway**.
|
||||
|
||||
## Storage (Gateway host)
|
||||
|
||||
Wake words are stored on the gateway machine at:
|
||||
Wake words and routing rules are stored in the gateway state database:
|
||||
|
||||
- `~/.openclaw/settings/voicewake.json`
|
||||
- `~/.openclaw/state/openclaw.sqlite`
|
||||
|
||||
Shape:
|
||||
The active tables are:
|
||||
|
||||
```json
|
||||
{ "triggers": ["openclaw", "claude", "computer"], "updatedAtMs": 1730000000000 }
|
||||
```
|
||||
- `voicewake_triggers`
|
||||
- `voicewake_routing_config`
|
||||
- `voicewake_routing_routes`
|
||||
|
||||
Legacy `settings/voicewake.json` and `settings/voicewake-routing.json` files are
|
||||
doctor migration inputs only; runtime reads and writes the SQLite tables.
|
||||
|
||||
## Protocol
|
||||
|
||||
|
||||
@@ -33,15 +33,12 @@ For the broader model/provider/runtime split, start with
|
||||
- A GitHub Copilot subscription that can drive the Copilot CLI (or a
|
||||
`gitHubToken` env / auth-profile entry for headless / cron runs).
|
||||
- A writable `copilotHome` directory. The harness defaults to
|
||||
`~/.openclaw/agents/<agentId>/copilot` for full per-agent isolation. The
|
||||
platform default (`%APPDATA%\copilot` on Windows, `$XDG_CONFIG_HOME/copilot`
|
||||
or `~/.config/copilot` elsewhere) is used as the doctor probe fallback when
|
||||
no explicit home is set.
|
||||
`<agentDir>/copilot` when OpenClaw provides an agent directory, otherwise
|
||||
`~/.openclaw/agents/<agentId>/copilot` for full per-agent isolation.
|
||||
|
||||
`openclaw doctor` runs the plugin
|
||||
[doctor contract](#doctor-and-probes) for the extension; failures there are
|
||||
the canonical way to confirm the environment is ready before opting an agent
|
||||
in.
|
||||
[doctor contract](#doctor) for declarative session-state ownership and future
|
||||
compatibility migrations. It does not run Copilot CLI environment probes.
|
||||
|
||||
## Plugin install
|
||||
|
||||
@@ -153,10 +150,6 @@ the same directory), or `~/.openclaw/agents/<agentId>/copilot` otherwise.
|
||||
Override with `copilotHome: <path>` on the attempt input when you need a
|
||||
custom location (for example, a shared mount for migration).
|
||||
|
||||
`probeCopilotAuthShape` (see [Doctor and probes](#doctor-and-probes)) is the
|
||||
pure shape check that validates which of the modes above will be used.
|
||||
It does not perform a live SDK handshake.
|
||||
|
||||
## Configuration surface
|
||||
|
||||
The harness reads its config from per-attempt input
|
||||
@@ -239,7 +232,7 @@ asserted in
|
||||
[`extensions/copilot/harness.test.ts`](https://github.com/openclaw/openclaw/blob/main/extensions/copilot/harness.test.ts)
|
||||
under `describe("runSideQuestion")`.
|
||||
|
||||
## Doctor and probes
|
||||
## Doctor
|
||||
|
||||
`extensions/copilot/doctor-contract-api.ts` is auto-loaded by
|
||||
`src/plugins/doctor-contract-registry.ts`. It contributes:
|
||||
@@ -251,18 +244,6 @@ under `describe("runSideQuestion")`.
|
||||
runtime `copilot`; CLI session key `copilot`; auth profile
|
||||
prefix `github-copilot:`.
|
||||
|
||||
`extensions/copilot/src/doctor-probes.ts` exports three imperative probes
|
||||
that hosts (including `openclaw doctor`) can call to verify the environment:
|
||||
|
||||
| Probe | What it checks | Reasons it can fail |
|
||||
| -------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `probeCopilotCliVersion` | `copilot --version` exits 0 with a non-empty version string | `non-zero-exit`, `empty-version`, `spawn-failed`, `spawn-error`, `probe-timeout` |
|
||||
| `probeCopilotHomeWritable` | `mkdir -p copilotHome` + write + rm a marker file | `copilothome-not-writable` (with the underlying fs error in `details.rawError`) |
|
||||
| `probeCopilotAuthShape` | At least one of `useLoggedInUser`, `gitHubToken`, or `profileId`+`profileVersion` | `no-auth-source` |
|
||||
|
||||
Each probe accepts a DI seam (`spawnFn`, `fsApi`) so tests do not spawn the
|
||||
real Copilot CLI or touch the host fs.
|
||||
|
||||
## Limitations
|
||||
|
||||
- The harness only claims the canonical `github-copilot` provider at MVP.
|
||||
|
||||
@@ -72,10 +72,12 @@ Scope intent:
|
||||
- `channels.telegram.accounts.*.webhookSecret`
|
||||
- `channels.slack.botToken`
|
||||
- `channels.slack.appToken`
|
||||
- `channels.slack.relay.authToken`
|
||||
- `channels.slack.userToken`
|
||||
- `channels.slack.signingSecret`
|
||||
- `channels.slack.accounts.*.botToken`
|
||||
- `channels.slack.accounts.*.appToken`
|
||||
- `channels.slack.accounts.*.relay.authToken`
|
||||
- `channels.slack.accounts.*.userToken`
|
||||
- `channels.slack.accounts.*.signingSecret`
|
||||
- `channels.sms.authToken`
|
||||
|
||||
@@ -295,6 +295,13 @@
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.slack.accounts.*.relay.authToken",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.slack.accounts.*.relay.authToken",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.slack.accounts.*.signingSecret",
|
||||
"configFile": "openclaw.json",
|
||||
@@ -323,6 +330,13 @@
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.slack.relay.authToken",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.slack.relay.authToken",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.slack.signingSecret",
|
||||
"configFile": "openclaw.json",
|
||||
|
||||
@@ -54,7 +54,7 @@ for bounded runtime excerpts and injected runtime-owned blocks. They are
|
||||
separate from bootstrap limits, startup-context limits, and skills prompt
|
||||
limits.
|
||||
|
||||
`toolResultMaxChars` is an advanced ceiling. When it is unset, OpenClaw chooses
|
||||
`toolResultMaxChars` is an advanced ceiling (up to `1000000` characters). When it is unset, OpenClaw chooses
|
||||
the live tool-result cap from the effective model context window: `16000` chars
|
||||
below 100K tokens, `32000` chars at 100K+ tokens, and `64000` chars at 200K+
|
||||
tokens, still bounded by the runtime context-share guard.
|
||||
|
||||
@@ -10,6 +10,12 @@ import {
|
||||
resolveClaudeCliExecutionArgs,
|
||||
} from "./cli-shared.js";
|
||||
|
||||
function expectDefaultDisallowedTools(args: readonly string[] | undefined) {
|
||||
const disallowedIndex = args?.indexOf("--disallowedTools") ?? -1;
|
||||
expect(disallowedIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(args?.[disallowedIndex + 1]).toBe("ScheduleWakeup,CronCreate");
|
||||
}
|
||||
|
||||
describe("normalizeClaudePermissionArgs", () => {
|
||||
it("leaves args alone when they omit permission flags", () => {
|
||||
expect(
|
||||
@@ -356,8 +362,10 @@ describe("normalizeClaudeBackendConfig", () => {
|
||||
expect(backend.config.input).toBe("stdin");
|
||||
expect(backend.config.args).toContain("--setting-sources");
|
||||
expect(backend.config.args).toContain("user");
|
||||
expectDefaultDisallowedTools(backend.config.args);
|
||||
expect(backend.config.resumeArgs).toContain("--setting-sources");
|
||||
expect(backend.config.resumeArgs).toContain("user");
|
||||
expectDefaultDisallowedTools(backend.config.resumeArgs);
|
||||
expect(backend.config.clearEnv).toEqual([...CLAUDE_CLI_CLEAR_ENV]);
|
||||
expect(backend.config.clearEnv).toContain("ANTHROPIC_API_TOKEN");
|
||||
expect(backend.config.clearEnv).toContain("ANTHROPIC_BASE_URL");
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
/**
|
||||
* Browser plugin runtime lifecycle helpers for startup relay setup and shutdown
|
||||
* cleanup.
|
||||
* Browser plugin runtime lifecycle helpers for startup and shutdown cleanup.
|
||||
*/
|
||||
import type { Server } from "node:http";
|
||||
import { getPwAiModule } from "./pw-ai-module.js";
|
||||
import { isPwAiLoaded } from "./pw-ai-state.js";
|
||||
import type { BrowserServerState } from "./server-context.js";
|
||||
import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js";
|
||||
import { stopKnownBrowserProfiles } from "./server-lifecycle.js";
|
||||
import { startTrackedBrowserTabCleanupTimer } from "./session-tab-cleanup.js";
|
||||
import { registerBrowserUnhandledRejectionHandler } from "./unhandled-rejections.js";
|
||||
|
||||
@@ -27,10 +26,6 @@ export async function createBrowserRuntimeState(params: {
|
||||
onWarn: params.onWarn,
|
||||
});
|
||||
|
||||
await ensureExtensionRelayForProfiles({
|
||||
resolved: params.resolved,
|
||||
onWarn: params.onWarn,
|
||||
});
|
||||
state.stopUnhandledRejectionHandler = registerBrowserUnhandledRejectionHandler();
|
||||
|
||||
return state;
|
||||
|
||||
@@ -19,7 +19,6 @@ const { getUnhandledRejectionHandlers, registerUnhandledRejectionHandlerMock, re
|
||||
});
|
||||
|
||||
const {
|
||||
ensureExtensionRelayForProfilesMock,
|
||||
getPwAiModuleMock,
|
||||
isPwAiLoadedMock,
|
||||
startTrackedBrowserTabCleanupTimerMock,
|
||||
@@ -28,7 +27,6 @@ const {
|
||||
} = vi.hoisted(() => {
|
||||
const trackedTabCleanupMockLocal = vi.fn();
|
||||
return {
|
||||
ensureExtensionRelayForProfilesMock: vi.fn(async () => {}),
|
||||
getPwAiModuleMock: vi.fn(),
|
||||
isPwAiLoadedMock: vi.fn(() => false),
|
||||
startTrackedBrowserTabCleanupTimerMock: vi.fn(() => trackedTabCleanupMockLocal),
|
||||
@@ -42,7 +40,6 @@ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./server-lifecycle.js", () => ({
|
||||
ensureExtensionRelayForProfiles: ensureExtensionRelayForProfilesMock,
|
||||
stopKnownBrowserProfiles: stopKnownBrowserProfilesMock,
|
||||
}));
|
||||
|
||||
@@ -64,7 +61,6 @@ const { isPlaywrightDialogRaceUnhandledRejection } = await import("./unhandled-r
|
||||
beforeEach(() => {
|
||||
resetHandlers();
|
||||
registerUnhandledRejectionHandlerMock.mockClear();
|
||||
ensureExtensionRelayForProfilesMock.mockClear();
|
||||
getPwAiModuleMock.mockClear();
|
||||
isPwAiLoadedMock.mockReset().mockReturnValue(false);
|
||||
startTrackedBrowserTabCleanupTimerMock.mockClear();
|
||||
|
||||
@@ -19,8 +19,7 @@ vi.mock("./server-context.js", () => ({
|
||||
listKnownProfileNames: listKnownProfileNamesMock,
|
||||
}));
|
||||
|
||||
const { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } =
|
||||
await import("./server-lifecycle.js");
|
||||
const { stopKnownBrowserProfiles } = await import("./server-lifecycle.js");
|
||||
|
||||
beforeEach(() => {
|
||||
createBrowserRouteContextMock.mockClear();
|
||||
@@ -28,17 +27,6 @@ beforeEach(() => {
|
||||
stopOpenClawChromeMock.mockClear();
|
||||
});
|
||||
|
||||
describe("ensureExtensionRelayForProfiles", () => {
|
||||
it("is a no-op after removing the Chrome extension relay path", async () => {
|
||||
await expect(
|
||||
ensureExtensionRelayForProfiles({
|
||||
resolved: { profiles: {} } as never,
|
||||
onWarn: vi.fn(),
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopKnownBrowserProfiles", () => {
|
||||
it("stops all known profiles and ignores per-profile failures", async () => {
|
||||
listKnownProfileNamesMock.mockReturnValue(["openclaw", "user"]);
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
/**
|
||||
* Browser server lifecycle helpers for relay setup and profile shutdown.
|
||||
* Browser server lifecycle helpers for profile shutdown.
|
||||
*/
|
||||
import { stopOpenClawChrome } from "./chrome.js";
|
||||
import type { ResolvedBrowserConfig } from "./config.js";
|
||||
import {
|
||||
type BrowserServerState,
|
||||
createBrowserRouteContext,
|
||||
listKnownProfileNames,
|
||||
} from "./server-context.js";
|
||||
|
||||
/** Ensures extension relay compatibility hooks for configured profiles. */
|
||||
export async function ensureExtensionRelayForProfiles(_params: {
|
||||
resolved: ResolvedBrowserConfig;
|
||||
onWarn: (message: string) => void;
|
||||
}) {
|
||||
// Intentional no-op: the Chrome extension relay path has been removed.
|
||||
// runtime-lifecycle still calls this helper, so keep the stub until the next
|
||||
// breaking cleanup rather than changing the call graph in a patch release.
|
||||
}
|
||||
|
||||
/** Stops every known Browser profile during runtime shutdown. */
|
||||
export async function stopKnownBrowserProfiles(params: {
|
||||
getState: () => BrowserServerState | null;
|
||||
|
||||
@@ -20,7 +20,6 @@ const mocks = vi.hoisted(() => ({
|
||||
}),
|
||||
resolveBrowserControlAuth: vi.fn(() => ({})),
|
||||
shouldAutoGenerateBrowserAuth: vi.fn(() => true),
|
||||
ensureExtensionRelayForProfiles: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", async () => {
|
||||
@@ -69,7 +68,6 @@ vi.mock("./server-context.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./server-lifecycle.js", () => ({
|
||||
ensureExtensionRelayForProfiles: mocks.ensureExtensionRelayForProfiles,
|
||||
stopKnownBrowserProfiles: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
@@ -85,7 +83,6 @@ describe("browser control auth bootstrap failures", () => {
|
||||
mocks.ensureBrowserControlAuth.mockClear();
|
||||
mocks.resolveBrowserControlAuth.mockClear();
|
||||
mocks.shouldAutoGenerateBrowserAuth.mockClear();
|
||||
mocks.ensureExtensionRelayForProfiles.mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -98,7 +95,6 @@ describe("browser control auth bootstrap failures", () => {
|
||||
expect(started).toBeNull();
|
||||
expect(mocks.ensureBrowserControlAuth).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.resolveBrowserControlAuth).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.ensureExtensionRelayForProfiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed when auth bootstrap resolves empty auth in production-like mode", async () => {
|
||||
@@ -111,7 +107,6 @@ describe("browser control auth bootstrap failures", () => {
|
||||
expect(started).toBeNull();
|
||||
expect(mocks.ensureBrowserControlAuth).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.resolveBrowserControlAuth).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.ensureExtensionRelayForProfiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed when password mode has no resolved password", async () => {
|
||||
@@ -123,7 +118,6 @@ describe("browser control auth bootstrap failures", () => {
|
||||
const started = await startBrowserControlServerFromConfig();
|
||||
|
||||
expect(started).toBeNull();
|
||||
expect(mocks.ensureExtensionRelayForProfiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed when password mode drops an inactive token but has no password", async () => {
|
||||
@@ -136,6 +130,5 @@ describe("browser control auth bootstrap failures", () => {
|
||||
const started = await startBrowserControlServerFromConfig();
|
||||
|
||||
expect(started).toBeNull();
|
||||
expect(mocks.ensureExtensionRelayForProfiles).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ const mocks = vi.hoisted(() => ({
|
||||
ensureBrowserControlAuth: vi.fn(async () => ({ auth: {} })),
|
||||
resolveBrowserControlAuth: vi.fn(() => ({})),
|
||||
shouldAutoGenerateBrowserAuth: vi.fn(() => false),
|
||||
ensureExtensionRelayForProfiles: vi.fn(async () => {}),
|
||||
stopKnownBrowserProfiles: vi.fn(async () => {}),
|
||||
isChromeReachable: vi.fn(async () => false),
|
||||
isChromeCdpReady: vi.fn(async () => false),
|
||||
@@ -32,7 +31,6 @@ vi.mock("../browser/control-auth.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../browser/server-lifecycle.js", () => ({
|
||||
ensureExtensionRelayForProfiles: mocks.ensureExtensionRelayForProfiles,
|
||||
stopKnownBrowserProfiles: mocks.stopKnownBrowserProfiles,
|
||||
}));
|
||||
|
||||
|
||||
@@ -961,6 +961,26 @@ describe("Codex app-server dynamic tool build", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes the approval reviewer device into Codex dynamic tools", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.approvalReviewerDeviceId = "device-ios-reviewer";
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
const factoryOptions: unknown[] = [];
|
||||
setOpenClawCodingToolsFactoryForTests((options) => {
|
||||
factoryOptions.push(options);
|
||||
return [];
|
||||
});
|
||||
|
||||
await buildDynamicToolsForTest(params, workspaceDir, { sandbox: null as never });
|
||||
|
||||
expect(factoryOptions[0]).toMatchObject({
|
||||
approvalReviewerDeviceId: "device-ios-reviewer",
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards tool outcome ordering into Codex dynamic tools", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
|
||||
@@ -19,10 +19,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { isToolAllowed } from "openclaw/plugin-sdk/sandbox";
|
||||
import {
|
||||
readCodexPluginConfig,
|
||||
type CodexPluginConfig,
|
||||
} from "./config.js";
|
||||
import { readCodexPluginConfig, type CodexPluginConfig } from "./config.js";
|
||||
import {
|
||||
filterCodexDynamicTools,
|
||||
isForcedPrivateQaCodexRuntime,
|
||||
@@ -260,6 +257,7 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
...sessionKeys,
|
||||
sessionId: params.sessionId,
|
||||
runId: params.runId,
|
||||
approvalReviewerDeviceId: params.approvalReviewerDeviceId,
|
||||
agentDir,
|
||||
cwd: input.effectiveCwd ?? input.effectiveWorkspace,
|
||||
workspaceDir: input.effectiveWorkspace,
|
||||
@@ -593,9 +591,10 @@ export function resolveCodexAppServerExecutionCwd(params: {
|
||||
nativeToolSurfaceEnabled: boolean;
|
||||
remoteWorkspaceRoot?: string;
|
||||
}): string {
|
||||
const cwd = params.environment && params.nativeToolSurfaceEnabled
|
||||
? params.environment.cwd
|
||||
: params.effectiveCwd;
|
||||
const cwd =
|
||||
params.environment && params.nativeToolSurfaceEnabled
|
||||
? params.environment.cwd
|
||||
: params.effectiveCwd;
|
||||
return mapCodexAppServerRemoteWorkspacePath({
|
||||
value: cwd,
|
||||
localWorkspaceRoot: params.localWorkspaceRoot,
|
||||
|
||||
@@ -16,7 +16,7 @@ on a model or provider entry; `auto` never picks it. PI remains the default
|
||||
embedded runtime.
|
||||
|
||||
See [GitHub Copilot agent runtime](../../docs/plugins/copilot.md) for
|
||||
configuration, doctor probes, transcript mirroring, compaction, side
|
||||
configuration, the doctor contract, transcript mirroring, compaction, side
|
||||
questions, replay, and the supported-surface contract.
|
||||
See [qa/copilot-capabilities.md](../../qa/copilot-capabilities.md)
|
||||
for the SDK capability inventory the harness is pinned to.
|
||||
|
||||
@@ -11,14 +11,6 @@
|
||||
* fields exist for copilot yet; the array is empty by design
|
||||
* and normalizeCompatibilityConfig is a structural no-op so
|
||||
* future retirements have a stable in-tree home.
|
||||
*
|
||||
* The deeper runtime probes (copilot CLI version, copilot auth,
|
||||
* copilotHome writability) live in {@link ./src/doctor-probes.ts}
|
||||
* because they have side effects (subprocess spawn, fs touch) and
|
||||
* need to be invoked imperatively, not declaratively, from the
|
||||
* doctor command. They are exported separately so callers can opt
|
||||
* in. Auto-discovery of doctor-contract-api.ts at the plugin root
|
||||
* keeps this file purely declarative.
|
||||
*/
|
||||
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
// Copilot tests cover doctor probes plugin behavior.
|
||||
import { EventEmitter } from "node:events";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
probeCopilotAuthShape,
|
||||
probeCopilotCliVersion,
|
||||
probeCopilotHomeWritable,
|
||||
} from "./doctor-probes.js";
|
||||
|
||||
type FakeChildOptions = {
|
||||
exitCode?: number | null;
|
||||
signal?: NodeJS.Signals | null;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
emitErrorMessage?: string;
|
||||
/** When true, never emits close; useful for timeout tests. */
|
||||
hang?: boolean;
|
||||
};
|
||||
|
||||
function makeFakeChild(opts: FakeChildOptions = {}) {
|
||||
const emitter = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
kill: () => void;
|
||||
};
|
||||
emitter.stdout = new EventEmitter();
|
||||
emitter.stderr = new EventEmitter();
|
||||
emitter.kill = vi.fn();
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (opts.stdout) {
|
||||
emitter.stdout.emit("data", Buffer.from(opts.stdout, "utf8"));
|
||||
}
|
||||
if (opts.stderr) {
|
||||
emitter.stderr.emit("data", Buffer.from(opts.stderr, "utf8"));
|
||||
}
|
||||
if (opts.emitErrorMessage) {
|
||||
emitter.emit("error", new Error(opts.emitErrorMessage));
|
||||
return;
|
||||
}
|
||||
if (!opts.hang) {
|
||||
emitter.emit("close", opts.exitCode ?? 0, opts.signal ?? null);
|
||||
}
|
||||
});
|
||||
|
||||
return emitter;
|
||||
}
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function makeTempHome(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-copilot-doctor-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("probeCopilotCliVersion", () => {
|
||||
it("reports ok with trimmed version on exit 0 with stdout", async () => {
|
||||
const result = await probeCopilotCliVersion({
|
||||
spawnFn: () => makeFakeChild({ stdout: " 1.2.3 \n" }) as never,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.version).toBe("1.2.3");
|
||||
expect(result.command).toBe("copilot");
|
||||
}
|
||||
});
|
||||
|
||||
it("uses custom command and args when provided", async () => {
|
||||
const calls: Array<{ cmd: string; args: string[] }> = [];
|
||||
const result = await probeCopilotCliVersion({
|
||||
command: "my-copilot",
|
||||
args: ["-V"],
|
||||
spawnFn: ((cmd: string, args: readonly string[]) => {
|
||||
calls.push({ cmd, args: [...args] });
|
||||
return makeFakeChild({ stdout: "9.9.9" }) as never;
|
||||
}) as never,
|
||||
});
|
||||
expect(calls).toEqual([{ cmd: "my-copilot", args: ["-V"] }]);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.command).toBe("my-copilot");
|
||||
}
|
||||
});
|
||||
|
||||
it("reports non-zero-exit with stderr details", async () => {
|
||||
const result = await probeCopilotCliVersion({
|
||||
spawnFn: () => makeFakeChild({ exitCode: 2, stderr: "boom: not installed" }) as never,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("non-zero-exit");
|
||||
expect(result.details?.exitCode).toBe(2);
|
||||
expect(result.details?.stderr).toBe("boom: not installed");
|
||||
}
|
||||
});
|
||||
|
||||
it("reports empty-version when exit 0 produces no stdout", async () => {
|
||||
const result = await probeCopilotCliVersion({
|
||||
spawnFn: () => makeFakeChild({ stdout: " \n" }) as never,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("empty-version");
|
||||
}
|
||||
});
|
||||
|
||||
it("reports spawn-failed when spawnFn throws synchronously (e.g. ENOENT)", async () => {
|
||||
const result = await probeCopilotCliVersion({
|
||||
spawnFn: (() => {
|
||||
throw new Error("ENOENT: copilot not found");
|
||||
}) as never,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("spawn-failed");
|
||||
expect(result.details?.rawError).toContain("ENOENT");
|
||||
}
|
||||
});
|
||||
|
||||
it("reports spawn-error when child emits 'error'", async () => {
|
||||
const result = await probeCopilotCliVersion({
|
||||
spawnFn: () => makeFakeChild({ emitErrorMessage: "spawn ENOEXEC" }) as never,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("spawn-error");
|
||||
expect(result.details?.rawError).toBe("spawn ENOEXEC");
|
||||
}
|
||||
});
|
||||
|
||||
it("reports probe-timeout when child hangs past timeoutMs and kills the child", async () => {
|
||||
const fakeChild = makeFakeChild({ hang: true });
|
||||
const result = await probeCopilotCliVersion({
|
||||
timeoutMs: 10,
|
||||
spawnFn: () => fakeChild as never,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("probe-timeout");
|
||||
expect(result.details?.timeoutMs).toBe(10);
|
||||
}
|
||||
expect(fakeChild.kill).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns just the first non-empty line as version when stdout has a banner / update hint", async () => {
|
||||
const result = await probeCopilotCliVersion({
|
||||
spawnFn: () =>
|
||||
makeFakeChild({
|
||||
stdout: "GitHub Copilot CLI 1.0.48.\nRun 'copilot update' to check for updates.\n",
|
||||
}) as never,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.version).toBe("GitHub Copilot CLI 1.0.48.");
|
||||
expect(result.rawStdout).toBe(
|
||||
"GitHub Copilot CLI 1.0.48.\nRun 'copilot update' to check for updates.",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not surface rawStdout when stdout is already single-line", async () => {
|
||||
const result = await probeCopilotCliVersion({
|
||||
spawnFn: () => makeFakeChild({ stdout: "1.2.3\n" }) as never,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.version).toBe("1.2.3");
|
||||
expect(result.rawStdout).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeCopilotHomeWritable", () => {
|
||||
it("reports ok when the directory exists and is writable, cleaning up after itself", async () => {
|
||||
const home = await makeTempHome();
|
||||
const result = await probeCopilotHomeWritable(home);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.copilotHome).toBe(home);
|
||||
expect(result.probedPath.startsWith(home)).toBe(true);
|
||||
}
|
||||
const entries = await fs.readdir(home);
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("creates copilotHome if missing", async () => {
|
||||
const root = await makeTempHome();
|
||||
const home = path.join(root, "nested", "copilot-cfg");
|
||||
const result = await probeCopilotHomeWritable(home);
|
||||
expect(result.ok).toBe(true);
|
||||
const stat = await fs.stat(home);
|
||||
expect(stat.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it("reports copilothome-not-writable when fs throws on mkdir", async () => {
|
||||
const result = await probeCopilotHomeWritable("/some/path", {
|
||||
fsApi: {
|
||||
mkdir: vi.fn().mockRejectedValueOnce(new Error("EPERM: not permitted")),
|
||||
writeFile: vi.fn(),
|
||||
rm: vi.fn(),
|
||||
} as never,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("copilothome-not-writable");
|
||||
expect(result.details?.rawError).toContain("EPERM");
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to the platform default copilotHome when argument is empty or whitespace", async () => {
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined);
|
||||
const result = await probeCopilotHomeWritable(" ", {
|
||||
fsApi: {
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
writeFile,
|
||||
rm: vi.fn().mockResolvedValue(undefined),
|
||||
} as never,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.copilotHome.length).toBeGreaterThan(0);
|
||||
expect(result.copilotHome.toLowerCase()).toContain("copilot");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeCopilotAuthShape", () => {
|
||||
it("resolves to useLoggedInUser when the flag is true", () => {
|
||||
const result = probeCopilotAuthShape({ useLoggedInUser: true });
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.resolvedMode).toBe("useLoggedInUser");
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves to gitHubToken when a non-empty token is supplied", () => {
|
||||
const result = probeCopilotAuthShape({ gitHubToken: "ghp_xxx" });
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.resolvedMode).toBe("gitHubToken");
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves to profile when both profileId and profileVersion are supplied", () => {
|
||||
const result = probeCopilotAuthShape({ profileId: "p1", profileVersion: "v1" });
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.resolvedMode).toBe("profile");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects when no auth source is provided", () => {
|
||||
const result = probeCopilotAuthShape({});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("no-auth-source");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects when only one of profileId / profileVersion is provided", () => {
|
||||
expect(probeCopilotAuthShape({ profileId: "p1" }).ok).toBe(false);
|
||||
expect(probeCopilotAuthShape({ profileVersion: "v1" }).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects useLoggedInUser:false on its own", () => {
|
||||
const result = probeCopilotAuthShape({ useLoggedInUser: false });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an empty gitHubToken string", () => {
|
||||
const result = probeCopilotAuthShape({ gitHubToken: "" });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,260 +0,0 @@
|
||||
/**
|
||||
* Runtime doctor probes for the copilot extension.
|
||||
*
|
||||
* Imperative side-effecting checks used to diagnose a copilot
|
||||
* deployment from within `openclaw doctor` (or any equivalent
|
||||
* harness-side health check). Kept out of doctor-contract-api.ts
|
||||
* because that contract is declarative and auto-loaded by the
|
||||
* plugin registry, whereas these probes spawn subprocesses or
|
||||
* touch the filesystem and must be invoked imperatively.
|
||||
*
|
||||
* All probes are pure (no module-level state) and dependency-
|
||||
* injectable for tests. They never throw on a probe-negative
|
||||
* result — failure is surfaced via the `ok: false` shape so the
|
||||
* caller can render a structured doctor report.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export type ProbeResult<TPayload extends object = Record<string, never>> =
|
||||
| ({ ok: true } & TPayload)
|
||||
| { ok: false; reason: string; details?: Record<string, unknown> };
|
||||
|
||||
export interface ProbeCopilotCliVersionOptions {
|
||||
/** Command to invoke; defaults to "copilot". */
|
||||
command?: string;
|
||||
/** Argv used to ask for version; defaults to ["--version"]. */
|
||||
args?: readonly string[];
|
||||
/** Timeout in milliseconds; defaults to 5_000. */
|
||||
timeoutMs?: number;
|
||||
/** Injection seam for testing. Defaults to node:child_process spawn. */
|
||||
spawnFn?: typeof spawn;
|
||||
}
|
||||
|
||||
export interface ProbeCopilotHomeOptions {
|
||||
/** Injection seam for testing. */
|
||||
fsApi?: Pick<typeof fs, "mkdir" | "writeFile" | "rm">;
|
||||
/** Filename used for the writability probe. */
|
||||
probeFileName?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_PROBE_TIMEOUT_MS = 5_000;
|
||||
const DEFAULT_PROBE_FILENAME = ".copilot-doctor-probe";
|
||||
|
||||
/**
|
||||
* Probe that the Copilot CLI is installed and prints a version.
|
||||
* Treats non-zero exit, missing stdout, and timeout all as failures.
|
||||
*/
|
||||
export async function probeCopilotCliVersion(
|
||||
options: ProbeCopilotCliVersionOptions = {},
|
||||
): Promise<ProbeResult<{ version: string; command: string; rawStdout?: string }>> {
|
||||
const command = options.command ?? "copilot";
|
||||
const args = options.args ?? ["--version"];
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
|
||||
const spawnImpl = options.spawnFn ?? spawn;
|
||||
|
||||
return new Promise<ProbeResult<{ version: string; command: string; rawStdout?: string }>>(
|
||||
(resolve) => {
|
||||
let child: ReturnType<typeof spawn> | undefined;
|
||||
let settled = false;
|
||||
const settle = (
|
||||
result: ProbeResult<{ version: string; command: string; rawStdout?: string }>,
|
||||
): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
try {
|
||||
child?.kill();
|
||||
} catch {
|
||||
// ignore double-kill / already-dead errors
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
settle({
|
||||
ok: false,
|
||||
reason: "probe-timeout",
|
||||
details: { command, args: [...args], timeoutMs },
|
||||
});
|
||||
}, timeoutMs);
|
||||
|
||||
try {
|
||||
child = spawnImpl(command, [...args], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
} catch (error) {
|
||||
settle({
|
||||
ok: false,
|
||||
reason: "spawn-failed",
|
||||
details: { command, args: [...args], rawError: formatProbeError(error) },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString("utf8");
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
});
|
||||
child.on("error", (error: Error) => {
|
||||
settle({
|
||||
ok: false,
|
||||
reason: "spawn-error",
|
||||
details: { command, args: [...args], rawError: error.message },
|
||||
});
|
||||
});
|
||||
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
if (code !== 0) {
|
||||
settle({
|
||||
ok: false,
|
||||
reason: "non-zero-exit",
|
||||
details: {
|
||||
command,
|
||||
args: [...args],
|
||||
exitCode: code,
|
||||
signal,
|
||||
stderr: stderr.trim() || undefined,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
const rawStdout = stdout.trim();
|
||||
if (!rawStdout) {
|
||||
settle({
|
||||
ok: false,
|
||||
reason: "empty-version",
|
||||
details: { command, args: [...args] },
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Many version commands (notably the GitHub Copilot CLI's `copilot --version`)
|
||||
// print a banner plus an "update available" hint on subsequent
|
||||
// lines. Surface only the first non-empty line as `version` so the
|
||||
// doctor UI gets a clean string; keep the full stdout in
|
||||
// `rawStdout` for debugging.
|
||||
const version = firstNonEmptyLine(rawStdout) ?? rawStdout;
|
||||
const payload: { version: string; command: string; rawStdout?: string } = {
|
||||
version,
|
||||
command,
|
||||
};
|
||||
if (rawStdout !== version) {
|
||||
payload.rawStdout = rawStdout;
|
||||
}
|
||||
settle({ ok: true, ...payload });
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(value: string): string | undefined {
|
||||
for (const line of value.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length > 0) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe that copilotHome (or default ~/.config/copilot) is writable
|
||||
* by the running user. Mirrors the existing auth-bridge's expectation
|
||||
* that the SDK can persist credentials under copilotHome.
|
||||
*/
|
||||
export async function probeCopilotHomeWritable(
|
||||
copilotHome: string | undefined,
|
||||
options: ProbeCopilotHomeOptions = {},
|
||||
): Promise<ProbeResult<{ copilotHome: string; probedPath: string }>> {
|
||||
const fsApi = options.fsApi ?? fs;
|
||||
const probeFileName = options.probeFileName ?? DEFAULT_PROBE_FILENAME;
|
||||
const resolvedHome =
|
||||
typeof copilotHome === "string" && copilotHome.trim().length > 0
|
||||
? copilotHome.trim()
|
||||
: defaultCopilotHome();
|
||||
const probedPath = path.join(resolvedHome, probeFileName);
|
||||
|
||||
try {
|
||||
await fsApi.mkdir(resolvedHome, { recursive: true });
|
||||
await fsApi.writeFile(probedPath, "copilot-doctor-probe", "utf8");
|
||||
await fsApi.rm(probedPath, { force: true });
|
||||
return { ok: true, copilotHome: resolvedHome, probedPath };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "copilothome-not-writable",
|
||||
details: {
|
||||
copilotHome: resolvedHome,
|
||||
probedPath,
|
||||
rawError: formatProbeError(error),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe GitHub Copilot agent runtime auth resolution given a useLoggedInUser hint.
|
||||
* Validates that at least one of {useLoggedInUser, gitHubToken,
|
||||
* profileId+profileVersion} is set. This is intentionally a
|
||||
* shape-only probe: actually performing an SDK auth handshake
|
||||
* would require a pool and is out of scope for `openclaw doctor`.
|
||||
*/
|
||||
export function probeCopilotAuthShape(input: {
|
||||
useLoggedInUser?: boolean;
|
||||
gitHubToken?: string;
|
||||
profileId?: string;
|
||||
profileVersion?: string;
|
||||
}): ProbeResult<{ resolvedMode: "useLoggedInUser" | "gitHubToken" | "profile" }> {
|
||||
if (input.useLoggedInUser === true) {
|
||||
return { ok: true, resolvedMode: "useLoggedInUser" };
|
||||
}
|
||||
if (typeof input.gitHubToken === "string" && input.gitHubToken.length > 0) {
|
||||
return { ok: true, resolvedMode: "gitHubToken" };
|
||||
}
|
||||
if (
|
||||
typeof input.profileId === "string" &&
|
||||
input.profileId.length > 0 &&
|
||||
typeof input.profileVersion === "string" &&
|
||||
input.profileVersion.length > 0
|
||||
) {
|
||||
return { ok: true, resolvedMode: "profile" };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
reason: "no-auth-source",
|
||||
details: {
|
||||
hint: "Set useLoggedInUser:true, or gitHubToken, or both profileId+profileVersion",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function defaultCopilotHome(): string {
|
||||
// Mirrors the SDK convention; auth-bridge uses the same default.
|
||||
if (process.platform === "win32") {
|
||||
return path.join(process.env.APPDATA ?? os.homedir(), "copilot");
|
||||
}
|
||||
const xdg = process.env.XDG_CONFIG_HOME;
|
||||
if (xdg && xdg.length > 0) {
|
||||
return path.join(xdg, "copilot");
|
||||
}
|
||||
return path.join(os.homedir(), ".config", "copilot");
|
||||
}
|
||||
|
||||
function formatProbeError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
@@ -171,7 +171,7 @@ export class SqliteBackedMatrixSyncStore extends MemoryStore {
|
||||
|
||||
constructor(private readonly storageRootDir: string) {
|
||||
super();
|
||||
this.stateKey = resolveSyncCacheStateKey(storageRootDir);
|
||||
this.stateKey = SYNC_CACHE_STATE_KEY;
|
||||
|
||||
let restoredSavedSync: ISyncData | null = null;
|
||||
let restoredClientOptions: IStoredClientOpts | undefined;
|
||||
@@ -426,10 +426,6 @@ function openMatrixSyncCacheStore(
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSyncCacheStateKey(_storageRootDir: string): string {
|
||||
return SYNC_CACHE_STATE_KEY;
|
||||
}
|
||||
|
||||
function metaKey(stateKey: string): string {
|
||||
return `${stateKey}:meta`;
|
||||
}
|
||||
@@ -557,7 +553,7 @@ export async function hasMatrixSyncCacheStateInStore(params: {
|
||||
storageRootDir: string;
|
||||
store: Pick<PluginStateKeyedStore<MatrixSyncCacheRecord>, "lookup">;
|
||||
}): Promise<boolean> {
|
||||
const stateKey = resolveSyncCacheStateKey(params.storageRootDir);
|
||||
const stateKey = SYNC_CACHE_STATE_KEY;
|
||||
const meta = await params.store.lookup(metaKey(stateKey));
|
||||
if (!isSyncCacheMeta(meta) || meta.chunkCount <= 0) {
|
||||
return false;
|
||||
@@ -586,7 +582,7 @@ export async function writeMatrixSyncCacheStateToStore(params: {
|
||||
payload: PersistedMatrixSyncStore;
|
||||
store: MatrixSyncCacheAsyncStore;
|
||||
}): Promise<void> {
|
||||
const stateKey = resolveSyncCacheStateKey(params.storageRootDir);
|
||||
const stateKey = SYNC_CACHE_STATE_KEY;
|
||||
const rows = buildSyncCacheRows(stateKey, params.payload);
|
||||
for (const row of rows.chunks) {
|
||||
await params.store.register(row.key, row.value);
|
||||
|
||||
@@ -76,6 +76,32 @@ describe("resolveDefaultMattermostAccountId", () => {
|
||||
expect(listMattermostAccountIds(cfg)).toEqual(["default", "work"]);
|
||||
expect(resolveDefaultMattermostAccountId(cfg)).toBe("default");
|
||||
});
|
||||
|
||||
it("inherits top-level access policy for named accounts before doctor migration", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
dmPolicy: "open",
|
||||
groupPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
groupAllowFrom: ["*"],
|
||||
accounts: {
|
||||
tony: {
|
||||
botToken: "tok-tony",
|
||||
baseUrl: "https://chat.example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveMattermostAccount({ cfg, accountId: "tony" });
|
||||
|
||||
expect(account.config.dmPolicy).toBe("open");
|
||||
expect(account.config.groupPolicy).toBe("open");
|
||||
expect(account.config.allowFrom).toEqual(["*"]);
|
||||
expect(account.config.groupAllowFrom).toEqual(["*"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMattermostReplyToMode", () => {
|
||||
|
||||
@@ -108,15 +108,6 @@ describe("slash-commands", () => {
|
||||
).toEqual(["oc_model", "oc_models"]);
|
||||
});
|
||||
|
||||
it("registers the queue command mapped to the core /queue directive", () => {
|
||||
const queueSpec = DEFAULT_COMMAND_SPECS.find((spec) => spec.trigger === "oc_queue");
|
||||
expect(queueSpec?.originalName).toBe("queue");
|
||||
const triggerMap = new Map<string, string>([["oc_queue", "queue"]]);
|
||||
expect(resolveCommandText("oc_queue", " collect drop:summarize ", triggerMap)).toBe(
|
||||
"/queue collect drop:summarize",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes callback path in slash config", () => {
|
||||
const config = resolveSlashCommandConfig({ callbackPath: "api/channels/mattermost/command" });
|
||||
expect(config.callbackPath).toBe("/api/channels/mattermost/command");
|
||||
|
||||
@@ -172,13 +172,6 @@ export const DEFAULT_COMMAND_SPECS: MattermostCommandSpec[] = [
|
||||
autoComplete: true,
|
||||
autoCompleteHint: "[on|off]",
|
||||
},
|
||||
{
|
||||
trigger: "oc_queue",
|
||||
originalName: "queue",
|
||||
description: "Adjust active-run queue behavior",
|
||||
autoComplete: true,
|
||||
autoCompleteHint: "[steer|followup|collect|interrupt] [debounce:2s] [cap:N] [drop:old|new|summarize]",
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Command registration ────────────────────────────────────────────────────
|
||||
|
||||
@@ -17,7 +17,6 @@ import type { TSchema } from "typebox";
|
||||
import { configureMemoryCoreDreamingState } from "./src/dreaming-state.js";
|
||||
import { registerShortTermPromotionDreaming } from "./src/dreaming.js";
|
||||
import { buildMemoryFlushPlan } from "./src/flush-plan.js";
|
||||
import { registerBuiltInMemoryEmbeddingProviders } from "./src/memory/provider-adapters.js";
|
||||
import { buildPromptSection } from "./src/prompt-section.js";
|
||||
|
||||
type MemoryToolsModule = typeof import("./src/tools.js");
|
||||
@@ -185,7 +184,6 @@ export default definePluginEntry({
|
||||
configureMemoryCoreDreamingState(<T>(options: OpenKeyedStoreOptions) =>
|
||||
api.runtime.state.openKeyedStore<T>(options),
|
||||
);
|
||||
registerBuiltInMemoryEmbeddingProviders(api);
|
||||
registerShortTermPromotionDreaming(api);
|
||||
api.registerMemoryCapability({
|
||||
promptBuilder: buildPromptSection,
|
||||
|
||||
@@ -5,7 +5,6 @@ export {
|
||||
DEFAULT_LOCAL_MODEL,
|
||||
getBuiltinMemoryEmbeddingProviderDoctorMetadata,
|
||||
listBuiltinAutoSelectMemoryEmbeddingProviderDoctorMetadata,
|
||||
registerBuiltInMemoryEmbeddingProviders,
|
||||
} from "./src/memory/provider-adapters.js";
|
||||
export { createEmbeddingProvider } from "./src/memory/embeddings.js";
|
||||
export {
|
||||
|
||||
@@ -209,7 +209,6 @@ const LANGUAGE_STOP_WORDS = {
|
||||
"할",
|
||||
"해",
|
||||
"했다",
|
||||
"했다",
|
||||
],
|
||||
pathNoise: [
|
||||
"cjs",
|
||||
|
||||
@@ -151,14 +151,8 @@ function formatFallbackWriteFailure(err: unknown): string {
|
||||
return "unknown error";
|
||||
}
|
||||
|
||||
// Raw snippets and promotions are pre-processing memory staging fragments
|
||||
// (session metadata, conversation summaries, operational logs). They must never
|
||||
// be persisted to the human-readable dream diary. When narrative generation
|
||||
// fails, always fall back to a generic placeholder so no staging content leaks
|
||||
// into DREAMS.md.
|
||||
function buildRequestScopedFallbackNarrative(_data: NarrativePhaseData): string {
|
||||
return "A memory trace surfaced, but details were unavailable in this run.";
|
||||
}
|
||||
const REQUEST_SCOPED_FALLBACK_NARRATIVE =
|
||||
"A memory trace surfaced, but details were unavailable in this run.";
|
||||
|
||||
export async function appendFallbackNarrativeEntry(params: {
|
||||
workspaceDir: string;
|
||||
@@ -171,7 +165,9 @@ export async function appendFallbackNarrativeEntry(params: {
|
||||
try {
|
||||
await appendNarrativeEntry({
|
||||
workspaceDir: params.workspaceDir,
|
||||
narrative: buildRequestScopedFallbackNarrative(params.data),
|
||||
// Raw snippets and promotions are pre-processing memory staging fragments.
|
||||
// Keep fallback diary text generic so DREAMS.md never leaks staging content.
|
||||
narrative: REQUEST_SCOPED_FALLBACK_NARRATIVE,
|
||||
nowMs: params.nowMs,
|
||||
timezone: params.timezone,
|
||||
});
|
||||
|
||||
@@ -4,11 +4,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import {
|
||||
clearMemoryEmbeddingProviders as clearRegistry,
|
||||
listRegisteredMemoryEmbeddingProviderAdapters as listRegisteredAdapters,
|
||||
registerMemoryEmbeddingProvider as registerAdapter,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import { clearMemoryEmbeddingProviders as clearRegistry } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import { hashText } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
|
||||
import { resolveSessionTranscriptsDirForAgent } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
||||
import { resolveOpenClawAgentSqlitePath } from "openclaw/plugin-sdk/sqlite-runtime";
|
||||
@@ -25,7 +21,6 @@ import { splitSourceWideEmbeddingChunks } from "./manager-embedding-ops.js";
|
||||
import { LOCAL_EMBEDDING_WORKER_ERROR_CODES } from "./manager-local-worker-errors.js";
|
||||
import type { MemoryIndexMeta } from "./manager-reindex-state.js";
|
||||
import { closeMemoryIndexManagersForAgent, EMBEDDING_PROBE_CACHE_TTL_MS } from "./manager.js";
|
||||
import { registerBuiltInMemoryEmbeddingProviders } from "./provider-adapters.js";
|
||||
|
||||
// This suite performs real sqlite/media indexing and can exceed the global
|
||||
// timeout when it shares a packed CI extension shard.
|
||||
@@ -255,26 +250,6 @@ vi.mock("./embeddings.js", () => {
|
||||
};
|
||||
});
|
||||
|
||||
describe("memory embedding provider registration", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
clearRegistry();
|
||||
});
|
||||
|
||||
it("does not register a built-in local embedding provider", () => {
|
||||
clearRegistry();
|
||||
registerBuiltInMemoryEmbeddingProviders({ registerMemoryEmbeddingProvider: registerAdapter });
|
||||
|
||||
const adapter = listRegisteredAdapters().find((entry) => entry.id === "local");
|
||||
|
||||
expect(adapter).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("memory index", () => {
|
||||
let fixtureRoot = "";
|
||||
let workspaceDir = "";
|
||||
@@ -307,7 +282,6 @@ describe("memory index", () => {
|
||||
beforeEach(async () => {
|
||||
vi.useRealTimers();
|
||||
clearRegistry();
|
||||
registerBuiltInMemoryEmbeddingProviders({ registerMemoryEmbeddingProvider: registerAdapter });
|
||||
embedBatchCalls = 0;
|
||||
embedBatchInputCalls = 0;
|
||||
providerRuntimeBatchCalls = [];
|
||||
|
||||
@@ -154,16 +154,12 @@ vi.mock("./embeddings.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
clearMemoryEmbeddingProviders as clearRegistry,
|
||||
registerMemoryEmbeddingProvider as registerAdapter,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import { clearMemoryEmbeddingProviders as clearRegistry } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import {
|
||||
closeAllMemorySearchManagers,
|
||||
getMemorySearchManager,
|
||||
type MemoryIndexManager,
|
||||
} from "./index.js";
|
||||
import { registerBuiltInMemoryEmbeddingProviders } from "./provider-adapters.js";
|
||||
|
||||
describe("memory watcher config", () => {
|
||||
let manager: MemoryIndexManager | null = null;
|
||||
@@ -176,7 +172,6 @@ describe("memory watcher config", () => {
|
||||
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
|
||||
vi.clearAllMocks();
|
||||
clearRegistry();
|
||||
registerBuiltInMemoryEmbeddingProviders({ registerMemoryEmbeddingProvider: registerAdapter });
|
||||
nativeWatchMockFailingDir.current = null;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
// Memory Core tests cover provider adapter registration plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { filterUnregisteredMemoryEmbeddingProviderAdapters } from "./provider-adapter-registration.js";
|
||||
|
||||
describe("filterUnregisteredMemoryEmbeddingProviderAdapters", () => {
|
||||
it("keeps builtin adapters that are not already registered", () => {
|
||||
const adapters = filterUnregisteredMemoryEmbeddingProviderAdapters({
|
||||
builtinAdapters: [
|
||||
{ id: "local" },
|
||||
{ id: "openai" },
|
||||
{ id: "gemini" },
|
||||
{ id: "voyage" },
|
||||
{ id: "mistral" },
|
||||
],
|
||||
registeredAdapters: [],
|
||||
});
|
||||
|
||||
expect(adapters.map((adapter) => adapter.id)).toEqual([
|
||||
"local",
|
||||
"openai",
|
||||
"gemini",
|
||||
"voyage",
|
||||
"mistral",
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips builtin adapters that are already registered", () => {
|
||||
const adapters = filterUnregisteredMemoryEmbeddingProviderAdapters({
|
||||
builtinAdapters: [
|
||||
{ id: "local" },
|
||||
{ id: "openai" },
|
||||
{ id: "gemini" },
|
||||
{ id: "voyage" },
|
||||
{ id: "mistral" },
|
||||
],
|
||||
registeredAdapters: [{ id: "local" }, { id: "gemini" }],
|
||||
});
|
||||
|
||||
expect(adapters.map((adapter) => adapter.id)).toEqual(["openai", "voyage", "mistral"]);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
// Memory Core provider module implements model/runtime integration.
|
||||
type AdapterLike = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export function filterUnregisteredMemoryEmbeddingProviderAdapters<T extends AdapterLike>(params: {
|
||||
builtinAdapters: readonly T[];
|
||||
registeredAdapters: readonly AdapterLike[];
|
||||
}): T[] {
|
||||
const existingIds = new Set(params.registeredAdapters.map((adapter) => adapter.id));
|
||||
return params.builtinAdapters.filter((adapter) => !existingIds.has(adapter.id));
|
||||
}
|
||||
@@ -2,11 +2,9 @@
|
||||
import {
|
||||
DEFAULT_LOCAL_MODEL,
|
||||
listMemoryEmbeddingProviders,
|
||||
listRegisteredMemoryEmbeddingProviderAdapters,
|
||||
type MemoryEmbeddingProviderAdapter,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-embedding-registry";
|
||||
import { getProviderEnvVars } from "openclaw/plugin-sdk/provider-env-vars";
|
||||
import { filterUnregisteredMemoryEmbeddingProviderAdapters } from "./provider-adapter-registration.js";
|
||||
|
||||
export type BuiltinMemoryEmbeddingProviderDoctorMetadata = {
|
||||
providerId: string;
|
||||
@@ -16,8 +14,6 @@ export type BuiltinMemoryEmbeddingProviderDoctorMetadata = {
|
||||
autoSelectPriority?: number;
|
||||
};
|
||||
|
||||
const builtinMemoryEmbeddingProviderAdapters = [] as const;
|
||||
|
||||
export { DEFAULT_LOCAL_MODEL };
|
||||
|
||||
function getBuiltinMemoryEmbeddingProviderAdapter(
|
||||
@@ -26,20 +22,6 @@ function getBuiltinMemoryEmbeddingProviderAdapter(
|
||||
return listMemoryEmbeddingProviders().find((adapter) => adapter.id === id);
|
||||
}
|
||||
|
||||
export function registerBuiltInMemoryEmbeddingProviders(register: {
|
||||
registerMemoryEmbeddingProvider: (adapter: MemoryEmbeddingProviderAdapter) => void;
|
||||
}): void {
|
||||
// Only inspect providers already registered in the current load. Falling back
|
||||
// to capability discovery here can recursively trigger plugin loading while
|
||||
// memory-core itself is still registering.
|
||||
for (const adapter of filterUnregisteredMemoryEmbeddingProviderAdapters({
|
||||
builtinAdapters: builtinMemoryEmbeddingProviderAdapters,
|
||||
registeredAdapters: listRegisteredMemoryEmbeddingProviderAdapters(),
|
||||
})) {
|
||||
register.registerMemoryEmbeddingProvider(adapter);
|
||||
}
|
||||
}
|
||||
|
||||
export function getBuiltinMemoryEmbeddingProviderDoctorMetadata(
|
||||
providerId: string,
|
||||
): BuiltinMemoryEmbeddingProviderDoctorMetadata | null {
|
||||
|
||||
@@ -35,9 +35,12 @@ describe("initializeMemoryWikiVault", () => {
|
||||
await expect(fs.readFile(path.join(rootDir, "WIKI.md"), "utf8")).resolves.toContain(
|
||||
"Render mode: `obsidian`",
|
||||
);
|
||||
await expect(
|
||||
fs.readFile(path.join(rootDir, ".openclaw-wiki", "state.json"), "utf8"),
|
||||
).resolves.toContain('"renderMode": "obsidian"');
|
||||
await expect(fs.access(path.join(rootDir, ".openclaw-wiki", "state.json"))).rejects.toThrow(
|
||||
/ENOENT/,
|
||||
);
|
||||
await expect(fs.access(path.join(rootDir, ".openclaw-wiki", "locks"))).rejects.toThrow(
|
||||
/ENOENT/,
|
||||
);
|
||||
});
|
||||
|
||||
it("is idempotent when the vault already exists", async () => {
|
||||
|
||||
@@ -20,7 +20,6 @@ export const WIKI_VAULT_DIRECTORIES = [
|
||||
"_attachments",
|
||||
"_views",
|
||||
".openclaw-wiki",
|
||||
".openclaw-wiki/locks",
|
||||
".openclaw-wiki/cache",
|
||||
] as const;
|
||||
|
||||
@@ -125,22 +124,6 @@ export async function initializeMemoryWikiVault(
|
||||
withTrailingNewline("# Inbox\n\nDrop raw ideas, questions, and source links here.\n"),
|
||||
createdFiles,
|
||||
);
|
||||
await writeFileIfMissing(
|
||||
rootDir,
|
||||
".openclaw-wiki/state.json",
|
||||
withTrailingNewline(
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
createdAt: resolveMemoryWikiTimestamp(options?.nowMs),
|
||||
renderMode: config.vault.renderMode,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
),
|
||||
createdFiles,
|
||||
);
|
||||
await writeFileIfMissing(rootDir, ".openclaw-wiki/log.jsonl", "", createdFiles);
|
||||
|
||||
if (createdDirectories.length > 0 || createdFiles.length > 0) {
|
||||
|
||||
@@ -125,18 +125,6 @@ function formatFrontmatterValue(value: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an AST as "dirty" — useful for callers that mutate the AST
|
||||
* structurally and want emitMd() to re-render rather than round-trip.
|
||||
*
|
||||
* Currently a no-op flag — emitMd() decides based on `opts.mode`. Kept
|
||||
* as an extension point for a future invariant where the AST tracks
|
||||
* its own dirty state.
|
||||
*/
|
||||
export function markDirty(_ast: MdAst): void {
|
||||
// intentionally empty
|
||||
}
|
||||
|
||||
// Re-export the frontmatter type for convenience so tests don't need
|
||||
// to import from ast.ts.
|
||||
export type { FrontmatterEntry };
|
||||
|
||||
@@ -73,7 +73,7 @@ export type { JsonlParseResult } from "./jsonl/parse.js";
|
||||
export type { YamlParseResult } from "./yaml/parse.js";
|
||||
|
||||
export type { EmitOptions } from "./emit.js";
|
||||
export { emitMd, markDirty } from "./emit.js";
|
||||
export { emitMd } from "./emit.js";
|
||||
export type { JsoncEmitOptions } from "./jsonc/emit.js";
|
||||
export { emitJsonc } from "./jsonc/emit.js";
|
||||
export type { JsonlEmitOptions } from "./jsonl/emit.js";
|
||||
|
||||
@@ -60,6 +60,20 @@ async function pollQaBus(params: {
|
||||
return (await response.json()) as QaBusPollResult;
|
||||
}
|
||||
|
||||
async function postQaBusJson(baseUrl: string, path: string, body: unknown) {
|
||||
return await postQaBusRawJson(baseUrl, path, JSON.stringify(body));
|
||||
}
|
||||
|
||||
async function postQaBusRawJson(baseUrl: string, path: string, body: string) {
|
||||
return await fetch(`${baseUrl}${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe("closeQaHttpServer", () => {
|
||||
it("closes idle keep-alive sockets so suite processes can exit", async () => {
|
||||
const server = createServer((_req, res) => {
|
||||
@@ -122,6 +136,85 @@ describe("qa-bus server", () => {
|
||||
kind: "inbound-message",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed poll numeric fields before long-polling", async () => {
|
||||
const state = createQaBusState();
|
||||
const bus = await startQaBusServer({ state });
|
||||
stops.push(bus["stop"]);
|
||||
|
||||
const startedAt = Date.now();
|
||||
const response = await postQaBusJson(bus.baseUrl, "/v1/poll", {
|
||||
accountId: "acct-a",
|
||||
cursor: "999",
|
||||
timeoutMs: 500,
|
||||
});
|
||||
|
||||
expect(Date.now() - startedAt).toBeLessThan(300);
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
error: "poll cursor must be an integer at least 0.",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed search limits before querying state", async () => {
|
||||
const state = createQaBusState();
|
||||
const bus = await startQaBusServer({ state });
|
||||
stops.push(bus["stop"]);
|
||||
|
||||
const response = await postQaBusJson(bus.baseUrl, "/v1/actions/search", {
|
||||
limit: "all",
|
||||
query: "anything",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
error: "search limit must be an integer at least 1.",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps oversized numeric poll and search fields bounded", async () => {
|
||||
const state = createQaBusState();
|
||||
const bus = await startQaBusServer({ state });
|
||||
stops.push(bus["stop"]);
|
||||
|
||||
const message = state.addInboundMessage({
|
||||
accountId: "acct-a",
|
||||
conversation: { id: "target", kind: "direct" },
|
||||
senderId: "acct-a-user",
|
||||
text: "bounded numeric fields",
|
||||
});
|
||||
|
||||
const pollResponse = await postQaBusJson(bus.baseUrl, "/v1/poll", {
|
||||
accountId: "acct-a",
|
||||
cursor: 0,
|
||||
limit: 10_000,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
expect(pollResponse.status).toBe(200);
|
||||
await expect(pollResponse.json()).resolves.toMatchObject({
|
||||
events: [{ message: { id: message.id } }],
|
||||
});
|
||||
|
||||
const searchResponse = await postQaBusJson(bus.baseUrl, "/v1/actions/search", {
|
||||
accountId: "acct-a",
|
||||
limit: 10_000,
|
||||
query: "bounded",
|
||||
});
|
||||
expect(searchResponse.status).toBe(200);
|
||||
await expect(searchResponse.json()).resolves.toMatchObject({
|
||||
messages: [{ id: message.id }],
|
||||
});
|
||||
|
||||
const extremeSearchResponse = await postQaBusRawJson(
|
||||
bus.baseUrl,
|
||||
"/v1/actions/search",
|
||||
`{"accountId":"acct-a","limit":1e309,"query":"bounded"}`,
|
||||
);
|
||||
expect(extremeSearchResponse.status).toBe(200);
|
||||
await expect(extremeSearchResponse.json()).resolves.toMatchObject({
|
||||
messages: [{ id: message.id }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleQaBusRequest", () => {
|
||||
|
||||
@@ -23,6 +23,9 @@ import type {
|
||||
|
||||
const QA_HTTP_JSON_MAX_BODY_BYTES = 1024 * 1024;
|
||||
const QA_HTTP_JSON_BODY_TIMEOUT_MS = 5_000;
|
||||
const QA_BUS_POLL_TIMEOUT_MAX_MS = 30_000;
|
||||
const QA_BUS_POLL_LIMIT_MAX = 500;
|
||||
const QA_BUS_SEARCH_LIMIT_MAX = 100;
|
||||
|
||||
export async function readQaJsonBody(req: IncomingMessage): Promise<unknown> {
|
||||
const text = (
|
||||
@@ -57,6 +60,66 @@ export function writeQaRequestBodyLimitError(res: ServerResponse, error: unknown
|
||||
return true;
|
||||
}
|
||||
|
||||
function readOptionalIntegerField(
|
||||
input: Record<string, unknown>,
|
||||
field: string,
|
||||
opts: {
|
||||
label: string;
|
||||
max?: number;
|
||||
min: number;
|
||||
},
|
||||
): number | undefined {
|
||||
const value = input[field];
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== "number" || value < opts.min) {
|
||||
throw new Error(`${opts.label} must be an integer at least ${opts.min}.`);
|
||||
}
|
||||
if (opts.max !== undefined && value > opts.max) {
|
||||
return opts.max;
|
||||
}
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new Error(`${opts.label} must be an integer at least ${opts.min}.`);
|
||||
}
|
||||
return opts.max === undefined ? value : Math.min(value, opts.max);
|
||||
}
|
||||
|
||||
function normalizeQaBusPollInput(input: Record<string, unknown>): QaBusPollInput {
|
||||
const cursor = readOptionalIntegerField(input, "cursor", {
|
||||
label: "poll cursor",
|
||||
min: 0,
|
||||
});
|
||||
const limit = readOptionalIntegerField(input, "limit", {
|
||||
label: "poll limit",
|
||||
max: QA_BUS_POLL_LIMIT_MAX,
|
||||
min: 1,
|
||||
});
|
||||
const timeoutMs = readOptionalIntegerField(input, "timeoutMs", {
|
||||
label: "poll timeoutMs",
|
||||
max: QA_BUS_POLL_TIMEOUT_MAX_MS,
|
||||
min: 0,
|
||||
});
|
||||
return {
|
||||
...input,
|
||||
...(cursor !== undefined ? { cursor } : {}),
|
||||
...(limit !== undefined ? { limit } : {}),
|
||||
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
||||
} as QaBusPollInput;
|
||||
}
|
||||
|
||||
function normalizeQaBusSearchInput(input: Record<string, unknown>): QaBusSearchMessagesInput {
|
||||
const limit = readOptionalIntegerField(input, "limit", {
|
||||
label: "search limit",
|
||||
max: QA_BUS_SEARCH_LIMIT_MAX,
|
||||
min: 1,
|
||||
});
|
||||
return {
|
||||
...input,
|
||||
...(limit !== undefined ? { limit } : {}),
|
||||
} as QaBusSearchMessagesInput;
|
||||
}
|
||||
|
||||
export async function closeQaHttpServer(server: Server): Promise<void> {
|
||||
let forceCloseTimer: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
@@ -146,12 +209,12 @@ export async function handleQaBusRequest(params: {
|
||||
return true;
|
||||
case "/v1/actions/search":
|
||||
writeJson(params.res, 200, {
|
||||
messages: params.state.searchMessages(body as unknown as QaBusSearchMessagesInput),
|
||||
messages: params.state.searchMessages(normalizeQaBusSearchInput(body)),
|
||||
});
|
||||
return true;
|
||||
case "/v1/poll": {
|
||||
const input = body as unknown as QaBusPollInput;
|
||||
const timeoutMs = Math.max(0, Math.min(input.timeoutMs ?? 0, 30_000));
|
||||
const input = normalizeQaBusPollInput(body);
|
||||
const timeoutMs = input.timeoutMs ?? 0;
|
||||
const accountId = normalizeAccountId(input.accountId);
|
||||
const initial = params.state.poll(input);
|
||||
const effectiveStartCursor = resolveQaBusPollStartCursor({
|
||||
|
||||
@@ -719,6 +719,54 @@ describe("qa confidence report", () => {
|
||||
expect(report.lanes[0]?.details).toContain("count/scenario mismatch");
|
||||
});
|
||||
|
||||
it("treats impossible suite counts as unknown", async () => {
|
||||
for (const [artifact, expectedDetail] of [
|
||||
[
|
||||
{ counts: { total: 1, passed: -1, skipped: 0, failed: 0 } },
|
||||
"counts.passed must be a non-negative integer",
|
||||
],
|
||||
[
|
||||
{ counts: { total: 1, passed: 2, failed: 0 } },
|
||||
"counts.total=1 is less than provided count sum=2",
|
||||
],
|
||||
[
|
||||
{ counts: { total: 1, skipped: 2, failed: 0 } },
|
||||
"counts.total=1 is less than provided count sum=2",
|
||||
],
|
||||
[
|
||||
{ counts: { total: 5, passed: 2, skipped: 2, failed: 0 } },
|
||||
"counts.total=5 does not match counts.passed+counts.failed+counts.skipped=4",
|
||||
],
|
||||
] as const) {
|
||||
await writeJson("live/qa-suite-summary.json", artifact);
|
||||
|
||||
const report = await buildQaConfidenceReport({
|
||||
manifest: {
|
||||
version: 1,
|
||||
profile: "codex-100",
|
||||
lanes: [
|
||||
{
|
||||
id: "first-hour-live",
|
||||
title: "First hour live",
|
||||
kind: "qa-suite-summary",
|
||||
artifact: "live/qa-suite-summary.json",
|
||||
required: true,
|
||||
failureVerdict: "qa-harness-bug",
|
||||
},
|
||||
],
|
||||
},
|
||||
artifactRoot: tempRoot,
|
||||
strictZeroUnknowns: true,
|
||||
generatedAt: "2026-05-13T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(report.pass).toBe(false);
|
||||
expect(report.counts).toMatchObject({ failed: 0, unknown: 1 });
|
||||
expect(report.lanes[0]).toMatchObject({ status: "unknown" });
|
||||
expect(report.lanes[0]?.details).toContain(expectedDetail);
|
||||
}
|
||||
});
|
||||
|
||||
it("requires generic summary lanes to expose an explicit pass signal", async () => {
|
||||
await writeJson("runtime/qa-runtime-parity-summary.json", {});
|
||||
|
||||
|
||||
@@ -152,6 +152,10 @@ function readNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function readCount(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function readBoolean(value: unknown): boolean | undefined {
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
@@ -371,9 +375,44 @@ function evaluateQaSuiteSummary(payload: unknown): QaConfidenceLaneEvaluation {
|
||||
};
|
||||
}
|
||||
const counts = isRecord(payload.counts) ? payload.counts : undefined;
|
||||
const totalCount = readNumber(counts?.total);
|
||||
const passedCount = readNumber(counts?.passed);
|
||||
const failedCount = readNumber(counts?.failed);
|
||||
for (const key of ["total", "passed", "failed", "skipped"] as const) {
|
||||
if (counts && Object.hasOwn(counts, key) && readCount(counts[key]) === undefined) {
|
||||
return {
|
||||
passed: false,
|
||||
status: "unknown",
|
||||
details: `qa-suite-summary counts.${key} must be a non-negative integer`,
|
||||
};
|
||||
}
|
||||
}
|
||||
const totalCount = readCount(counts?.total);
|
||||
const passedCount = readCount(counts?.passed);
|
||||
const failedCount = readCount(counts?.failed);
|
||||
const explicitSkippedCount = readCount(counts?.skipped);
|
||||
if (totalCount !== undefined) {
|
||||
const providedCountSum =
|
||||
(passedCount ?? 0) + (failedCount ?? 0) + (explicitSkippedCount ?? 0);
|
||||
if (totalCount < providedCountSum) {
|
||||
return {
|
||||
passed: false,
|
||||
status: "unknown",
|
||||
details: `qa-suite-summary counts.total=${totalCount} is less than provided count sum=${providedCountSum}`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
passedCount !== undefined &&
|
||||
failedCount !== undefined &&
|
||||
explicitSkippedCount !== undefined &&
|
||||
totalCount !== providedCountSum
|
||||
) {
|
||||
return {
|
||||
passed: false,
|
||||
status: "unknown",
|
||||
details: `qa-suite-summary counts.total=${totalCount} does not match counts.passed+counts.failed+counts.skipped=${
|
||||
providedCountSum
|
||||
}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
const scenarios = Array.isArray(payload.scenarios) ? payload.scenarios : undefined;
|
||||
const failedScenarios = scenarios?.filter(
|
||||
(scenario) => isRecord(scenario) && scenario.status === "fail",
|
||||
@@ -446,7 +485,6 @@ function evaluateQaSuiteSummary(payload: unknown): QaConfidenceLaneEvaluation {
|
||||
details: `qa-suite-summary has ${unknownBlockingScenarioCount} scenario row(s) with unsupported non-pass status`,
|
||||
};
|
||||
}
|
||||
const explicitSkippedCount = readNumber(counts?.skipped);
|
||||
const inferredSkippedCount =
|
||||
totalCount === undefined || passedCount === undefined
|
||||
? undefined
|
||||
|
||||
@@ -279,6 +279,63 @@ describe("runQaDockerUp", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects explicit host port collisions before touching Docker", async () => {
|
||||
const calls: string[] = [];
|
||||
const outputDir = await mkdtemp(path.join(os.tmpdir(), "qa-docker-up-"));
|
||||
|
||||
try {
|
||||
await expect(
|
||||
runQaDockerUp(
|
||||
{
|
||||
repoRoot: "/repo/openclaw",
|
||||
outputDir,
|
||||
gatewayPort: 43124,
|
||||
qaLabPort: 43124,
|
||||
skipUiBuild: true,
|
||||
usePrebuiltImage: true,
|
||||
},
|
||||
createHealthyDockerDeps(calls),
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"QA Lab gateway and UI host ports must be different. Both resolved to 43124.",
|
||||
);
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
} finally {
|
||||
await rm(outputDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects resolved host port collisions before writing the harness", async () => {
|
||||
const outputDir = await mkdtemp(path.join(os.tmpdir(), "qa-docker-up-"));
|
||||
const resolveHostPort = vi.fn(async () => 28001);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
runQaDockerUp(
|
||||
{
|
||||
repoRoot: "/repo/openclaw",
|
||||
outputDir,
|
||||
skipUiBuild: true,
|
||||
usePrebuiltImage: true,
|
||||
},
|
||||
{
|
||||
...createHealthyDockerDeps([]),
|
||||
resolveHostPortImpl: resolveHostPort,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"QA Lab gateway and UI host ports must be different. Both resolved to 28001.",
|
||||
);
|
||||
|
||||
await expect(readFile(path.join(outputDir, "docker-compose.qa.yml"), "utf8")).rejects.toThrow(
|
||||
"ENOENT",
|
||||
);
|
||||
} finally {
|
||||
await rm(outputDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to the container IP when the host gateway port is unreachable", async () => {
|
||||
const calls: string[] = [];
|
||||
const fetchCalls: string[] = [];
|
||||
|
||||
@@ -41,7 +41,11 @@ async function isQaLabDockerHealthReachable(url: string, fetchImpl: FetchLike) {
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingCommandError(error: unknown, command: string, seen = new Set<unknown>()): boolean {
|
||||
function isMissingCommandError(
|
||||
error: unknown,
|
||||
command: string,
|
||||
seen = new Set<unknown>(),
|
||||
): boolean {
|
||||
if (!error || seen.has(error)) {
|
||||
return false;
|
||||
}
|
||||
@@ -99,6 +103,11 @@ export async function runQaDockerUp(
|
||||
params.gatewayPort != null,
|
||||
);
|
||||
const qaLabPort = await resolveHostPortImpl(params.qaLabPort ?? 43124, params.qaLabPort != null);
|
||||
if (gatewayPort === qaLabPort) {
|
||||
throw new Error(
|
||||
`QA Lab gateway and UI host ports must be different. Both resolved to ${gatewayPort}.`,
|
||||
);
|
||||
}
|
||||
const runCommand = deps?.runCommand ?? execCommand;
|
||||
const fetchImpl = deps?.fetchImpl ?? fetchHealthUrl;
|
||||
const sleepImpl = deps?.sleepImpl ?? sleep;
|
||||
|
||||
@@ -1006,7 +1006,11 @@ describe("buildQaRuntimeEnv", () => {
|
||||
|
||||
it("force-kills Windows gateway process trees when graceful taskkill fails", () => {
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
const originalSystemRoot = process.env.SystemRoot;
|
||||
const originalWindir = process.env.WINDIR;
|
||||
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
|
||||
process.env.SystemRoot = "C:\\Windows";
|
||||
delete process.env.WINDIR;
|
||||
try {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
pid: 12345,
|
||||
@@ -1025,11 +1029,12 @@ describe("buildQaRuntimeEnv", () => {
|
||||
runTaskkill,
|
||||
);
|
||||
|
||||
expect(runTaskkill).toHaveBeenNthCalledWith(1, "taskkill", ["/PID", "12345", "/T"], {
|
||||
const taskkillPath = path.win32.join("C:\\Windows", "System32", "taskkill.exe");
|
||||
expect(runTaskkill).toHaveBeenNthCalledWith(1, taskkillPath, ["/PID", "12345", "/T"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
expect(runTaskkill).toHaveBeenNthCalledWith(2, "taskkill", ["/PID", "12345", "/T", "/F"], {
|
||||
expect(runTaskkill).toHaveBeenNthCalledWith(2, taskkillPath, ["/PID", "12345", "/T", "/F"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
@@ -1038,6 +1043,16 @@ describe("buildQaRuntimeEnv", () => {
|
||||
if (platformDescriptor) {
|
||||
Object.defineProperty(process, "platform", platformDescriptor);
|
||||
}
|
||||
if (originalSystemRoot === undefined) {
|
||||
delete process.env.SystemRoot;
|
||||
} else {
|
||||
process.env.SystemRoot = originalSystemRoot;
|
||||
}
|
||||
if (originalWindir === undefined) {
|
||||
delete process.env.WINDIR;
|
||||
} else {
|
||||
process.env.WINDIR = originalWindir;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ import { stageQaMockAuthProfiles } from "./providers/shared/mock-auth.js";
|
||||
import { seedQaAgentWorkspace } from "./qa-agent-workspace.js";
|
||||
import { buildQaGatewayConfig, type QaThinkingLevel } from "./qa-gateway-config.js";
|
||||
import type { QaTransportAdapter } from "./qa-transport.js";
|
||||
import { resolveQaWindowsSystem32ExePath } from "./windows-system-tools.js";
|
||||
|
||||
export type { QaCliBackendAuthMode } from "./providers/env.js";
|
||||
const QA_GATEWAY_CHILD_STARTUP_MAX_ATTEMPTS = 5;
|
||||
@@ -398,11 +399,12 @@ function signalQaGatewayWindowsProcessTree(
|
||||
signal: NodeJS.Signals,
|
||||
runTaskkill: QaGatewayTaskkillRunner = spawnSync,
|
||||
) {
|
||||
const taskkillPath = resolveQaWindowsSystem32ExePath("taskkill.exe");
|
||||
const args = ["/PID", String(pid), "/T"];
|
||||
if (signal === "SIGKILL") {
|
||||
args.push("/F");
|
||||
}
|
||||
const result = runTaskkill("taskkill", args, {
|
||||
const result = runTaskkill(taskkillPath, args, {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
@@ -410,7 +412,7 @@ function signalQaGatewayWindowsProcessTree(
|
||||
return true;
|
||||
}
|
||||
if (signal !== "SIGKILL") {
|
||||
const forceResult = runTaskkill("taskkill", [...args, "/F"], {
|
||||
const forceResult = runTaskkill(taskkillPath, [...args, "/F"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
@@ -90,9 +90,12 @@ function listUiAssetFiles(rootDir: string, currentDir = rootDir): string[] {
|
||||
return files;
|
||||
}
|
||||
|
||||
export function resolveUiAssetVersion(overrideDir?: string | null): string | null {
|
||||
export function resolveUiAssetVersion(
|
||||
overrideDir?: string | null,
|
||||
repoRoot = process.cwd(),
|
||||
): string | null {
|
||||
try {
|
||||
const distDir = resolveUiDistDir(overrideDir);
|
||||
const distDir = resolveUiDistDir(overrideDir, repoRoot);
|
||||
const indexPath = path.join(distDir, "index.html");
|
||||
if (!fs.existsSync(indexPath) || !fs.statSync(indexPath).isFile()) {
|
||||
return null;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
writeQaLabServerError,
|
||||
type QaLabServerStartParams,
|
||||
} from "./lab-server.js";
|
||||
import { resolveUiAssetVersion } from "./lab-server-ui.js";
|
||||
|
||||
const qaChannelMock = vi.hoisted(() => ({
|
||||
resolveAccount: vi.fn(),
|
||||
@@ -557,6 +558,37 @@ describe("qa-lab server", () => {
|
||||
expect(getResponse.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
expect(await getResponse.text()).toBe("streamed body\n");
|
||||
|
||||
const indexedArtifactUrl = new URL("/api/evidence/artifact", lab.baseUrl);
|
||||
indexedArtifactUrl.searchParams.set(
|
||||
"evidencePath",
|
||||
".artifacts/qa-e2e/server/qa-evidence.json",
|
||||
);
|
||||
indexedArtifactUrl.searchParams.set("entryIndex", "0");
|
||||
indexedArtifactUrl.searchParams.set("artifactIndex", "0");
|
||||
const indexedResponse = await fetchWithRetry(indexedArtifactUrl.toString());
|
||||
expect(indexedResponse.status).toBe(200);
|
||||
expect(await indexedResponse.text()).toBe("streamed body\n");
|
||||
|
||||
const hexIndexUrl = new URL(indexedArtifactUrl);
|
||||
hexIndexUrl.searchParams.set("entryIndex", "0x0");
|
||||
const hexIndexResponse = await fetchWithRetry(hexIndexUrl.toString());
|
||||
expect(hexIndexResponse.status).toBe(400);
|
||||
|
||||
const exponentIndexUrl = new URL(indexedArtifactUrl);
|
||||
exponentIndexUrl.searchParams.set("artifactIndex", "1e0");
|
||||
const exponentIndexResponse = await fetchWithRetry(exponentIndexUrl.toString());
|
||||
expect(exponentIndexResponse.status).toBe(400);
|
||||
|
||||
const leadingZeroIndexUrl = new URL(indexedArtifactUrl);
|
||||
leadingZeroIndexUrl.searchParams.set("entryIndex", "00");
|
||||
const leadingZeroIndexResponse = await fetchWithRetry(leadingZeroIndexUrl.toString());
|
||||
expect(leadingZeroIndexResponse.status).toBe(400);
|
||||
|
||||
const whitespaceIndexUrl = new URL(indexedArtifactUrl);
|
||||
whitespaceIndexUrl.searchParams.set("entryIndex", " 0 ");
|
||||
const whitespaceIndexResponse = await fetchWithRetry(whitespaceIndexUrl.toString());
|
||||
expect(whitespaceIndexResponse.status).toBe(400);
|
||||
|
||||
await writeFile(path.join(evidenceDir, "undeclared.log"), "hidden\n", "utf8");
|
||||
const undeclaredUrl = new URL(artifactUrl);
|
||||
undeclaredUrl.searchParams.set("artifactPath", "undeclared.log");
|
||||
@@ -788,6 +820,12 @@ describe("qa-lab server", () => {
|
||||
expect(rootResponse.status).toBe(200);
|
||||
expect(await rootResponse.text()).toContain("repo-root-ui");
|
||||
|
||||
const versionResponse = await fetchWithRetry(`${lab.baseUrl}/api/ui-version`);
|
||||
expect(versionResponse.status).toBe(200);
|
||||
const versionPayload = (await versionResponse.json()) as { version?: string | null };
|
||||
expect(versionPayload.version).toBe(resolveUiAssetVersion(null, repoRoot));
|
||||
expect(versionPayload.version).toMatch(/^[0-9a-f]{12}$/);
|
||||
|
||||
const runnerCatalog = await waitForRunnerCatalog(lab.baseUrl);
|
||||
expect(runnerCatalog.status).toBe("ready");
|
||||
const tempModel = runnerCatalog.real.find((model) => model.key === "anthropic/qa-temp-model");
|
||||
|
||||
@@ -101,6 +101,17 @@ function withQaLabRunCounts(run: Omit<QaLabScenarioRun, "counts">): QaLabScenari
|
||||
};
|
||||
}
|
||||
|
||||
function parseQaEvidenceArtifactIndexText(value: string): number {
|
||||
if (!/^(0|[1-9]\d*)$/.test(value)) {
|
||||
throw new QaEvidenceGalleryError("Evidence artifact index is invalid.", 400);
|
||||
}
|
||||
const index = Number(value);
|
||||
if (!Number.isSafeInteger(index) || String(index) !== value) {
|
||||
throw new QaEvidenceGalleryError("Evidence artifact index is invalid.", 400);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function injectKickoffMessage(params: {
|
||||
state: QaBusState;
|
||||
defaults: QaLabBootstrapDefaults;
|
||||
@@ -435,7 +446,7 @@ export async function startQaLabServer(
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
res.end(JSON.stringify({ version: resolveUiAssetVersion(params?.uiDistDir) }));
|
||||
res.end(JSON.stringify({ version: resolveUiAssetVersion(params?.uiDistDir, repoRoot) }));
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/api/outcomes") {
|
||||
@@ -471,8 +482,8 @@ export async function startQaLabServer(
|
||||
const evidencePath = url.searchParams.get("evidencePath")?.trim();
|
||||
const artifactPath = url.searchParams.get("artifactPath")?.trim();
|
||||
const producerFile = url.searchParams.get("producerFile")?.trim();
|
||||
const entryIndexText = url.searchParams.get("entryIndex")?.trim();
|
||||
const artifactIndexText = url.searchParams.get("artifactIndex")?.trim();
|
||||
const entryIndexText = url.searchParams.get("entryIndex");
|
||||
const artifactIndexText = url.searchParams.get("artifactIndex");
|
||||
if (
|
||||
!evidencePath ||
|
||||
(!artifactPath && !producerFile && (!entryIndexText || !artifactIndexText))
|
||||
@@ -493,8 +504,8 @@ export async function startQaLabServer(
|
||||
repoRoot,
|
||||
})
|
||||
: await resolveQaEvidenceArtifactFileByIndex({
|
||||
artifactIndex: Number(artifactIndexText),
|
||||
entryIndex: Number(entryIndexText),
|
||||
artifactIndex: parseQaEvidenceArtifactIndexText(artifactIndexText!),
|
||||
entryIndex: parseQaEvidenceArtifactIndexText(entryIndexText!),
|
||||
evidencePath,
|
||||
repoRoot,
|
||||
});
|
||||
|
||||
@@ -76,6 +76,8 @@ export const LIVE_TRANSPORT_COVERAGE_LANES: readonly LiveTransportCoverageLane[]
|
||||
{ standardId: "top-level-reply-shape", scenarioId: "whatsapp-top-level-reply-shape" },
|
||||
{ standardId: "restart-resume", scenarioId: "whatsapp-restart-resume" },
|
||||
{ standardId: "help-command", scenarioId: "whatsapp-help-command" },
|
||||
{ standardId: "quote-reply", scenarioId: "whatsapp-reply-to-message" },
|
||||
{ standardId: "quote-reply", scenarioId: "whatsapp-group-reply-to-message" },
|
||||
{ standardId: "reaction-observation", scenarioId: "whatsapp-status-reactions" },
|
||||
{ standardId: "allowlist-block", scenarioId: "whatsapp-group-allowlist-block" },
|
||||
],
|
||||
|
||||
@@ -428,6 +428,7 @@ describe("WhatsApp QA live runtime", () => {
|
||||
"top-level-reply-shape",
|
||||
"restart-resume",
|
||||
"help-command",
|
||||
"quote-reply",
|
||||
"reaction-observation",
|
||||
"allowlist-block",
|
||||
]);
|
||||
@@ -631,6 +632,8 @@ describe("WhatsApp QA live runtime", () => {
|
||||
"whatsapp-top-level-reply-shape",
|
||||
"whatsapp-restart-resume",
|
||||
"whatsapp-help-command",
|
||||
"whatsapp-reply-to-message",
|
||||
"whatsapp-group-reply-to-message",
|
||||
"whatsapp-status-reactions",
|
||||
"whatsapp-group-allowlist-block",
|
||||
];
|
||||
@@ -656,6 +659,8 @@ describe("WhatsApp QA live runtime", () => {
|
||||
"whatsapp-whoami-command",
|
||||
"whatsapp-context-command",
|
||||
"whatsapp-tool-only-usage-footer",
|
||||
"whatsapp-reply-to-message",
|
||||
"whatsapp-group-reply-to-message",
|
||||
"whatsapp-reply-context-isolation",
|
||||
"whatsapp-inbound-image-caption",
|
||||
"whatsapp-audio-preflight",
|
||||
@@ -677,6 +682,68 @@ describe("WhatsApp QA live runtime", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("defines quote-reply scenarios for DM and group replies", () => {
|
||||
const scenarios = testing.findScenarios([
|
||||
"whatsapp-reply-to-message",
|
||||
"whatsapp-group-reply-to-message",
|
||||
]);
|
||||
const runs = scenarios.map((scenario) => {
|
||||
const run = scenario.buildRun();
|
||||
if (run.kind === "approval" || !run.verify) {
|
||||
throw new Error(`${scenario.id} unexpectedly built a non-message run`);
|
||||
}
|
||||
return { scenario, run };
|
||||
});
|
||||
|
||||
expect(
|
||||
runs.map(({ scenario, run }) => ({
|
||||
id: scenario.id,
|
||||
requiresGroupJid: scenario.requiresGroupJid,
|
||||
standardId: scenario.standardId,
|
||||
target: run.target,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
id: "whatsapp-reply-to-message",
|
||||
requiresGroupJid: undefined,
|
||||
standardId: "quote-reply",
|
||||
target: "dm",
|
||||
},
|
||||
{
|
||||
id: "whatsapp-group-reply-to-message",
|
||||
requiresGroupJid: true,
|
||||
standardId: "quote-reply",
|
||||
target: "group",
|
||||
},
|
||||
]);
|
||||
expect(runs[0]?.run.input).not.toContain("openclawqa");
|
||||
expect(runs[1]?.run.input).toMatch(/^openclawqa\b/u);
|
||||
|
||||
for (const { run } of runs) {
|
||||
expect(() =>
|
||||
run.verify?.(
|
||||
{
|
||||
kind: "text",
|
||||
observedAt: "2026-06-05T01:00:01.000Z",
|
||||
quoted: { messageId: "trigger-message-id" },
|
||||
text: "reply",
|
||||
},
|
||||
{ sent: { messageId: "trigger-message-id" } } as never,
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
run.verify?.(
|
||||
{
|
||||
kind: "text",
|
||||
observedAt: "2026-06-05T01:00:01.000Z",
|
||||
text: "reply",
|
||||
},
|
||||
{ sent: { messageId: "trigger-message-id" } } as never,
|
||||
),
|
||||
).toThrow("expected reply quote trigger-message-id, got <missing>");
|
||||
}
|
||||
});
|
||||
|
||||
it("seeds the structured-message location check through text context", () => {
|
||||
const [scenario] = testing.findScenarios(["whatsapp-inbound-structured-messages"]);
|
||||
if (!scenario) {
|
||||
|
||||
@@ -70,6 +70,7 @@ type WhatsAppQaScenarioId =
|
||||
| "whatsapp-context-command"
|
||||
| "whatsapp-group-allowlist-block"
|
||||
| "whatsapp-group-audio-gating"
|
||||
| "whatsapp-group-reply-to-message"
|
||||
| "whatsapp-help-command"
|
||||
| "whatsapp-inbound-image-caption"
|
||||
| "whatsapp-inbound-structured-messages"
|
||||
@@ -423,6 +424,31 @@ const whatsappQaCredentialPayloadSchema = z.object({
|
||||
groupJid: z.string().trim().min(1).optional(),
|
||||
});
|
||||
|
||||
function buildWhatsAppQuoteReplyRun(target: "dm" | "group"): WhatsAppQaMessageScenarioRun {
|
||||
const token = `WHATSAPP_QA_REPLY_TO_${target.toUpperCase()}_${randomUUID().slice(0, 8).toUpperCase()}`;
|
||||
const input =
|
||||
target === "group"
|
||||
? `openclawqa reply with only this exact marker: ${token}`
|
||||
: `Reply with only this exact marker: ${token}`;
|
||||
return {
|
||||
configMode: "allowlist",
|
||||
expectReply: true,
|
||||
input,
|
||||
matchText: token,
|
||||
target,
|
||||
verify: (reply, context) => {
|
||||
if (!context.sent.messageId) {
|
||||
throw new Error("WhatsApp driver did not return a triggering message id.");
|
||||
}
|
||||
if (reply.quoted?.messageId !== context.sent.messageId) {
|
||||
throw new Error(
|
||||
`expected reply quote ${context.sent.messageId}, got ${reply.quoted?.messageId ?? "<missing>"}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const WHATSAPP_QA_SCENARIOS: WhatsAppQaScenarioDefinition[] = [
|
||||
{
|
||||
id: "whatsapp-canary",
|
||||
@@ -649,31 +675,24 @@ const WHATSAPP_QA_SCENARIOS: WhatsAppQaScenarioDefinition[] = [
|
||||
},
|
||||
{
|
||||
id: "whatsapp-reply-to-message",
|
||||
standardId: "quote-reply",
|
||||
title: "WhatsApp DM reply-to mode quotes the triggering message",
|
||||
timeoutMs: 60_000,
|
||||
configOverrides: {
|
||||
replyToMode: "all",
|
||||
},
|
||||
buildRun: () => {
|
||||
const token = `WHATSAPP_QA_REPLY_TO_${randomUUID().slice(0, 8).toUpperCase()}`;
|
||||
return {
|
||||
configMode: "allowlist",
|
||||
expectReply: true,
|
||||
input: `Reply with only this exact marker: ${token}`,
|
||||
matchText: token,
|
||||
target: "dm",
|
||||
verify: (reply, context) => {
|
||||
if (!context.sent.messageId) {
|
||||
throw new Error("WhatsApp driver did not return a triggering message id.");
|
||||
}
|
||||
if (reply.quoted?.messageId !== context.sent.messageId) {
|
||||
throw new Error(
|
||||
`expected reply quote ${context.sent.messageId}, got ${reply.quoted?.messageId ?? "<missing>"}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
buildRun: () => buildWhatsAppQuoteReplyRun("dm"),
|
||||
},
|
||||
{
|
||||
id: "whatsapp-group-reply-to-message",
|
||||
standardId: "quote-reply",
|
||||
title: "WhatsApp group reply-to mode quotes the triggering message",
|
||||
timeoutMs: 60_000,
|
||||
configOverrides: {
|
||||
replyToMode: "all",
|
||||
},
|
||||
requiresGroupJid: true,
|
||||
buildRun: () => buildWhatsAppQuoteReplyRun("group"),
|
||||
},
|
||||
{
|
||||
id: "whatsapp-reply-context-isolation",
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
QA_CHANNEL_REQUIRED_PLUGIN_IDS,
|
||||
} from "./qa-channel-transport.js";
|
||||
import { buildQaGatewayConfig } from "./qa-gateway-config.js";
|
||||
import { resolveQaWindowsSystem32ExePath } from "./windows-system-tools.js";
|
||||
|
||||
type ModelRow = {
|
||||
key: string;
|
||||
@@ -120,10 +121,14 @@ function killProcessTree(pid: number | undefined, signal: NodeJS.Signals) {
|
||||
}
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
const killer = spawn("taskkill", ["/pid", String(pid), "/t", "/f"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
const killer = spawn(
|
||||
resolveQaWindowsSystem32ExePath("taskkill.exe"),
|
||||
["/pid", String(pid), "/t", "/f"],
|
||||
{
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
killer.once("error", () => {
|
||||
try {
|
||||
process.kill(pid, signal);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Qa Lab tests cover node exec plugin behavior.
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveQaNodeExecPath } from "./node-exec.js";
|
||||
|
||||
@@ -40,6 +41,29 @@ describe("resolveQaNodeExecPath", () => {
|
||||
).resolves.toBe("/usr/local/bin/node");
|
||||
});
|
||||
|
||||
it("uses trusted Windows where.exe when resolving node from PATH", async () => {
|
||||
await expect(
|
||||
resolveQaNodeExecPath({
|
||||
execPath: String.raw`D:\Tools\bun.exe`,
|
||||
platform: "win32",
|
||||
versions: { ...process.versions, bun: "1.2.3" },
|
||||
env: { SystemRoot: String.raw`D:\Windows` },
|
||||
execFileImpl: async (file, args, options) => {
|
||||
expect(file).toBe(path.win32.join(String.raw`D:\Windows`, "System32", "where.exe"));
|
||||
expect(args).toEqual(["node"]);
|
||||
expect(options).toEqual({
|
||||
encoding: "utf8",
|
||||
env: { SystemRoot: String.raw`D:\Windows` },
|
||||
});
|
||||
return {
|
||||
stdout: String.raw`D:\nodejs\node.exe` + "\r\n",
|
||||
stderr: "",
|
||||
};
|
||||
},
|
||||
}),
|
||||
).resolves.toBe(String.raw`D:\nodejs\node.exe`);
|
||||
});
|
||||
|
||||
it("throws a clear error when node is unavailable", async () => {
|
||||
await expect(
|
||||
resolveQaNodeExecPath({
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { resolveQaWindowsSystem32ExePath } from "./windows-system-tools.js";
|
||||
|
||||
type ExecFileAsync = (
|
||||
file: string,
|
||||
@@ -39,7 +40,8 @@ export async function resolveQaNodeExecPath(params?: {
|
||||
return execPath;
|
||||
}
|
||||
|
||||
const locator = platform === "win32" ? "where" : "which";
|
||||
const locator =
|
||||
platform === "win32" ? resolveQaWindowsSystem32ExePath("where.exe", params?.env) : "which";
|
||||
const execFileImpl = params?.execFileImpl ?? execFileAsync;
|
||||
let stdout;
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Qa Lab plugin module implements process tree cpu behavior.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { parseStrictFiniteNumber, parseStrictInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { resolveQaWindowsPowerShellExePath } from "./windows-system-tools.js";
|
||||
|
||||
type ProcessTreeSnapshot = {
|
||||
childrenByParent: Map<number, number[]>;
|
||||
@@ -175,7 +176,7 @@ function collectProcessTreeMetric(
|
||||
|
||||
function readWindowsProcessTreeSnapshot(): ProcessTreeSnapshot | null {
|
||||
const result = spawnSync(
|
||||
"powershell.exe",
|
||||
resolveQaWindowsPowerShellExePath(),
|
||||
[
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
|
||||
52
extensions/qa-lab/src/process-tree-cpu.windows.test.ts
Normal file
52
extensions/qa-lab/src/process-tree-cpu.windows.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Qa Lab tests cover Windows process tree sampling command selection.
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const spawnSyncMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
return {
|
||||
...actual,
|
||||
spawnSync: spawnSyncMock,
|
||||
};
|
||||
});
|
||||
|
||||
import { readProcessTreeCpuMs } from "./process-tree-cpu.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
spawnSyncMock.mockReset();
|
||||
});
|
||||
|
||||
describe("readProcessTreeCpuMs on Windows", () => {
|
||||
it("uses the trusted Windows PowerShell path", () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
vi.stubEnv("SystemRoot", "D:\\Windows");
|
||||
spawnSyncMock.mockReturnValue({
|
||||
status: 0,
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
ProcessId: 100,
|
||||
ParentProcessId: 50,
|
||||
KernelModeTime: "10000",
|
||||
UserModeTime: "20000",
|
||||
WorkingSetSize: "1000",
|
||||
},
|
||||
{
|
||||
ProcessId: 101,
|
||||
ParentProcessId: 100,
|
||||
KernelModeTime: "30000",
|
||||
UserModeTime: "40000",
|
||||
WorkingSetSize: "2000",
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
expect(readProcessTreeCpuMs(100)).toBe(10);
|
||||
expect(spawnSyncMock.mock.calls[0]?.[0]).toBe(
|
||||
path.win32.join("D:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4240,7 +4240,7 @@ describe("qa mock openai server", () => {
|
||||
expect(body).not.toContain("HEARTBEAT_OK");
|
||||
});
|
||||
|
||||
it("rejects malformed Anthropic /v1/messages JSON with an invalid_request_error", async () => {
|
||||
it("rejects malformed or non-object Anthropic /v1/messages JSON", async () => {
|
||||
const server = await startQaMockOpenAiServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
@@ -4249,20 +4249,55 @@ describe("qa mock openai server", () => {
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
const response = await fetch(`${server.baseUrl}/v1/messages`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: '{"model":"claude-opus-4-8","messages":[',
|
||||
for (const rawBody of ['{"model":"claude-opus-4-8","messages":[', "null", "[]", '"text"']) {
|
||||
const response = await fetch(`${server.baseUrl}/v1/messages`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const body = (await response.json()) as {
|
||||
type: string;
|
||||
error: { type: string; message: string };
|
||||
};
|
||||
expect(body.type).toBe("error");
|
||||
expect(body.error.type).toBe("invalid_request_error");
|
||||
expect(body.error.message).toContain("Malformed JSON body");
|
||||
}
|
||||
|
||||
const health = await fetch(`${server.baseUrl}/healthz`);
|
||||
expect(health.status).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects malformed OpenAI-compatible JSON without crashing the mock server", async () => {
|
||||
const server = await startQaMockOpenAiServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
});
|
||||
cleanups.push(async () => {
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const body = (await response.json()) as {
|
||||
type: string;
|
||||
error: { type: string; message: string };
|
||||
};
|
||||
expect(body.type).toBe("error");
|
||||
expect(body.error.type).toBe("invalid_request_error");
|
||||
expect(body.error.message).toContain("Malformed JSON body");
|
||||
for (const path of ["/v1/responses", "/v1/embeddings", "/v1/images/generations"]) {
|
||||
for (const rawBody of ["{bad", "[]", '"text"']) {
|
||||
const response = await fetch(`${server.baseUrl}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const body = (await response.json()) as {
|
||||
error: { type: string; message: string };
|
||||
};
|
||||
expect(body.error.type).toBe("invalid_request_error");
|
||||
expect(body.error.message).toContain("Malformed JSON body");
|
||||
}
|
||||
}
|
||||
|
||||
const health = await fetch(`${server.baseUrl}/healthz`);
|
||||
expect(health.status).toBe(200);
|
||||
});
|
||||
|
||||
it("defaults empty-string Anthropic /v1/messages model to claude-opus-4-8", async () => {
|
||||
|
||||
@@ -228,6 +228,26 @@ function readBody(req: IncomingMessage): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
function parseJsonObjectBody(raw: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = raw ? (JSON.parse(raw) as unknown) : {};
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeOpenAiMalformedJsonError(res: ServerResponse, label: string) {
|
||||
writeJson(res, 400, {
|
||||
error: {
|
||||
type: "invalid_request_error",
|
||||
message: `Malformed JSON body for ${label} request.`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function transcriptionTextForAudioRequest(rawBody: string) {
|
||||
if (rawBody.length >= QA_GROUP_AUDIO_MIN_MULTIPART_BODY_CHARS) {
|
||||
return QA_GROUP_AUDIO_TRANSCRIPTION_TEXT;
|
||||
@@ -3418,7 +3438,11 @@ export async function startQaMockOpenAiServer(params?: { host?: string; port?: n
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/v1/images/generations") {
|
||||
const raw = await readBody(req);
|
||||
const body = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
|
||||
const body = parseJsonObjectBody(raw);
|
||||
if (!body) {
|
||||
writeOpenAiMalformedJsonError(res, "OpenAI Images");
|
||||
return;
|
||||
}
|
||||
imageGenerationRequests.push(body);
|
||||
if (imageGenerationRequests.length > 20) {
|
||||
imageGenerationRequests.splice(0, imageGenerationRequests.length - 20);
|
||||
@@ -3442,7 +3466,11 @@ export async function startQaMockOpenAiServer(params?: { host?: string; port?: n
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/v1/embeddings") {
|
||||
const raw = await readBody(req);
|
||||
const body = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
|
||||
const body = parseJsonObjectBody(raw);
|
||||
if (!body) {
|
||||
writeOpenAiMalformedJsonError(res, "OpenAI Embeddings");
|
||||
return;
|
||||
}
|
||||
const inputs = extractEmbeddingInputTexts(body.input);
|
||||
writeJson(res, 200, {
|
||||
object: "list",
|
||||
@@ -3464,7 +3492,11 @@ export async function startQaMockOpenAiServer(params?: { host?: string; port?: n
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/v1/responses") {
|
||||
const raw = await readBody(req);
|
||||
const body = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
|
||||
const body = parseJsonObjectBody(raw);
|
||||
if (!body) {
|
||||
writeOpenAiMalformedJsonError(res, "OpenAI Responses");
|
||||
return;
|
||||
}
|
||||
const input = Array.isArray(body.input) ? (body.input as ResponsesInputItem[]) : [];
|
||||
const events = await buildResponsesPayload(body, scenarioState);
|
||||
const resolvedModel = typeof body.model === "string" ? body.model : "";
|
||||
@@ -3502,10 +3534,8 @@ export async function startQaMockOpenAiServer(params?: { host?: string; port?: n
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/v1/messages") {
|
||||
const raw = await readBody(req);
|
||||
let body: AnthropicMessagesRequest;
|
||||
try {
|
||||
body = raw ? (JSON.parse(raw) as AnthropicMessagesRequest) : {};
|
||||
} catch {
|
||||
const body = parseJsonObjectBody(raw) as AnthropicMessagesRequest | null;
|
||||
if (!body) {
|
||||
writeJson(res, 400, {
|
||||
type: "error",
|
||||
error: {
|
||||
|
||||
@@ -187,7 +187,11 @@ describe("qa suite runtime agent process helpers", () => {
|
||||
|
||||
it("force-kills timed-out Windows qa cli process trees with taskkill", async () => {
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
const originalSystemRoot = process.env.SystemRoot;
|
||||
const originalWindir = process.env.WINDIR;
|
||||
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
|
||||
process.env.SystemRoot = "C:\\Windows";
|
||||
delete process.env.WINDIR;
|
||||
try {
|
||||
const child = createSpawnedProcess({ pid: 12345 });
|
||||
spawnMock.mockReturnValue(child);
|
||||
@@ -213,15 +217,29 @@ describe("qa suite runtime agent process helpers", () => {
|
||||
|
||||
await waitForSpawnCount(1);
|
||||
await timeoutAssertion;
|
||||
expect(spawnSyncMock).toHaveBeenCalledWith("taskkill", ["/PID", "12345", "/T", "/F"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
expect(spawnSyncMock).toHaveBeenCalledWith(
|
||||
path.win32.join("C:\\Windows", "System32", "taskkill.exe"),
|
||||
["/PID", "12345", "/T", "/F"],
|
||||
{
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (platformDescriptor) {
|
||||
Object.defineProperty(process, "platform", platformDescriptor);
|
||||
}
|
||||
if (originalSystemRoot === undefined) {
|
||||
delete process.env.SystemRoot;
|
||||
} else {
|
||||
process.env.SystemRoot = originalSystemRoot;
|
||||
}
|
||||
if (originalWindir === undefined) {
|
||||
delete process.env.WINDIR;
|
||||
} else {
|
||||
process.env.WINDIR = originalWindir;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -680,7 +698,7 @@ describe("qa suite runtime agent process helpers", () => {
|
||||
|
||||
expect(gatewayCall).toHaveBeenCalledWith(
|
||||
"agent.wait",
|
||||
{ runId: "run-oversized", timeoutMs: 9e15 },
|
||||
{ runId: "run-oversized", timeoutMs: MAX_TIMER_TIMEOUT_MS },
|
||||
{ timeoutMs: MAX_TIMER_TIMEOUT_MS },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import { liveTurnTimeoutMs } from "./suite-runtime-agent-common.js";
|
||||
import { waitForGatewayHealthy, waitForTransportReady } from "./suite-runtime-gateway.js";
|
||||
import type { QaDreamingStatus, QaSuiteRuntimeEnv } from "./suite-runtime-types.js";
|
||||
import { resolveQaGatewayTimeoutWithGraceMs } from "./timer-timeouts.js";
|
||||
import { resolveQaWindowsSystem32ExePath } from "./windows-system-tools.js";
|
||||
|
||||
type QaMemorySearchResult = {
|
||||
results?: Array<{ snippet?: string; text?: string; path?: string }>;
|
||||
@@ -209,10 +210,14 @@ function signalQaCliProcessTree(
|
||||
) {
|
||||
if (process.platform === "win32") {
|
||||
if (typeof child.pid === "number") {
|
||||
const result = spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
const result = spawnSync(
|
||||
resolveQaWindowsSystem32ExePath("taskkill.exe"),
|
||||
["/PID", String(child.pid), "/T", "/F"],
|
||||
{
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
if (!result.error && result.status === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -342,15 +347,16 @@ async function waitForAgentRun(
|
||||
runId: string,
|
||||
timeoutMs = 30_000,
|
||||
) {
|
||||
const waitTimeoutMs = resolveTimerTimeoutMs(timeoutMs, 30_000);
|
||||
try {
|
||||
return (await env.gateway.call(
|
||||
"agent.wait",
|
||||
{
|
||||
runId,
|
||||
timeoutMs,
|
||||
timeoutMs: waitTimeoutMs,
|
||||
},
|
||||
{
|
||||
timeoutMs: resolveQaGatewayTimeoutWithGraceMs(timeoutMs),
|
||||
timeoutMs: resolveQaGatewayTimeoutWithGraceMs(waitTimeoutMs),
|
||||
},
|
||||
)) as { status?: string; error?: string };
|
||||
} catch (error) {
|
||||
|
||||
@@ -431,26 +431,44 @@ describe("qa test file scenario runner", () => {
|
||||
});
|
||||
|
||||
it("force-kills Windows scenario command trees when graceful taskkill fails", () => {
|
||||
const originalSystemRoot = process.env.SystemRoot;
|
||||
const originalWindir = process.env.WINDIR;
|
||||
process.env.SystemRoot = "C:\\Windows";
|
||||
delete process.env.WINDIR;
|
||||
const runTaskkill = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({ status: 1 })
|
||||
.mockReturnValueOnce({ status: 0 });
|
||||
|
||||
expect(
|
||||
qaTestFileScenarioRunnerTesting.killQaScenarioWindowsProcessTree(
|
||||
12345,
|
||||
"SIGTERM",
|
||||
runTaskkill,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(runTaskkill).toHaveBeenNthCalledWith(1, "taskkill", ["/pid", "12345", "/T"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
expect(runTaskkill).toHaveBeenNthCalledWith(2, "taskkill", ["/pid", "12345", "/T", "/F"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
try {
|
||||
expect(
|
||||
qaTestFileScenarioRunnerTesting.killQaScenarioWindowsProcessTree(
|
||||
12345,
|
||||
"SIGTERM",
|
||||
runTaskkill,
|
||||
),
|
||||
).toBe(true);
|
||||
const taskkillPath = path.win32.join("C:\\Windows", "System32", "taskkill.exe");
|
||||
expect(runTaskkill).toHaveBeenNthCalledWith(1, taskkillPath, ["/pid", "12345", "/T"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
expect(runTaskkill).toHaveBeenNthCalledWith(2, taskkillPath, ["/pid", "12345", "/T", "/F"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
} finally {
|
||||
if (originalSystemRoot === undefined) {
|
||||
delete process.env.SystemRoot;
|
||||
} else {
|
||||
process.env.SystemRoot = originalSystemRoot;
|
||||
}
|
||||
if (originalWindir === undefined) {
|
||||
delete process.env.WINDIR;
|
||||
} else {
|
||||
process.env.WINDIR = originalWindir;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("fails script scenarios that exit cleanly after timeout termination", async () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { QaProviderMode } from "./providers/index.js";
|
||||
import type { QaSeedScenarioWithSource } from "./scenario-catalog.js";
|
||||
import type { QaScorecardEvidenceMode } from "./scorecard-taxonomy.js";
|
||||
import { shellQuote } from "./shell-quote.js";
|
||||
import { resolveQaWindowsSystem32ExePath } from "./windows-system-tools.js";
|
||||
|
||||
export type QaTestFileScenario = QaSeedScenarioWithSource & {
|
||||
execution: Extract<
|
||||
@@ -196,11 +197,12 @@ function killQaScenarioWindowsProcessTree(
|
||||
if (pid === undefined) {
|
||||
return false;
|
||||
}
|
||||
const taskkillPath = resolveQaWindowsSystem32ExePath("taskkill.exe");
|
||||
const args = ["/pid", String(pid), "/T"];
|
||||
if (signal === "SIGKILL") {
|
||||
args.push("/F");
|
||||
}
|
||||
const result = runTaskkill("taskkill", args, {
|
||||
const result = runTaskkill(taskkillPath, args, {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
@@ -208,7 +210,7 @@ function killQaScenarioWindowsProcessTree(
|
||||
return true;
|
||||
}
|
||||
if (signal !== "SIGKILL") {
|
||||
const forceResult = runTaskkill("taskkill", [...args, "/F"], {
|
||||
const forceResult = runTaskkill(taskkillPath, [...args, "/F"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
@@ -134,6 +134,24 @@ describe("token efficiency report", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails live reports with non-integer token usage evidence", () => {
|
||||
const report = buildTokenEfficiencyReport({
|
||||
summary: makeLiveSummary([
|
||||
makeRuntimeParity(
|
||||
"fractional-live-usage",
|
||||
makeCell("openclaw", { inputTokens: 100.5, outputTokens: 0, totalTokens: 100.5 }),
|
||||
makeCell("codex", { inputTokens: 101, outputTokens: 0, totalTokens: 101 }),
|
||||
),
|
||||
]),
|
||||
});
|
||||
|
||||
expect(report.pass).toBe(false);
|
||||
expect(report.failures).toEqual([
|
||||
"fractional-live-usage openclaw live usage inputTokens must be a non-negative integer",
|
||||
"fractional-live-usage openclaw live usage totalTokens must be a non-negative integer",
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails empty live runtime summaries instead of treating them as skipped proof", () => {
|
||||
const report = buildTokenEfficiencyReport({
|
||||
generatedAt: "2026-05-10T00:00:00.000Z",
|
||||
|
||||
@@ -181,6 +181,26 @@ function liveEvidenceFailures(row: TokenEfficiencyRow): string[] {
|
||||
return failures;
|
||||
}
|
||||
|
||||
function liveUsageShapeFailures(
|
||||
scenarioId: string,
|
||||
runtime: RuntimeId,
|
||||
usage: RuntimeParityCell["usage"],
|
||||
): string[] {
|
||||
const failures: string[] = [];
|
||||
for (const key of ["inputTokens", "outputTokens", "totalTokens"] as const) {
|
||||
const value: unknown = usage[key];
|
||||
if (
|
||||
typeof value !== "number" ||
|
||||
!Number.isFinite(value) ||
|
||||
!Number.isInteger(value) ||
|
||||
value < 0
|
||||
) {
|
||||
failures.push(`${scenarioId} ${runtime} live usage ${key} must be a non-negative integer`);
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
export function buildTokenEfficiencyReport(
|
||||
params: BuildTokenEfficiencyReportParams,
|
||||
): TokenEfficiencyReport {
|
||||
@@ -218,8 +238,16 @@ export function buildTokenEfficiencyReport(
|
||||
}),
|
||||
);
|
||||
const aggregate = buildAggregate(rows);
|
||||
const failures = rows.flatMap((row) => {
|
||||
const rowFailures = liveUsage ? liveEvidenceFailures(row) : [];
|
||||
const failures = rows.flatMap((row, index) => {
|
||||
const result = parityResults[index];
|
||||
const rowFailures =
|
||||
liveUsage && result
|
||||
? [
|
||||
...liveUsageShapeFailures(row.scenarioId, "openclaw", result.cells.openclaw.usage),
|
||||
...liveUsageShapeFailures(row.scenarioId, "codex", result.cells.codex.usage),
|
||||
...liveEvidenceFailures(row),
|
||||
]
|
||||
: [];
|
||||
if (row.flagged) {
|
||||
rowFailures.push(
|
||||
`${row.scenarioId} token delta=${formatPercent(row.deltaPercent)} exceeds ${thresholdPercent.toFixed(1)}% Codex increase threshold`,
|
||||
|
||||
@@ -94,6 +94,35 @@ describe("qa tool coverage report", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("escapes freeform metadata in the markdown table", () => {
|
||||
const report = buildQaToolCoverageReport({
|
||||
scenarios: [
|
||||
makeScenario("tool-read", "read|file", {
|
||||
toolCoverage: {
|
||||
bucket: "codex-native-workspace",
|
||||
expectedLayer: "codex-native-workspace",
|
||||
capabilityLayer: "codex-native-workspace",
|
||||
required: true,
|
||||
tracking: "#80236",
|
||||
reason: "tracked | runtime drift",
|
||||
codexDefaultImpact: "P2 | default",
|
||||
qaImpact: "P1 | confidence",
|
||||
action: "fix | backfill",
|
||||
},
|
||||
}),
|
||||
],
|
||||
generatedAt: "2026-05-10T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const markdown = renderQaToolCoverageMarkdownReport(report);
|
||||
|
||||
expect(markdown).toContain("read\\|file");
|
||||
expect(markdown).toContain("P2 \\| default");
|
||||
expect(markdown).toContain("P1 \\| confidence");
|
||||
expect(markdown).toContain("fix \\| backfill");
|
||||
expect(markdown).toContain("#80236 tracked \\| runtime drift");
|
||||
});
|
||||
|
||||
it("uses runtime parity summary rows and allows tracked known-broken drift", () => {
|
||||
const report = buildQaToolCoverageReport({
|
||||
scenarios: [
|
||||
|
||||
@@ -325,9 +325,22 @@ export function renderQaToolCoverageMarkdownReport(report: QaToolCoverageReport)
|
||||
];
|
||||
|
||||
for (const row of report.rows) {
|
||||
lines.push(
|
||||
`| ${row.tool} | ${row.bucket} | ${row.expectedLayer} | ${row.capabilityLayer} | ${row.required ? "yes" : "no"} | ${row.fixtureCount} | ${row.openclaw} | ${row.codex} | ${row.drift} | ${row.codexDefaultImpact ?? ""} | ${row.qaImpact ?? ""} | ${row.action ?? ""} | ${row.tracking ?? ""} |`,
|
||||
);
|
||||
const cells = [
|
||||
row.tool,
|
||||
row.bucket,
|
||||
row.expectedLayer,
|
||||
row.capabilityLayer,
|
||||
row.required ? "yes" : "no",
|
||||
row.fixtureCount.toString(),
|
||||
row.openclaw,
|
||||
row.codex,
|
||||
row.drift,
|
||||
row.codexDefaultImpact ?? "",
|
||||
row.qaImpact ?? "",
|
||||
row.action ?? "",
|
||||
row.tracking ?? "",
|
||||
].map(escapeTableCell);
|
||||
lines.push(`| ${cells.join(" | ")} |`);
|
||||
}
|
||||
|
||||
if (report.failures.length > 0) {
|
||||
@@ -344,3 +357,7 @@ export function renderQaToolCoverageMarkdownReport(report: QaToolCoverageReport)
|
||||
|
||||
return `${lines.join("\n").trimEnd()}\n`;
|
||||
}
|
||||
|
||||
function escapeTableCell(value: string): string {
|
||||
return value.replace(/\|/gu, "\\|").replace(/\s+/gu, " ").trim();
|
||||
}
|
||||
|
||||
34
extensions/qa-lab/src/windows-system-tools.test.ts
Normal file
34
extensions/qa-lab/src/windows-system-tools.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// Qa Lab tests cover Windows system tool path resolution.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveQaWindowsPowerShellExePath,
|
||||
resolveQaWindowsSystem32ExePath,
|
||||
resolveQaWindowsSystemRoot,
|
||||
} from "./windows-system-tools.js";
|
||||
|
||||
describe("qa-lab windows system tools", () => {
|
||||
it("resolves System32 executables from a trusted SystemRoot", () => {
|
||||
expect(resolveQaWindowsSystemRoot({ SystemRoot: "D:\\Windows\\" })).toBe("D:\\Windows");
|
||||
expect(resolveQaWindowsSystem32ExePath("taskkill.exe", { SystemRoot: "D:\\Windows\\" })).toBe(
|
||||
"D:\\Windows\\System32\\taskkill.exe",
|
||||
);
|
||||
expect(resolveQaWindowsPowerShellExePath({ SystemRoot: "D:\\Windows\\" })).toBe(
|
||||
"D:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the default Windows root when env roots are unsafe", () => {
|
||||
expect(resolveQaWindowsSystem32ExePath("taskkill.exe", { SystemRoot: "C:\\tmp;C:\\bad" })).toBe(
|
||||
"C:\\Windows\\System32\\taskkill.exe",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-basename System32 executable names", () => {
|
||||
expect(() => resolveQaWindowsSystem32ExePath("..\\taskkill.exe")).toThrow(
|
||||
"Invalid Windows System32 executable name",
|
||||
);
|
||||
expect(() => resolveQaWindowsSystem32ExePath("taskkill")).toThrow(
|
||||
"Invalid Windows System32 executable name",
|
||||
);
|
||||
});
|
||||
});
|
||||
74
extensions/qa-lab/src/windows-system-tools.ts
Normal file
74
extensions/qa-lab/src/windows-system-tools.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
// Qa Lab resolves Windows system tools without trusting PATH.
|
||||
import path from "node:path";
|
||||
|
||||
const DEFAULT_WINDOWS_SYSTEM_ROOT = "C:\\Windows";
|
||||
|
||||
function getEnvValueCaseInsensitive(
|
||||
env: Record<string, string | undefined>,
|
||||
expectedKey: string,
|
||||
): string | undefined {
|
||||
const direct = env[expectedKey];
|
||||
if (direct !== undefined) {
|
||||
return direct;
|
||||
}
|
||||
const expected = expectedKey.toUpperCase();
|
||||
const actualKey = Object.keys(env).find((key) => key.toUpperCase() === expected);
|
||||
return actualKey ? env[actualKey] : undefined;
|
||||
}
|
||||
|
||||
function normalizeWindowsSystemRoot(raw: string | undefined): string | null {
|
||||
const trimmed = raw?.trim();
|
||||
if (
|
||||
!trimmed ||
|
||||
trimmed.includes("\0") ||
|
||||
trimmed.includes("\r") ||
|
||||
trimmed.includes("\n") ||
|
||||
trimmed.includes(";")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const normalized = path.win32.normalize(trimmed);
|
||||
if (!path.win32.isAbsolute(normalized) || normalized.startsWith("\\\\")) {
|
||||
return null;
|
||||
}
|
||||
const parsed = path.win32.parse(normalized);
|
||||
if (!/^[A-Za-z]:\\$/u.test(parsed.root) || normalized.length <= parsed.root.length) {
|
||||
return null;
|
||||
}
|
||||
return normalized.replace(/[\\/]+$/u, "");
|
||||
}
|
||||
|
||||
export function resolveQaWindowsSystemRoot(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): string {
|
||||
return (
|
||||
normalizeWindowsSystemRoot(getEnvValueCaseInsensitive(env, "SystemRoot")) ??
|
||||
normalizeWindowsSystemRoot(getEnvValueCaseInsensitive(env, "WINDIR")) ??
|
||||
DEFAULT_WINDOWS_SYSTEM_ROOT
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveQaWindowsSystem32ExePath(
|
||||
executableName: string,
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): string {
|
||||
if (
|
||||
path.win32.basename(executableName) !== executableName ||
|
||||
!/^[A-Za-z0-9_.-]+\.exe$/u.test(executableName)
|
||||
) {
|
||||
throw new Error(`Invalid Windows System32 executable name: ${executableName}`);
|
||||
}
|
||||
return path.win32.join(resolveQaWindowsSystemRoot(env), "System32", executableName);
|
||||
}
|
||||
|
||||
export function resolveQaWindowsPowerShellExePath(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): string {
|
||||
return path.win32.join(
|
||||
resolveQaWindowsSystemRoot(env),
|
||||
"System32",
|
||||
"WindowsPowerShell",
|
||||
"v1.0",
|
||||
"powershell.exe",
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// Qa Matrix plugin module implements cli behavior.
|
||||
export { runQaMatrixCommand } from "./src/cli.runtime.js";
|
||||
@@ -1,2 +0,0 @@
|
||||
// Qa Matrix plugin module implements runtime behavior.
|
||||
export { runMatrixQaLive } from "./src/runners/contract/runtime.js";
|
||||
@@ -63,7 +63,11 @@ describe("Matrix QA CLI runtime", () => {
|
||||
|
||||
it("force-kills Windows CLI process trees when graceful taskkill fails", () => {
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
const originalSystemRoot = process.env.SystemRoot;
|
||||
const originalWindir = process.env.WINDIR;
|
||||
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
|
||||
process.env.SystemRoot = "C:\\Windows";
|
||||
delete process.env.WINDIR;
|
||||
try {
|
||||
const killMock = vi.fn();
|
||||
const child = {
|
||||
@@ -77,11 +81,12 @@ describe("Matrix QA CLI runtime", () => {
|
||||
|
||||
testing.killMatrixQaCliChild(child, "SIGTERM", runTaskkill);
|
||||
|
||||
expect(runTaskkill).toHaveBeenNthCalledWith(1, "taskkill", ["/PID", "12345", "/T"], {
|
||||
const taskkillPath = path.win32.join("C:\\Windows", "System32", "taskkill.exe");
|
||||
expect(runTaskkill).toHaveBeenNthCalledWith(1, taskkillPath, ["/PID", "12345", "/T"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
expect(runTaskkill).toHaveBeenNthCalledWith(2, "taskkill", ["/PID", "12345", "/T", "/F"], {
|
||||
expect(runTaskkill).toHaveBeenNthCalledWith(2, taskkillPath, ["/PID", "12345", "/T", "/F"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
@@ -90,6 +95,16 @@ describe("Matrix QA CLI runtime", () => {
|
||||
if (platformDescriptor) {
|
||||
Object.defineProperty(process, "platform", platformDescriptor);
|
||||
}
|
||||
if (originalSystemRoot === undefined) {
|
||||
delete process.env.SystemRoot;
|
||||
} else {
|
||||
process.env.SystemRoot = originalSystemRoot;
|
||||
}
|
||||
if (originalWindir === undefined) {
|
||||
delete process.env.WINDIR;
|
||||
} else {
|
||||
process.env.WINDIR = originalWindir;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { redactSensitiveText } from "openclaw/plugin-sdk/logging-core";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { resolveMatrixQaWindowsSystem32ExePath } from "../../windows-system-tools.js";
|
||||
|
||||
export type MatrixQaCliRunResult = {
|
||||
args: string[];
|
||||
@@ -111,16 +112,17 @@ function killMatrixQaCliChild(
|
||||
): void {
|
||||
if (process.platform === "win32") {
|
||||
if (child.pid) {
|
||||
const taskkillPath = resolveMatrixQaWindowsSystem32ExePath("taskkill.exe");
|
||||
const args = ["/PID", String(child.pid), "/T"];
|
||||
if (signal === "SIGKILL") {
|
||||
args.push("/F");
|
||||
}
|
||||
const result = runTaskkill("taskkill", args, { stdio: "ignore", windowsHide: true });
|
||||
const result = runTaskkill(taskkillPath, args, { stdio: "ignore", windowsHide: true });
|
||||
if (!result.error && result.status === 0) {
|
||||
return;
|
||||
}
|
||||
if (signal !== "SIGKILL") {
|
||||
const forceResult = runTaskkill("taskkill", [...args, "/F"], {
|
||||
const forceResult = runTaskkill(taskkillPath, [...args, "/F"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
32
extensions/qa-matrix/src/windows-system-tools.test.ts
Normal file
32
extensions/qa-matrix/src/windows-system-tools.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// Qa Matrix tests cover Windows system tool path resolution.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveMatrixQaWindowsSystem32ExePath,
|
||||
resolveMatrixQaWindowsSystemRoot,
|
||||
} from "./windows-system-tools.js";
|
||||
|
||||
describe("qa-matrix windows system tools", () => {
|
||||
it("resolves System32 executables from a trusted SystemRoot", () => {
|
||||
expect(resolveMatrixQaWindowsSystemRoot({ SystemRoot: "D:\\Windows\\" })).toBe("D:\\Windows");
|
||||
expect(
|
||||
resolveMatrixQaWindowsSystem32ExePath("taskkill.exe", { SystemRoot: "D:\\Windows\\" }),
|
||||
).toBe("D:\\Windows\\System32\\taskkill.exe");
|
||||
});
|
||||
|
||||
it("falls back to the default Windows root when env roots are unsafe", () => {
|
||||
expect(
|
||||
resolveMatrixQaWindowsSystem32ExePath("taskkill.exe", {
|
||||
WINDIR: "\\\\attacker\\share",
|
||||
}),
|
||||
).toBe("C:\\Windows\\System32\\taskkill.exe");
|
||||
});
|
||||
|
||||
it("rejects non-basename System32 executable names", () => {
|
||||
expect(() => resolveMatrixQaWindowsSystem32ExePath("..\\taskkill.exe")).toThrow(
|
||||
"Invalid Windows System32 executable name",
|
||||
);
|
||||
expect(() => resolveMatrixQaWindowsSystem32ExePath("taskkill")).toThrow(
|
||||
"Invalid Windows System32 executable name",
|
||||
);
|
||||
});
|
||||
});
|
||||
62
extensions/qa-matrix/src/windows-system-tools.ts
Normal file
62
extensions/qa-matrix/src/windows-system-tools.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// Qa Matrix resolves Windows system tools without trusting PATH.
|
||||
import path from "node:path";
|
||||
|
||||
const DEFAULT_WINDOWS_SYSTEM_ROOT = "C:\\Windows";
|
||||
|
||||
function getEnvValueCaseInsensitive(
|
||||
env: Record<string, string | undefined>,
|
||||
expectedKey: string,
|
||||
): string | undefined {
|
||||
const direct = env[expectedKey];
|
||||
if (direct !== undefined) {
|
||||
return direct;
|
||||
}
|
||||
const expected = expectedKey.toUpperCase();
|
||||
const actualKey = Object.keys(env).find((key) => key.toUpperCase() === expected);
|
||||
return actualKey ? env[actualKey] : undefined;
|
||||
}
|
||||
|
||||
function normalizeWindowsSystemRoot(raw: string | undefined): string | null {
|
||||
const trimmed = raw?.trim();
|
||||
if (
|
||||
!trimmed ||
|
||||
trimmed.includes("\0") ||
|
||||
trimmed.includes("\r") ||
|
||||
trimmed.includes("\n") ||
|
||||
trimmed.includes(";")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const normalized = path.win32.normalize(trimmed);
|
||||
if (!path.win32.isAbsolute(normalized) || normalized.startsWith("\\\\")) {
|
||||
return null;
|
||||
}
|
||||
const parsed = path.win32.parse(normalized);
|
||||
if (!/^[A-Za-z]:\\$/u.test(parsed.root) || normalized.length <= parsed.root.length) {
|
||||
return null;
|
||||
}
|
||||
return normalized.replace(/[\\/]+$/u, "");
|
||||
}
|
||||
|
||||
export function resolveMatrixQaWindowsSystemRoot(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): string {
|
||||
return (
|
||||
normalizeWindowsSystemRoot(getEnvValueCaseInsensitive(env, "SystemRoot")) ??
|
||||
normalizeWindowsSystemRoot(getEnvValueCaseInsensitive(env, "WINDIR")) ??
|
||||
DEFAULT_WINDOWS_SYSTEM_ROOT
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveMatrixQaWindowsSystem32ExePath(
|
||||
executableName: string,
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): string {
|
||||
if (
|
||||
path.win32.basename(executableName) !== executableName ||
|
||||
!/^[A-Za-z0-9_.-]+\.exe$/u.test(executableName)
|
||||
) {
|
||||
throw new Error(`Invalid Windows System32 executable name: ${executableName}`);
|
||||
}
|
||||
return path.win32.join(resolveMatrixQaWindowsSystemRoot(env), "System32", executableName);
|
||||
}
|
||||
1
extensions/slack/npm-shrinkwrap.json
generated
1
extensions/slack/npm-shrinkwrap.json
generated
@@ -12,6 +12,7 @@
|
||||
"@slack/types": "2.21.1",
|
||||
"@slack/web-api": "7.16.0",
|
||||
"typebox": "1.1.39",
|
||||
"ws": "8.21.0",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"@slack/types": "2.21.1",
|
||||
"@slack/web-api": "7.16.0",
|
||||
"typebox": "1.1.39",
|
||||
"ws": "8.21.0",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -11,5 +11,13 @@ export function isSlackPluginAccountConfigured(account: ResolvedSlackAccount): b
|
||||
if (mode === "http") {
|
||||
return hasConfiguredAccountValue(account.config.signingSecret);
|
||||
}
|
||||
if (mode === "relay") {
|
||||
const relay = account.config.relay;
|
||||
return (
|
||||
hasConfiguredAccountValue(relay?.url) &&
|
||||
hasConfiguredAccountValue(relay?.authToken) &&
|
||||
hasConfiguredAccountValue(relay?.gatewayId)
|
||||
);
|
||||
}
|
||||
return Boolean(account.appToken?.trim());
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ export function inspectSlackAccount(params: {
|
||||
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const mode = merged.mode ?? "socket";
|
||||
const isHttpMode = mode === "http";
|
||||
const isRelayMode = mode === "relay";
|
||||
|
||||
const configBot = inspectSlackToken(merged.botToken);
|
||||
const configApp = inspectSlackToken(merged.appToken);
|
||||
@@ -89,9 +90,10 @@ export function inspectSlackAccount(params: {
|
||||
const envBot = allowEnv
|
||||
? normalizeSecretInputString(params.envBotToken ?? process.env.SLACK_BOT_TOKEN)
|
||||
: undefined;
|
||||
const envApp = allowEnv
|
||||
? normalizeSecretInputString(params.envAppToken ?? process.env.SLACK_APP_TOKEN)
|
||||
: undefined;
|
||||
const envApp =
|
||||
allowEnv && !isRelayMode
|
||||
? normalizeSecretInputString(params.envAppToken ?? process.env.SLACK_APP_TOKEN)
|
||||
: undefined;
|
||||
const envUser = allowEnv
|
||||
? normalizeSecretInputString(params.envUserToken ?? process.env.SLACK_USER_TOKEN)
|
||||
: undefined;
|
||||
@@ -100,6 +102,11 @@ export function inspectSlackAccount(params: {
|
||||
const appToken = configApp.token ?? envApp;
|
||||
const signingSecret = configSigningSecret.token;
|
||||
const userToken = configUser.token ?? envUser;
|
||||
const relayConfigured =
|
||||
isRelayMode &&
|
||||
Boolean(normalizeOptionalString(merged.relay?.url)) &&
|
||||
hasConfiguredSecretInput(merged.relay?.authToken) &&
|
||||
Boolean(normalizeOptionalString(merged.relay?.gatewayId));
|
||||
const botTokenSource: SlackTokenSource = configBot.token
|
||||
? "config"
|
||||
: configBot.status === "configured_unavailable"
|
||||
@@ -173,8 +180,10 @@ export function inspectSlackAccount(params: {
|
||||
configured: isHttpMode
|
||||
? (configBot.status !== "missing" || Boolean(envBot)) &&
|
||||
configSigningSecret.status !== "missing"
|
||||
: (configBot.status !== "missing" || Boolean(envBot)) &&
|
||||
(configApp.status !== "missing" || Boolean(envApp)),
|
||||
: isRelayMode
|
||||
? (configBot.status !== "missing" || Boolean(envBot)) && relayConfigured
|
||||
: (configBot.status !== "missing" || Boolean(envBot)) &&
|
||||
(configApp.status !== "missing" || Boolean(envApp)),
|
||||
config: merged,
|
||||
groupPolicy: merged.groupPolicy,
|
||||
textChunkLimit: merged.textChunkLimit,
|
||||
|
||||
@@ -54,6 +54,13 @@ const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("sl
|
||||
if (slack?.mode === "http") {
|
||||
return hasConfiguredAccountValue(slack.signingSecret);
|
||||
}
|
||||
if (slack?.mode === "relay") {
|
||||
return (
|
||||
hasConfiguredAccountValue(slack.relay?.url) &&
|
||||
hasConfiguredAccountValue(slack.relay?.authToken) &&
|
||||
hasConfiguredAccountValue(slack.relay?.gatewayId)
|
||||
);
|
||||
}
|
||||
return (
|
||||
hasConfiguredAccountValue(slack?.appToken) ||
|
||||
hasConfiguredAccountValue(process.env.SLACK_APP_TOKEN)
|
||||
@@ -137,7 +144,7 @@ export function mergeSlackAccountConfig(
|
||||
channelConfig: cfg.channels?.slack as SlackAccountConfig,
|
||||
accounts: cfg.channels?.slack?.accounts as Record<string, Partial<SlackAccountConfig>>,
|
||||
accountId,
|
||||
nestedObjectKeys: ["botLoopProtection"],
|
||||
nestedObjectKeys: ["botLoopProtection", "relay"],
|
||||
});
|
||||
const streaming = mergeSlackStreamingConfig(
|
||||
(cfg.channels?.slack as Record<string, unknown> | undefined)?.streaming,
|
||||
@@ -207,7 +214,7 @@ export function resolveSlackAccount(params: {
|
||||
const mode = merged.mode ?? "socket";
|
||||
const baseAllowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const botActive = enabled;
|
||||
const appActive = enabled && mode !== "http";
|
||||
const appActive = enabled && mode === "socket";
|
||||
const userActive = enabled;
|
||||
const envBot =
|
||||
botActive && baseAllowEnv ? resolveSlackBotToken(process.env.SLACK_BOT_TOKEN) : undefined;
|
||||
|
||||
@@ -160,7 +160,7 @@ function getTokenForOperation(
|
||||
account: ResolvedSlackAccount,
|
||||
operation: "read" | "write",
|
||||
): string | undefined {
|
||||
const userToken = normalizeOptionalString(account.config.userToken);
|
||||
const userToken = normalizeOptionalString(account.userToken);
|
||||
const botToken = normalizeOptionalString(account.botToken);
|
||||
const allowUserWrites = account.config.userTokenReadOnly === false;
|
||||
if (operation === "read") {
|
||||
@@ -414,7 +414,7 @@ const resolveSlackAllowlistGroupOverrides = createFlatAllowlistOverrideResolver(
|
||||
const resolveSlackAllowlistNames = createAccountScopedAllowlistNameResolver({
|
||||
resolveAccount: resolveSlackAccount,
|
||||
resolveToken: (account: ResolvedSlackAccount) =>
|
||||
normalizeOptionalString(account.config.userToken) ?? normalizeOptionalString(account.botToken),
|
||||
normalizeOptionalString(account.userToken) ?? normalizeOptionalString(account.botToken),
|
||||
resolveNames: async ({ token, entries }) =>
|
||||
(await loadSlackResolveUsersModule()).resolveSlackUserAllowlist({ token, entries }),
|
||||
});
|
||||
@@ -646,7 +646,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
if (kind === "group") {
|
||||
return resolveTargetsWithOptionalToken({
|
||||
token:
|
||||
normalizeOptionalString(account.config.userToken) ??
|
||||
normalizeOptionalString(account.userToken) ??
|
||||
normalizeOptionalString(account.botToken),
|
||||
inputs,
|
||||
missingTokenNote: "missing Slack token",
|
||||
@@ -661,7 +661,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
}
|
||||
return resolveTargetsWithOptionalToken({
|
||||
token:
|
||||
normalizeOptionalString(account.config.userToken) ??
|
||||
normalizeOptionalString(account.userToken) ??
|
||||
normalizeOptionalString(account.botToken),
|
||||
inputs,
|
||||
missingTokenNote: "missing Slack token",
|
||||
@@ -713,7 +713,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
const lines = [];
|
||||
const details: Record<string, unknown> = {};
|
||||
const botToken = account.botToken?.trim();
|
||||
const userToken = account.config.userToken?.trim();
|
||||
const userToken = account.userToken?.trim();
|
||||
const { fetchSlackScopes } = await loadSlackScopesModule();
|
||||
const botScopes: SlackScopesResultShape = botToken
|
||||
? await fetchSlackScopes(botToken, timeoutMs)
|
||||
@@ -729,16 +729,19 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
},
|
||||
resolveAccountSnapshot: ({ account }) => {
|
||||
const mode = account.config.mode ?? "socket";
|
||||
const configured =
|
||||
(mode === "http"
|
||||
const credentialConfigured =
|
||||
mode === "http"
|
||||
? resolveConfiguredFromRequiredCredentialStatuses(account, [
|
||||
"botTokenStatus",
|
||||
"signingSecretStatus",
|
||||
])
|
||||
: resolveConfiguredFromRequiredCredentialStatuses(account, [
|
||||
"botTokenStatus",
|
||||
"appTokenStatus",
|
||||
])) ?? isSlackPluginAccountConfigured(account);
|
||||
: mode === "socket"
|
||||
? resolveConfiguredFromRequiredCredentialStatuses(account, [
|
||||
"botTokenStatus",
|
||||
"appTokenStatus",
|
||||
])
|
||||
: undefined;
|
||||
const configured = credentialConfigured ?? isSlackPluginAccountConfigured(account);
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
|
||||
@@ -111,6 +111,36 @@ describe("slack config schema", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts relay mode with a SecretInput auth token", () => {
|
||||
expectSlackConfigValid({
|
||||
mode: "relay",
|
||||
botToken: "xoxb-any",
|
||||
relay: {
|
||||
url: "wss://router.example.com/gateway/ws",
|
||||
authToken: { source: "env", provider: "default", id: "SLACK_RELAY_AUTH_TOKEN" },
|
||||
gatewayId: "team-gateway",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("requires every relay connection field", () => {
|
||||
expectSlackConfigIssue({ mode: "relay" }, "relay.url");
|
||||
expectSlackConfigIssue(
|
||||
{ mode: "relay", relay: { url: "wss://router.example.com/gateway/ws" } },
|
||||
"relay.authToken",
|
||||
);
|
||||
expectSlackConfigIssue(
|
||||
{
|
||||
mode: "relay",
|
||||
relay: {
|
||||
url: "wss://router.example.com/gateway/ws",
|
||||
authToken: "secret",
|
||||
},
|
||||
},
|
||||
"relay.gatewayId",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid Socket Mode ping/pong transport tuning", () => {
|
||||
expectSlackConfigIssue(
|
||||
{
|
||||
|
||||
@@ -82,6 +82,22 @@ export const slackChannelConfigUiHints = {
|
||||
label: "Slack Socket Mode Ping/Pong Logging",
|
||||
help: "Enable Slack SDK ping/pong transport logs while debugging Socket Mode websocket health.",
|
||||
},
|
||||
relay: {
|
||||
label: "Slack Relay Mode",
|
||||
help: 'Relay-delivered Slack events. Use with mode="relay" when openclaw-slack-router owns the Slack Socket Mode connection.',
|
||||
},
|
||||
"relay.url": {
|
||||
label: "Slack Relay URL",
|
||||
help: "Full websocket URL for openclaw-slack-router. Include the route path, for example ws://127.0.0.1:8081/gateway/ws.",
|
||||
},
|
||||
"relay.authToken": {
|
||||
label: "Slack Relay Auth Token",
|
||||
help: "Bearer token used by this gateway to authenticate its reverse websocket connection to openclaw-slack-router.",
|
||||
},
|
||||
"relay.gatewayId": {
|
||||
label: "Slack Relay Gateway ID",
|
||||
help: "Destination id that openclaw-slack-router uses when routing user-group mentions to this gateway.",
|
||||
},
|
||||
botToken: {
|
||||
label: "Slack Bot Token",
|
||||
help: "Slack bot token used for standard chat actions in the configured workspace. Keep this credential scoped and rotate if workspace app permissions change.",
|
||||
|
||||
@@ -3,6 +3,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const enqueueMock = vi.fn(async (_entry: unknown) => {});
|
||||
const flushKeyMock = vi.fn(async (_key: string) => {});
|
||||
const onFlushCallbacks: Array<(entries: Array<Record<string, unknown>>) => Promise<void>> = [];
|
||||
const prepareSlackMessageMock = vi.fn(async () => ({ ctxPayload: {} }));
|
||||
const dispatchPreparedSlackMessageMock = vi.fn(async () => {});
|
||||
const resolveThreadTsMock = vi.fn(async ({ message }: { message: Record<string, unknown> }) => ({
|
||||
...message,
|
||||
}));
|
||||
@@ -14,13 +17,18 @@ vi.mock("openclaw/plugin-sdk/channel-inbound", async () => {
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
createChannelInboundDebouncer: () => ({
|
||||
debounceMs: 10,
|
||||
debouncer: {
|
||||
enqueue: (entry: unknown) => enqueueMock(entry),
|
||||
flushKey: (key: string) => flushKeyMock(key),
|
||||
},
|
||||
}),
|
||||
createChannelInboundDebouncer: (params: {
|
||||
onFlush: (entries: Array<Record<string, unknown>>) => Promise<void>;
|
||||
}) => {
|
||||
onFlushCallbacks.push(params.onFlush);
|
||||
return {
|
||||
debounceMs: 10,
|
||||
debouncer: {
|
||||
enqueue: (entry: unknown) => enqueueMock(entry),
|
||||
flushKey: (key: string) => flushKeyMock(key),
|
||||
},
|
||||
};
|
||||
},
|
||||
shouldDebounceTextInbound: ({ hasMedia }: { hasMedia?: boolean }) => !hasMedia,
|
||||
};
|
||||
});
|
||||
@@ -31,6 +39,16 @@ vi.mock("./thread-resolution.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./message-handler/pipeline.runtime.js", () => ({
|
||||
prepareSlackMessage: prepareSlackMessageMock,
|
||||
dispatchPreparedSlackMessage: dispatchPreparedSlackMessageMock,
|
||||
}));
|
||||
|
||||
vi.mock("./inbound-delivery-state.js", () => ({
|
||||
hasSlackInboundMessageDelivery: vi.fn(async () => false),
|
||||
recordSlackInboundMessageDeliveries: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
function createContext(overrides?: {
|
||||
markMessageSeen?: (channel: string | undefined, ts: string | undefined) => boolean;
|
||||
releaseSeenMessage?: (channel: string | undefined, ts: string | undefined) => void;
|
||||
@@ -80,6 +98,9 @@ describe("createSlackMessageHandler", () => {
|
||||
beforeEach(() => {
|
||||
enqueueMock.mockClear();
|
||||
flushKeyMock.mockClear();
|
||||
onFlushCallbacks.length = 0;
|
||||
prepareSlackMessageMock.mockClear();
|
||||
dispatchPreparedSlackMessageMock.mockClear();
|
||||
resolveThreadTsMock.mockClear();
|
||||
});
|
||||
|
||||
@@ -201,4 +222,52 @@ describe("createSlackMessageHandler", () => {
|
||||
|
||||
expect(flushKeyMock).toHaveBeenCalledWith("slack:default:C111:1709000000.000100:U111");
|
||||
});
|
||||
|
||||
it("waits for debounced dispatch completion when requested by relay delivery", async () => {
|
||||
const { handler } = createHandlerWithTracker();
|
||||
const handled = handler(
|
||||
{
|
||||
type: "message",
|
||||
channel: "C111",
|
||||
user: "U111",
|
||||
ts: "1709000000.000500",
|
||||
text: "relay message",
|
||||
} as never,
|
||||
{ source: "message", awaitDispatch: true },
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(enqueueMock).toHaveBeenCalledTimes(1));
|
||||
const entry = enqueueMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
let settled = false;
|
||||
void handled.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
|
||||
await onFlushCallbacks[0]?.([entry]);
|
||||
await expect(handled).resolves.toBeUndefined();
|
||||
expect(dispatchPreparedSlackMessageMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("propagates debounced dispatch failures to relay delivery", async () => {
|
||||
dispatchPreparedSlackMessageMock.mockRejectedValueOnce(new Error("dispatch failed"));
|
||||
const { handler } = createHandlerWithTracker();
|
||||
const handled = handler(
|
||||
{
|
||||
type: "message",
|
||||
channel: "C111",
|
||||
user: "U111",
|
||||
ts: "1709000000.000600",
|
||||
text: "relay message",
|
||||
} as never,
|
||||
{ source: "message", awaitDispatch: true },
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(enqueueMock).toHaveBeenCalledTimes(1));
|
||||
const entry = enqueueMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
const handledFailure = expect(handled).rejects.toThrow("dispatch failed");
|
||||
const flushFailure = expect(onFlushCallbacks[0]?.([entry])).rejects.toThrow("dispatch failed");
|
||||
await Promise.all([handledFailure, flushFailure]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import type { ResolvedSlackAccount } from "../accounts.js";
|
||||
import type { SlackSendIdentity } from "../send.js";
|
||||
import type { SlackMessageEvent } from "../types.js";
|
||||
import { stripSlackMentionsForCommandDetection } from "./commands.js";
|
||||
import type { SlackMonitorContext } from "./context.js";
|
||||
@@ -33,9 +34,35 @@ function loadSlackMessagePipeline(): Promise<SlackMessagePipeline> {
|
||||
|
||||
export type SlackMessageHandler = (
|
||||
message: SlackMessageEvent,
|
||||
opts: { source: "message" | "app_mention"; wasMentioned?: boolean },
|
||||
opts: {
|
||||
source: "message" | "app_mention";
|
||||
wasMentioned?: boolean;
|
||||
relayIdentity?: SlackSendIdentity;
|
||||
/** Wait until any inbound debounce flush and dispatch has completed. */
|
||||
awaitDispatch?: boolean;
|
||||
},
|
||||
) => Promise<void>;
|
||||
|
||||
type SlackDispatchCompletion = {
|
||||
promise: Promise<void>;
|
||||
resolve: () => void;
|
||||
reject: (error: unknown) => void;
|
||||
};
|
||||
|
||||
type QueuedSlackMessageOptions = Parameters<SlackMessageHandler>[1] & {
|
||||
dispatchCompletion?: Omit<SlackDispatchCompletion, "promise">;
|
||||
};
|
||||
|
||||
function createSlackDispatchCompletion(): SlackDispatchCompletion {
|
||||
let resolve!: () => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<void>((nextResolve, nextReject) => {
|
||||
resolve = nextResolve;
|
||||
reject = nextReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
const APP_MENTION_RETRY_TTL_MS = 60_000;
|
||||
|
||||
export class SlackRetryableInboundError extends Error {
|
||||
@@ -71,103 +98,123 @@ export function createSlackMessageHandler(params: {
|
||||
const { ctx, account, trackEvent } = params;
|
||||
const { debounceMs, debouncer } = createChannelInboundDebouncer<{
|
||||
message: SlackMessageEvent;
|
||||
opts: { source: "message" | "app_mention"; wasMentioned?: boolean };
|
||||
opts: QueuedSlackMessageOptions;
|
||||
}>({
|
||||
cfg: ctx.cfg,
|
||||
channel: "slack",
|
||||
buildKey: (entry) => buildSlackDebounceKey(entry.message, ctx.accountId),
|
||||
shouldDebounce: (entry) => shouldDebounceSlackMessage(entry.message, ctx.cfg),
|
||||
onFlush: async (entries) => {
|
||||
const last = entries.at(-1);
|
||||
if (!last) {
|
||||
return;
|
||||
}
|
||||
const flushedKey = buildSlackDebounceKey(last.message, ctx.accountId);
|
||||
const topLevelConversationKey = buildTopLevelSlackConversationKey(
|
||||
last.message,
|
||||
ctx.accountId,
|
||||
);
|
||||
if (flushedKey && topLevelConversationKey) {
|
||||
const pendingKeys = pendingTopLevelDebounceKeys.get(topLevelConversationKey);
|
||||
if (pendingKeys) {
|
||||
pendingKeys.delete(flushedKey);
|
||||
if (pendingKeys.size === 0) {
|
||||
pendingTopLevelDebounceKeys.delete(topLevelConversationKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
const combinedText =
|
||||
entries.length === 1
|
||||
? (last.message.text ?? "")
|
||||
: entries
|
||||
.map((entry) => entry.message.text ?? "")
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const combinedMentioned = entries.some((entry) => Boolean(entry.opts.wasMentioned));
|
||||
const syntheticMessage: SlackMessageEvent = {
|
||||
...last.message,
|
||||
text: combinedText,
|
||||
};
|
||||
const seenMessageKey = buildSeenMessageKey(last.message.channel, last.message.ts);
|
||||
const completions = entries
|
||||
.map((entry) => entry.opts.dispatchCompletion)
|
||||
.filter((completion) => completion !== undefined);
|
||||
try {
|
||||
const { prepareSlackMessage, dispatchPreparedSlackMessage } =
|
||||
await loadSlackMessagePipeline();
|
||||
const prepared = await prepareSlackMessage({
|
||||
ctx,
|
||||
account,
|
||||
message: syntheticMessage,
|
||||
opts: {
|
||||
...last.opts,
|
||||
wasMentioned: combinedMentioned || last.opts.wasMentioned,
|
||||
},
|
||||
});
|
||||
if (!prepared) {
|
||||
return;
|
||||
}
|
||||
if (seenMessageKey) {
|
||||
pruneAppMentionRetryKeys(Date.now());
|
||||
if (last.opts.source === "app_mention") {
|
||||
// If app_mention wins the race and dispatches first, drop the later message dispatch.
|
||||
rememberExpiringAppMentionKey(appMentionDispatchedKeys, seenMessageKey);
|
||||
} else if (
|
||||
last.opts.source === "message" &&
|
||||
appMentionDispatchedKeys.has(seenMessageKey)
|
||||
) {
|
||||
appMentionDispatchedKeys.delete(seenMessageKey);
|
||||
appMentionRetryKeys.delete(seenMessageKey);
|
||||
await (async () => {
|
||||
const last = entries.at(-1);
|
||||
if (!last) {
|
||||
return;
|
||||
}
|
||||
appMentionRetryKeys.delete(seenMessageKey);
|
||||
}
|
||||
if (entries.length > 1) {
|
||||
const ids = entries.map((entry) => entry.message.ts).filter(Boolean) as string[];
|
||||
if (ids.length > 0) {
|
||||
prepared.ctxPayload.MessageSids = ids;
|
||||
prepared.ctxPayload.MessageSidFirst = ids[0];
|
||||
prepared.ctxPayload.MessageSidLast = ids[ids.length - 1];
|
||||
const flushedKey = buildSlackDebounceKey(last.message, ctx.accountId);
|
||||
const topLevelConversationKey = buildTopLevelSlackConversationKey(
|
||||
last.message,
|
||||
ctx.accountId,
|
||||
);
|
||||
if (flushedKey && topLevelConversationKey) {
|
||||
const pendingKeys = pendingTopLevelDebounceKeys.get(topLevelConversationKey);
|
||||
if (pendingKeys) {
|
||||
pendingKeys.delete(flushedKey);
|
||||
if (pendingKeys.size === 0) {
|
||||
pendingTopLevelDebounceKeys.delete(topLevelConversationKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await dispatchPreparedSlackMessage(prepared);
|
||||
await recordSlackInboundMessageDeliveries({
|
||||
accountId: ctx.accountId,
|
||||
messages: entries.map((entry) => entry.message),
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof SlackRetryableInboundError)) {
|
||||
await recordSlackInboundMessageDeliveries({
|
||||
accountId: ctx.accountId,
|
||||
messages: entries.map((entry) => entry.message),
|
||||
const combinedText =
|
||||
entries.length === 1
|
||||
? (last.message.text ?? "")
|
||||
: entries
|
||||
.map((entry) => entry.message.text ?? "")
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const combinedMentioned = entries.some((entry) => Boolean(entry.opts.wasMentioned));
|
||||
const syntheticMessage: SlackMessageEvent = {
|
||||
...last.message,
|
||||
text: combinedText,
|
||||
};
|
||||
const seenMessageKey = buildSeenMessageKey(last.message.channel, last.message.ts);
|
||||
try {
|
||||
const { prepareSlackMessage, dispatchPreparedSlackMessage } =
|
||||
await loadSlackMessagePipeline();
|
||||
const {
|
||||
dispatchCompletion: _completion,
|
||||
awaitDispatch: _awaitDispatch,
|
||||
...lastOpts
|
||||
} = last.opts;
|
||||
const prepared = await prepareSlackMessage({
|
||||
ctx,
|
||||
account,
|
||||
message: syntheticMessage,
|
||||
opts: {
|
||||
...lastOpts,
|
||||
wasMentioned: combinedMentioned || last.opts.wasMentioned,
|
||||
},
|
||||
});
|
||||
if (!prepared) {
|
||||
return;
|
||||
}
|
||||
if (seenMessageKey) {
|
||||
pruneAppMentionRetryKeys(Date.now());
|
||||
if (last.opts.source === "app_mention") {
|
||||
// If app_mention wins the race and dispatches first, drop the later message dispatch.
|
||||
rememberExpiringAppMentionKey(appMentionDispatchedKeys, seenMessageKey);
|
||||
} else if (
|
||||
last.opts.source === "message" &&
|
||||
appMentionDispatchedKeys.has(seenMessageKey)
|
||||
) {
|
||||
appMentionDispatchedKeys.delete(seenMessageKey);
|
||||
appMentionRetryKeys.delete(seenMessageKey);
|
||||
return;
|
||||
}
|
||||
appMentionRetryKeys.delete(seenMessageKey);
|
||||
}
|
||||
if (entries.length > 1) {
|
||||
const ids = entries.map((entry) => entry.message.ts).filter(Boolean) as string[];
|
||||
if (ids.length > 0) {
|
||||
prepared.ctxPayload.MessageSids = ids;
|
||||
prepared.ctxPayload.MessageSidFirst = ids[0];
|
||||
prepared.ctxPayload.MessageSidLast = ids[ids.length - 1];
|
||||
}
|
||||
}
|
||||
try {
|
||||
await dispatchPreparedSlackMessage(prepared);
|
||||
await recordSlackInboundMessageDeliveries({
|
||||
accountId: ctx.accountId,
|
||||
messages: entries.map((entry) => entry.message),
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof SlackRetryableInboundError)) {
|
||||
await recordSlackInboundMessageDeliveries({
|
||||
accountId: ctx.accountId,
|
||||
messages: entries.map((entry) => entry.message),
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SlackRetryableInboundError) {
|
||||
if (seenMessageKey) {
|
||||
appMentionDispatchedKeys.delete(seenMessageKey);
|
||||
}
|
||||
ctx.releaseSeenMessage(last.message.channel, last.message.ts);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
})();
|
||||
for (const completion of completions) {
|
||||
completion.resolve();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SlackRetryableInboundError) {
|
||||
if (seenMessageKey) {
|
||||
appMentionDispatchedKeys.delete(seenMessageKey);
|
||||
}
|
||||
ctx.releaseSeenMessage(last.message.channel, last.message.ts);
|
||||
for (const completion of completions) {
|
||||
completion.reject(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -284,6 +331,21 @@ export function createSlackMessageHandler(params: {
|
||||
pendingKeys.add(debounceKey);
|
||||
pendingTopLevelDebounceKeys.set(conversationKey, pendingKeys);
|
||||
}
|
||||
await debouncer.enqueue({ message: resolvedMessage, opts });
|
||||
const dispatchCompletion = opts.awaitDispatch ? createSlackDispatchCompletion() : undefined;
|
||||
await debouncer.enqueue({
|
||||
message: resolvedMessage,
|
||||
opts: {
|
||||
...opts,
|
||||
...(dispatchCompletion
|
||||
? {
|
||||
dispatchCompletion: {
|
||||
resolve: dispatchCompletion.resolve,
|
||||
reject: dispatchCompletion.reject,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
await dispatchCompletion?.promise;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -339,6 +339,7 @@ function createPreparedSlackMessage(params?: {
|
||||
typingReaction?: string;
|
||||
ackReactionMessageTs?: string;
|
||||
ackReactionPromise?: Promise<boolean> | null;
|
||||
relayIdentity?: { username?: string; iconUrl?: string; iconEmoji?: string };
|
||||
}) {
|
||||
const routeSessionKey = params?.route?.sessionKey ?? "agent:agent-1:slack:C123";
|
||||
const mainSessionKey = params?.route?.mainSessionKey ?? "main";
|
||||
@@ -373,6 +374,7 @@ function createPreparedSlackMessage(params?: {
|
||||
accountId: "default",
|
||||
config: params?.accountConfig ?? {},
|
||||
},
|
||||
relayIdentity: params?.relayIdentity,
|
||||
message,
|
||||
route: {
|
||||
agentId: "agent-1",
|
||||
@@ -1279,6 +1281,27 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
||||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
|
||||
});
|
||||
|
||||
it("uses the relay identity when the agent has no explicit Slack identity", async () => {
|
||||
const relayIdentity = { username: "Nik Team Claw" };
|
||||
|
||||
await dispatchPreparedSlackMessage(createPreparedSlackMessage({ relayIdentity }));
|
||||
|
||||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT, { identity: relayIdentity });
|
||||
});
|
||||
|
||||
it("does not use native Slack streaming when a custom identity is active", async () => {
|
||||
mockedNativeStreaming = true;
|
||||
const relayIdentity = { username: "Nik Team Claw" };
|
||||
|
||||
await dispatchPreparedSlackMessage(createPreparedSlackMessage({ relayIdentity }));
|
||||
|
||||
expect(startSlackStreamMock).not.toHaveBeenCalled();
|
||||
expect(createSlackDraftStreamMock).toHaveBeenCalledTimes(1);
|
||||
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
|
||||
expectDeliverReplyCall(0, FINAL_REPLY_TEXT, { identity: relayIdentity });
|
||||
});
|
||||
|
||||
it("does not create a Slack thread for top-level messages when replyToMode is off", async () => {
|
||||
mockedSlackStreamingMode = "off";
|
||||
mockedSlackIsThreadReply = false;
|
||||
|
||||
@@ -466,7 +466,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
iconUrl: outboundIdentity.avatarUrl,
|
||||
iconEmoji: outboundIdentity.emoji,
|
||||
}
|
||||
: undefined;
|
||||
: prepared.relayIdentity;
|
||||
|
||||
if (prepared.isDirectMessage) {
|
||||
const sessionCfg = cfg.session;
|
||||
@@ -688,7 +688,11 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
shouldEnableSlackPreviewStreaming({
|
||||
mode: slackStreaming.mode,
|
||||
});
|
||||
// Slack's native streaming APIs do not accept chat:write.customize identity
|
||||
// fields. Keep custom-identity replies on the draft/standard postMessage
|
||||
// path so the configured username and icon are not silently discarded.
|
||||
const streamingEnabled =
|
||||
!slackIdentity &&
|
||||
!sourceRepliesAreToolOnly &&
|
||||
isSlackStreamingEnabled({
|
||||
mode: slackStreaming.mode,
|
||||
|
||||
@@ -40,6 +40,7 @@ import type { ResolvedSlackAccount } from "../../accounts.js";
|
||||
import { reactSlackMessage } from "../../actions.js";
|
||||
import { formatSlackError } from "../../errors.js";
|
||||
import { formatSlackFileReference } from "../../file-reference.js";
|
||||
import type { SlackSendIdentity } from "../../send.js";
|
||||
import { hasSlackThreadParticipationWithPersistence } from "../../sent-thread-cache.js";
|
||||
import type { SlackAttachment, SlackFile, SlackMessageEvent } from "../../types.js";
|
||||
import { normalizeAllowListLower, normalizeSlackAllowOwnerEntry } from "../allow-list.js";
|
||||
@@ -619,7 +620,11 @@ export async function prepareSlackMessage(params: {
|
||||
ctx: SlackMonitorContext;
|
||||
account: ResolvedSlackAccount;
|
||||
message: SlackMessageEvent;
|
||||
opts: { source: "message" | "app_mention"; wasMentioned?: boolean };
|
||||
opts: {
|
||||
source: "message" | "app_mention";
|
||||
wasMentioned?: boolean;
|
||||
relayIdentity?: SlackSendIdentity;
|
||||
};
|
||||
}): Promise<PreparedSlackMessage | null> {
|
||||
const { ctx, account, message, opts } = params;
|
||||
const cfg = ctx.cfg;
|
||||
@@ -1390,6 +1395,7 @@ export async function prepareSlackMessage(params: {
|
||||
ctx,
|
||||
account,
|
||||
message,
|
||||
...(opts.relayIdentity ? { relayIdentity: opts.relayIdentity } : {}),
|
||||
route,
|
||||
channelConfig,
|
||||
replyTarget,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
|
||||
import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
import type { ResolvedSlackAccount } from "../../accounts.js";
|
||||
import type { SlackSendIdentity } from "../../send.js";
|
||||
import type { SlackMessageEvent } from "../../types.js";
|
||||
import type { SlackChannelConfigResolved } from "../channel-config.js";
|
||||
import type { SlackMonitorContext } from "../context.js";
|
||||
@@ -12,6 +13,7 @@ export type PreparedSlackMessage = {
|
||||
ctx: SlackMonitorContext;
|
||||
account: ResolvedSlackAccount;
|
||||
message: SlackMessageEvent;
|
||||
relayIdentity?: SlackSendIdentity;
|
||||
route: ResolvedAgentRoute;
|
||||
channelConfig: SlackChannelConfigResolved | null;
|
||||
replyTarget: string;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { formatUnknownError, waitForSlackSocketDisconnect } from "./reconnect-po
|
||||
|
||||
type SlackAppConstructor = typeof import("@slack/bolt").App;
|
||||
type SlackHttpReceiverConstructor = typeof import("@slack/bolt").HTTPReceiver;
|
||||
type SlackReceiver = import("@slack/bolt").Receiver;
|
||||
type SlackSocketModeReceiverConstructor = typeof import("@slack/bolt").SocketModeReceiver;
|
||||
type SlackSocketModeReceiverOptions = ConstructorParameters<SlackSocketModeReceiverConstructor>[0];
|
||||
type SlackSocketModeConfig = Pick<
|
||||
@@ -113,6 +114,14 @@ function installSlackNativeReconnectFailureObserver(receiver: unknown) {
|
||||
);
|
||||
}
|
||||
|
||||
function createSlackRelayReceiver(): SlackReceiver {
|
||||
return {
|
||||
init() {},
|
||||
start: () => Promise.resolve(undefined),
|
||||
stop: () => Promise.resolve(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSlackBoltModule(value: unknown): SlackBoltResolvedExports | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
@@ -296,7 +305,7 @@ export function shouldSkipOpenClawSlackSelfEvent(args: SlackSelfFilterArgs): boo
|
||||
|
||||
export function createSlackBoltApp(params: {
|
||||
interop: SlackBoltResolvedExports;
|
||||
slackMode: "socket" | "http";
|
||||
slackMode: "socket" | "http" | "relay";
|
||||
botToken: string;
|
||||
appToken?: string;
|
||||
signingSecret?: string;
|
||||
@@ -322,25 +331,31 @@ export function createSlackBoltApp(params: {
|
||||
socketModeReceiverOptions.pingPongLoggingEnabled = params.socketMode.pingPongLoggingEnabled;
|
||||
}
|
||||
|
||||
const receiver =
|
||||
params.slackMode === "socket"
|
||||
? new params.interop.SocketModeReceiver(socketModeReceiverOptions)
|
||||
: new params.interop.HTTPReceiver({
|
||||
signingSecret: params.signingSecret ?? "",
|
||||
endpoints: params.slackWebhookPath,
|
||||
});
|
||||
let receiver:
|
||||
| InstanceType<SlackSocketModeReceiverConstructor>
|
||||
| InstanceType<SlackHttpReceiverConstructor>
|
||||
| SlackReceiver
|
||||
| undefined;
|
||||
if (params.slackMode === "socket") {
|
||||
receiver = new params.interop.SocketModeReceiver(socketModeReceiverOptions);
|
||||
installSlackNativeReconnectFailureObserver(receiver);
|
||||
} else if (params.slackMode === "http") {
|
||||
receiver = new params.interop.HTTPReceiver({
|
||||
signingSecret: params.signingSecret ?? "",
|
||||
endpoints: params.slackWebhookPath,
|
||||
});
|
||||
} else {
|
||||
receiver = createSlackRelayReceiver();
|
||||
}
|
||||
const app = new params.interop.App({
|
||||
token: params.botToken,
|
||||
receiver,
|
||||
clientOptions: params.clientOptions,
|
||||
ignoreSelf: false,
|
||||
// Bolt eagerly starts an auth.test promise in the constructor when token
|
||||
// verification is enabled. Invalid tokens can reject before any listener
|
||||
// consumes that promise, tripping OpenClaw's fatal unhandled-rejection path.
|
||||
tokenVerificationEnabled: false,
|
||||
...(receiver ? { receiver } : {}),
|
||||
});
|
||||
app.use(async (args) => {
|
||||
if (shouldSkipOpenClawSlackSelfEvent(args)) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user