mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-19 21:22:05 +08:00
Compare commits
233 Commits
lint-perfo
...
qa-fold-ht
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dc652394d | ||
|
|
46e0dd4bf8 | ||
|
|
2ea3f65592 | ||
|
|
31d489215e | ||
|
|
0a7017aa1d | ||
|
|
83e3c0f78c | ||
|
|
f7fd088766 | ||
|
|
1a408dad03 | ||
|
|
742b6ffee8 | ||
|
|
27f702d68f | ||
|
|
0781dae620 | ||
|
|
6256ad86c9 | ||
|
|
f7f415f26b | ||
|
|
2983edd5a2 | ||
|
|
4da36da605 | ||
|
|
92d1f04de3 | ||
|
|
611ad1a097 | ||
|
|
6ef4970988 | ||
|
|
8d9eba3f4f | ||
|
|
40dc8fd147 | ||
|
|
2257a21b7e | ||
|
|
d4833e27c7 | ||
|
|
d1bb2d5a12 | ||
|
|
eb7da0a2e5 | ||
|
|
797865c9dc | ||
|
|
7fcbfa6971 | ||
|
|
3091c13713 | ||
|
|
c159063c70 | ||
|
|
dae37a4579 | ||
|
|
2e0dfda462 | ||
|
|
5b3d652c05 | ||
|
|
b39a932112 | ||
|
|
0c76a98f10 | ||
|
|
a8b5f5d551 | ||
|
|
bbe9669926 | ||
|
|
7580c80f37 | ||
|
|
7f38b1a910 | ||
|
|
8aaf937bc0 | ||
|
|
6467c1962a | ||
|
|
0c565f3b0e | ||
|
|
7211d77553 | ||
|
|
dba291ed35 | ||
|
|
32c02e843a | ||
|
|
5e329f4065 | ||
|
|
e6743eb783 | ||
|
|
dbd5689ea1 | ||
|
|
44b0644e88 | ||
|
|
6aa85dfaa1 | ||
|
|
86b24ac2b2 | ||
|
|
d236612cc0 | ||
|
|
c3390f0bc6 | ||
|
|
a6ac8de523 | ||
|
|
ca527aad9d | ||
|
|
3a435eebc0 | ||
|
|
dfc5bd5fcc | ||
|
|
7cc66b5175 | ||
|
|
fcec95ffd7 | ||
|
|
e67f8ba459 | ||
|
|
33fa225f65 | ||
|
|
86a28636fa | ||
|
|
90ba9fc864 | ||
|
|
f5419b5bb0 | ||
|
|
14fd10f8f8 | ||
|
|
38fefc5aaf | ||
|
|
ccdec2e294 | ||
|
|
c79a5aa253 | ||
|
|
0dbac0d5f9 | ||
|
|
b972feb3f7 | ||
|
|
3c01716c82 | ||
|
|
e802fb8a9f | ||
|
|
94b710ac00 | ||
|
|
ebee101d30 | ||
|
|
5697ab810e | ||
|
|
cd98f195a7 | ||
|
|
be7d86ed80 | ||
|
|
5d6ac23086 | ||
|
|
f61ad70d3f | ||
|
|
05e70bd331 | ||
|
|
8480ef3f86 | ||
|
|
fc1bdecf08 | ||
|
|
a57e761f6b | ||
|
|
13be16d699 | ||
|
|
257b533e85 | ||
|
|
5939ab4c49 | ||
|
|
5db2f6c1fc | ||
|
|
afd9cb0c10 | ||
|
|
2e1e4167a9 | ||
|
|
3bacf96ccc | ||
|
|
433d8cbb2c | ||
|
|
37b2770071 | ||
|
|
e172f64f3f | ||
|
|
8e66d7aad3 | ||
|
|
688ecb1655 | ||
|
|
96dbd1c723 | ||
|
|
2f12755498 | ||
|
|
a37dd0210b | ||
|
|
17106b4844 | ||
|
|
93d0d2aedd | ||
|
|
82ae81f3bf | ||
|
|
2db37c2cd0 | ||
|
|
6370f2023a | ||
|
|
2dbbef46bb | ||
|
|
df261fabb3 | ||
|
|
c041a45ece | ||
|
|
089f8c7fb5 | ||
|
|
712e69dd74 | ||
|
|
13aaece8b3 | ||
|
|
32ee308f55 | ||
|
|
5776b9b4e6 | ||
|
|
6368c1173c | ||
|
|
dc9b1d5159 | ||
|
|
308fb97f7a | ||
|
|
e498fc8c3b | ||
|
|
b794d7fb58 | ||
|
|
bc7c2baa5c | ||
|
|
46c42d4a0d | ||
|
|
3a82bf5766 | ||
|
|
5e7a0b1558 | ||
|
|
38ebc24f77 | ||
|
|
af3c10626c | ||
|
|
324ad548a8 | ||
|
|
e552f97866 | ||
|
|
259f071a93 | ||
|
|
a5190f7d4a | ||
|
|
a619518ebe | ||
|
|
ea0b0ad0a0 | ||
|
|
fb25f29638 | ||
|
|
300f5e8590 | ||
|
|
f144899219 | ||
|
|
d095e2a4f5 | ||
|
|
8aaa4bf3ef | ||
|
|
06b6f7055b | ||
|
|
de95726177 | ||
|
|
e91c17947b | ||
|
|
a25c64a4e4 | ||
|
|
8888bca752 | ||
|
|
50a4bb00e5 | ||
|
|
de17d5b9ef | ||
|
|
6a0c3eaf78 | ||
|
|
78f948f768 | ||
|
|
975340fbd5 | ||
|
|
7570831ee1 | ||
|
|
f2a83a7a71 | ||
|
|
d9c66b9c6d | ||
|
|
39328ed692 | ||
|
|
8662b9de54 | ||
|
|
6504237900 | ||
|
|
fb69db6365 | ||
|
|
845ad1cf71 | ||
|
|
356a199bd4 | ||
|
|
f1cab04966 | ||
|
|
87854e841e | ||
|
|
508e3bf413 | ||
|
|
825188cb6a | ||
|
|
36bfe77db1 | ||
|
|
def4c995ac | ||
|
|
f91350485c | ||
|
|
d91766e5e1 | ||
|
|
dae06a203f | ||
|
|
52948a1726 | ||
|
|
88c92922e1 | ||
|
|
ae6e1fa4d2 | ||
|
|
f1c0d5f06f | ||
|
|
b28fda9ef8 | ||
|
|
09427aa760 | ||
|
|
107904c2c5 | ||
|
|
6c82a9fb18 | ||
|
|
741f7080a7 | ||
|
|
a1b7118d0f | ||
|
|
ca6d52e0e8 | ||
|
|
4c55e04e49 | ||
|
|
8d596aa651 | ||
|
|
1385da8d3f | ||
|
|
ad715dfdc9 | ||
|
|
d1ab308f5c | ||
|
|
aaceaf8e7c | ||
|
|
1492f9906a | ||
|
|
9ceb970a06 | ||
|
|
c33007ef58 | ||
|
|
09a159c913 | ||
|
|
eedb6678f1 | ||
|
|
459bcd6198 | ||
|
|
1bbc3b6cb6 | ||
|
|
6325a8b5f4 | ||
|
|
5805af9dc4 | ||
|
|
42bcb3ecb0 | ||
|
|
b48238aa88 | ||
|
|
dc980273e3 | ||
|
|
74f90885f3 | ||
|
|
236a0c8fe0 | ||
|
|
b306745f64 | ||
|
|
20dd2be0f2 | ||
|
|
5b030418c1 | ||
|
|
a74ce6f20c | ||
|
|
10f0588ee3 | ||
|
|
39297fb0ad | ||
|
|
3914a3638c | ||
|
|
2ef0589b76 | ||
|
|
01e562113b | ||
|
|
410a95269a | ||
|
|
2c6bf1a5d8 | ||
|
|
ccc1ad4c74 | ||
|
|
f19cae6d1d | ||
|
|
8c3185d55c | ||
|
|
a0d9f9ea45 | ||
|
|
dd5febe2aa | ||
|
|
943511674c | ||
|
|
c33cec04d9 | ||
|
|
c9605779ef | ||
|
|
c79f1e5441 | ||
|
|
15e101137d | ||
|
|
900a834c60 | ||
|
|
9765f7333a | ||
|
|
1fb11ab306 | ||
|
|
01b919aeff | ||
|
|
c654641e0c | ||
|
|
d04cedb9fe | ||
|
|
ba69d4fb03 | ||
|
|
73241d39f6 | ||
|
|
c770c7b084 | ||
|
|
e12cf72b17 | ||
|
|
e9e44bf83c | ||
|
|
843b1d6fbb | ||
|
|
09b56592d2 | ||
|
|
701687efa3 | ||
|
|
d8cc16a3e2 | ||
|
|
7f894ba2be | ||
|
|
e5de09f96a | ||
|
|
2a0e63d12b | ||
|
|
e3bab80bda | ||
|
|
7207072436 | ||
|
|
8af89b097a | ||
|
|
8bcb1e05b6 |
@@ -30,6 +30,9 @@ out of this repo. If a score needs private evidence, use the redacted
|
||||
completeness-instruction paths.
|
||||
- Feature `coverageIds` are ANDed proof targets, not aliases. A feature may
|
||||
list multiple IDs when each ID proves part of one capability.
|
||||
- Coverage IDs use dotted `namespace.behavior` form, with lowercase
|
||||
alphanumeric/dash segments. Profile, surface, and category IDs may remain
|
||||
dashed or dotted.
|
||||
- Keep categories and feature names unique, product-shaped, and broader than raw
|
||||
coverage IDs. Do not promote generic IDs into standalone feature names.
|
||||
- Avoid duplicate coverage-ID bundles under different feature names in one
|
||||
|
||||
@@ -1686,7 +1686,8 @@ jobs:
|
||||
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
|
||||
OPENCLAW_LIVE_PROVIDERS: ${{ matrix.providers }}
|
||||
OPENCLAW_LIVE_IMAGE: ${{ needs.prepare_live_test_image.outputs.live_image }}
|
||||
OPENCLAW_LIVE_MAX_MODELS: "6"
|
||||
OPENCLAW_LIVE_MODELS: ${{ matrix.models || 'modern' }}
|
||||
OPENCLAW_LIVE_MAX_MODELS: ${{ matrix.max_models || '6' }}
|
||||
OPENCLAW_LIVE_MODEL_TIMEOUT_MS: "45000"
|
||||
OPENCLAW_SKIP_DOCKER_BUILD: "1"
|
||||
OPENCLAW_VITEST_MAX_WORKERS: "2"
|
||||
@@ -2000,7 +2001,7 @@ jobs:
|
||||
profiles: stable full
|
||||
- suite_id: native-live-src-gateway-profiles-minimax
|
||||
label: Native live gateway profiles MiniMax
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M3,minimax-portal/MiniMax-M3 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M2.7,minimax-portal/MiniMax-M2.7 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
timeout_minutes: 60
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
@@ -2303,7 +2304,7 @@ jobs:
|
||||
profiles: stable full
|
||||
- suite_id: live-gateway-minimax-docker
|
||||
label: Docker live gateway MiniMax
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M3,minimax-portal/MiniMax-M3 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M2.7,minimax-portal/MiniMax-M2.7 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
timeout_minutes: 40
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
|
||||
7
.github/workflows/openclaw-performance.yml
vendored
7
.github/workflows/openclaw-performance.yml
vendored
@@ -45,7 +45,7 @@ on:
|
||||
kova_ref:
|
||||
description: openclaw/Kova Git ref to install
|
||||
required: false
|
||||
default: b63b6f9e20efb23641df00487e982230d81a90ac
|
||||
default: 4f146016583018bad9e24f8e64a6af5f963bb7ee
|
||||
type: string
|
||||
dispatch_id:
|
||||
description: Optional parent workflow dispatch identifier
|
||||
@@ -66,6 +66,7 @@ env:
|
||||
OCM_LINUX_X64_SHA256: b849b8de5d77e97e0df9319703254ae95e29d7f26a7552ea79bf173ff110ea0a
|
||||
KOVA_REPOSITORY: openclaw/Kova
|
||||
PERFORMANCE_MODEL_ID: gpt-5.5
|
||||
KOVA_SCENARIO_TIMEOUT_MS: "300000"
|
||||
|
||||
jobs:
|
||||
kova:
|
||||
@@ -98,7 +99,7 @@ jobs:
|
||||
live: "true"
|
||||
include_filters: "scenario:agent-cold-warm-message"
|
||||
env:
|
||||
KOVA_REF: ${{ inputs.kova_ref || 'b63b6f9e20efb23641df00487e982230d81a90ac' }}
|
||||
KOVA_REF: ${{ inputs.kova_ref || '4f146016583018bad9e24f8e64a6af5f963bb7ee' }}
|
||||
KOVA_HOME: ${{ github.workspace }}/.artifacts/kova/home/${{ matrix.lane }}
|
||||
PERFORMANCE_HELPER_DIR: ${{ github.workspace }}/.artifacts/performance-workflow
|
||||
REPORT_DIR: ${{ github.workspace }}/.artifacts/kova/reports/${{ matrix.lane }}
|
||||
@@ -291,6 +292,7 @@ jobs:
|
||||
--auth "$AUTH_MODE"
|
||||
--parallel 1
|
||||
--repeat "$repeat"
|
||||
--timeout-ms "$KOVA_SCENARIO_TIMEOUT_MS"
|
||||
--report-dir "$REPORT_DIR"
|
||||
--execute
|
||||
--json
|
||||
@@ -361,6 +363,7 @@ jobs:
|
||||
- Kova repository: ${KOVA_REPOSITORY}
|
||||
- Kova ref: ${KOVA_REF}
|
||||
- Kova profile: ${PROFILE}
|
||||
- Kova scenario timeout: ${KOVA_SCENARIO_TIMEOUT_MS}ms
|
||||
- Lane auth: ${AUTH_MODE}
|
||||
- Lane model: ${PERFORMANCE_MODEL_ID}
|
||||
- Lane repeat: ${repeat}
|
||||
|
||||
@@ -532,6 +532,7 @@ jobs:
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
|
||||
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.scenario || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -624,6 +625,7 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_DISCORD_CAPTURE_CONTENT: "1"
|
||||
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.discord_scenario || '' }}
|
||||
@@ -721,6 +723,7 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_WHATSAPP_CAPTURE_CONTENT: "1"
|
||||
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.whatsapp_scenario || '' }}
|
||||
@@ -815,6 +818,7 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_SLACK_CAPTURE_CONTENT: "1"
|
||||
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
|
||||
|
||||
@@ -117,11 +117,11 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Tests in a normal source checkout: `pnpm test <path-or-filter> [vitest args...]`, `pnpm test:changed`, `pnpm test:serial`, `pnpm test:coverage`; never raw `vitest`.
|
||||
- If raw Vitest is unavoidable, use `vitest run ...`; bare `vitest ...` starts local watch mode and will not exit on its own.
|
||||
- Tests in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm test*`; use `node scripts/run-vitest.mjs <path-or-filter>` for tiny explicit-file proof, or Crabbox/Testbox for anything broader.
|
||||
- Checks/lint in a normal source checkout: `pnpm check:changed` delegates to Crabbox/Testbox; lanes: `pnpm changed:lanes --json`; staged/path-scoped: `pnpm check:changed --staged` or `pnpm check:changed -- <files...>`; full `pnpm check`/`pnpm lint` only when required.
|
||||
- Checks in a normal source checkout: `pnpm check:changed` delegates to Crabbox/Testbox; lanes: `pnpm changed:lanes --json`; staged: `pnpm check:changed --staged`; full: `pnpm check`.
|
||||
- Checks in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm check*`; use `node scripts/crabbox-wrapper.mjs run ... -- env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed` so pnpm runs inside Testbox, not locally.
|
||||
- Extension tests: `pnpm test:extensions`, `pnpm test extensions`, `pnpm test extensions/<id>`.
|
||||
- Typecheck: `tsgo` lanes only (`pnpm tsgo*`, `pnpm check:test-types`); never add `tsc --noEmit`, `typecheck`, `check:types`.
|
||||
- Formatting: `oxfmt`, not Prettier. Use repo wrappers (`pnpm format:*`, `scripts/run-oxlint.mjs`; full `pnpm lint:*` only when scope requires).
|
||||
- Formatting: `oxfmt`, not Prettier. Use repo wrappers (`pnpm format:*`, `pnpm lint:*`, `scripts/run-oxlint.mjs`).
|
||||
- Build before push when build output, packaging, lazy/module boundaries, dynamic imports, or published surfaces can change.
|
||||
|
||||
## Validation
|
||||
|
||||
@@ -33,7 +33,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Complete contribution record
|
||||
|
||||
This audited record covers the complete v2026.6.8..HEAD history: 373 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~1 history: 375 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
|
||||
|
||||
#### Pull requests
|
||||
|
||||
@@ -410,6 +410,8 @@ This audited record covers the complete v2026.6.8..HEAD history: 373 merged PRs.
|
||||
- **PR #94118** [codex] Fix Telegram rich local Markdown link hrefs. Related #94117. Thanks @dankarization and @obviyus.
|
||||
- **PR #94646** refactor(sqlite): land database-first memory and proxy alignment. Thanks @vincentkoc.
|
||||
- **PR #94658** test(sqlite): use shared temp directory helper. Thanks @vincentkoc.
|
||||
- **PR #92135** fix(openai-embedding): preserve openai/ prefix for non-native base URLs. Related #92124. Thanks @xialonglee and @Kambrian.
|
||||
- **PR #93737** refactor: add session maintenance transaction seam. Thanks @jalehman.
|
||||
|
||||
## 2026.6.8
|
||||
|
||||
|
||||
@@ -898,32 +898,38 @@ private fun SettingsShellScreen(
|
||||
ProfilePanel(displayName = displayName.ifBlank { "OpenClaw" }, onClick = { onRouteChange(SettingsRoute.Profile) })
|
||||
}
|
||||
|
||||
item {
|
||||
SettingsGroup(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsRow("Profile", displayName.ifBlank { "Local device" }, Icons.Default.Person, route = SettingsRoute.Profile),
|
||||
SettingsRow("Voice", if (speakerEnabled) "Speaker on" else "Speaker muted", Icons.Default.Mic, route = SettingsRoute.Voice),
|
||||
SettingsRow("Agents", if (agents.isEmpty()) "Load from gateway" else "${agents.size} available", Icons.Default.Person, status = agents.isNotEmpty(), route = SettingsRoute.Agents),
|
||||
SettingsRow("Approvals", approvalsSummary(pendingToolCalls.size), Icons.Default.Lock, status = approvalsStatus(pendingToolCalls.size), route = SettingsRoute.Approvals),
|
||||
SettingsRow("Cron Jobs", cronJobsSummary(cronStatus.jobs), Icons.Outlined.AccessTime, status = if (cronStatus.jobs > 0) cronStatus.enabled else null, route = SettingsRoute.CronJobs),
|
||||
SettingsRow("Usage", usageSummaryText(usageSummary.providers.size), Icons.Default.Storage, status = if (usageSummary.providers.isNotEmpty()) true else null, route = SettingsRoute.Usage),
|
||||
SettingsRow("Skills", skillsSummaryText(skillsSummary.skills), Icons.Default.Settings, status = skillsStatus(skillsSummary.skills), route = SettingsRoute.Skills),
|
||||
SettingsRow("Nodes & Devices", nodesDevicesSummaryText(nodesDevicesSummary), Icons.Default.Cloud, status = nodesDevicesStatus(nodesDevicesSummary), route = SettingsRoute.NodesDevices),
|
||||
SettingsRow("Channels", channelsSummaryText(channelsSummary), Icons.Default.Notifications, status = channelsStatus(channelsSummary), route = SettingsRoute.Channels),
|
||||
SettingsRow("Dreaming", dreamingSummaryText(dreamingSummary), Icons.Default.Storage, status = dreamingStatus(dreamingSummary), route = SettingsRoute.Dreaming),
|
||||
SettingsRow("Canvas", "Screen surface", Icons.AutoMirrored.Filled.ScreenShare, status = isConnected, route = SettingsRoute.Canvas),
|
||||
SettingsRow("Notifications", if (notificationForwardingEnabled) "Smart delivery" else "Off", Icons.Default.Notifications, route = SettingsRoute.Notifications),
|
||||
SettingsRow("Phone Capabilities", if (cameraEnabled) "Camera enabled" else "Locked", Icons.Default.Lock, status = !cameraEnabled, route = SettingsRoute.PhoneCapabilities),
|
||||
SettingsRow("Gateway", gatewaySummary(statusText, isConnected), Icons.Default.Cloud, status = isConnected, route = SettingsRoute.Gateway),
|
||||
SettingsRow("Appearance", appearanceThemeSummary(appearanceThemeMode), Icons.Default.Palette, route = SettingsRoute.Appearance),
|
||||
SettingsRow("Health", "Diagnostics", Icons.Default.Settings, status = isConnected, route = SettingsRoute.Health),
|
||||
SettingsRow("About", "Version and update", Icons.Default.Storage, route = SettingsRoute.About),
|
||||
),
|
||||
onOpen = onRouteChange,
|
||||
val settingsRows =
|
||||
listOf(
|
||||
SettingsRow("Gateway", gatewaySummary(statusText, isConnected), Icons.Default.Cloud, status = isConnected, route = SettingsRoute.Gateway),
|
||||
SettingsRow("Nodes & Devices", nodesDevicesSummaryText(nodesDevicesSummary), Icons.Default.Cloud, status = nodesDevicesStatus(nodesDevicesSummary), route = SettingsRoute.NodesDevices),
|
||||
SettingsRow("Channels", channelsSummaryText(channelsSummary), Icons.Default.Notifications, status = channelsStatus(channelsSummary), route = SettingsRoute.Channels),
|
||||
SettingsRow("Agents", if (agents.isEmpty()) "Load from gateway" else "${agents.size} available", Icons.Default.Person, status = agents.isNotEmpty(), route = SettingsRoute.Agents),
|
||||
SettingsRow("Approvals", approvalsSummary(pendingToolCalls.size), Icons.Default.Lock, status = approvalsStatus(pendingToolCalls.size), route = SettingsRoute.Approvals),
|
||||
SettingsRow("Cron Jobs", cronJobsSummary(cronStatus.jobs), Icons.Outlined.AccessTime, status = if (cronStatus.jobs > 0) cronStatus.enabled else null, route = SettingsRoute.CronJobs),
|
||||
SettingsRow("Usage", usageSummaryText(usageSummary.providers.size), Icons.Default.Storage, status = if (usageSummary.providers.isNotEmpty()) true else null, route = SettingsRoute.Usage),
|
||||
SettingsRow("Skills", skillsSummaryText(skillsSummary.skills), Icons.Default.Settings, status = skillsStatus(skillsSummary.skills), route = SettingsRoute.Skills),
|
||||
SettingsRow("Dreaming", dreamingSummaryText(dreamingSummary), Icons.Default.Storage, status = dreamingStatus(dreamingSummary), route = SettingsRoute.Dreaming),
|
||||
SettingsRow("Voice", if (speakerEnabled) "Speaker on" else "Speaker muted", Icons.Default.Mic, route = SettingsRoute.Voice),
|
||||
SettingsRow("Canvas", "Screen surface", Icons.AutoMirrored.Filled.ScreenShare, status = isConnected, route = SettingsRoute.Canvas),
|
||||
SettingsRow("Notifications", if (notificationForwardingEnabled) "Smart delivery" else "Off", Icons.Default.Notifications, route = SettingsRoute.Notifications),
|
||||
SettingsRow("Phone Capabilities", if (cameraEnabled) "Camera enabled" else "Locked", Icons.Default.Lock, status = !cameraEnabled, route = SettingsRoute.PhoneCapabilities),
|
||||
SettingsRow("Appearance", appearanceThemeSummary(appearanceThemeMode), Icons.Default.Palette, route = SettingsRoute.Appearance),
|
||||
SettingsRow("About", "Version and update", Icons.Default.Storage, route = SettingsRoute.About),
|
||||
SettingsRow("Health", "Diagnostics", Icons.Default.Settings, status = isConnected, route = SettingsRoute.Health),
|
||||
)
|
||||
|
||||
settingsSections(settingsRows).forEach { section ->
|
||||
item {
|
||||
SettingsSectionTitle(section.title)
|
||||
}
|
||||
item {
|
||||
SettingsGroup(rows = section.rows, onOpen = onRouteChange)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
SettingsSectionTitle("Account")
|
||||
}
|
||||
item {
|
||||
SettingsGroup(
|
||||
rows = listOf(SettingsRow("Sign Out", "Disconnect", Icons.AutoMirrored.Filled.ExitToApp)),
|
||||
@@ -1057,7 +1063,7 @@ private fun dreamingStatus(summary: GatewayDreamingSummary): Boolean? =
|
||||
else -> null
|
||||
}
|
||||
|
||||
private data class SettingsRow(
|
||||
internal data class SettingsRow(
|
||||
val title: String,
|
||||
val value: String,
|
||||
val icon: ImageVector,
|
||||
@@ -1065,6 +1071,65 @@ private data class SettingsRow(
|
||||
val route: SettingsRoute? = null,
|
||||
)
|
||||
|
||||
internal data class SettingsSection(
|
||||
val title: String,
|
||||
val rows: List<SettingsRow>,
|
||||
)
|
||||
|
||||
internal fun settingsSections(rows: List<SettingsRow>): List<SettingsSection> =
|
||||
settingsSectionOrder.mapNotNull { title ->
|
||||
val sectionRows = rows.filter { row -> row.route?.let(::settingsSectionTitleForRoute) == title }
|
||||
if (sectionRows.isEmpty()) null else SettingsSection(title = title, rows = sectionRows)
|
||||
}
|
||||
|
||||
private val settingsSectionOrder =
|
||||
listOf(
|
||||
"Connection",
|
||||
"Agents & automation",
|
||||
"Phone context & privacy",
|
||||
"Profile & device",
|
||||
"Diagnostics",
|
||||
)
|
||||
|
||||
internal fun settingsSectionTitleForRoute(route: SettingsRoute): String =
|
||||
when (route) {
|
||||
SettingsRoute.Gateway,
|
||||
SettingsRoute.NodesDevices,
|
||||
SettingsRoute.Channels,
|
||||
-> "Connection"
|
||||
|
||||
SettingsRoute.Agents,
|
||||
SettingsRoute.Approvals,
|
||||
SettingsRoute.CronJobs,
|
||||
SettingsRoute.Usage,
|
||||
SettingsRoute.Skills,
|
||||
SettingsRoute.Dreaming,
|
||||
-> "Agents & automation"
|
||||
|
||||
SettingsRoute.Voice,
|
||||
SettingsRoute.Canvas,
|
||||
SettingsRoute.Notifications,
|
||||
SettingsRoute.PhoneCapabilities,
|
||||
-> "Phone context & privacy"
|
||||
|
||||
SettingsRoute.Profile,
|
||||
SettingsRoute.Appearance,
|
||||
SettingsRoute.About,
|
||||
-> "Profile & device"
|
||||
|
||||
SettingsRoute.Health -> "Diagnostics"
|
||||
SettingsRoute.Home -> "Diagnostics"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsSectionTitle(title: String) {
|
||||
Text(
|
||||
text = title.uppercase(),
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.sp, lineHeight = 16.sp),
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfilePanel(
|
||||
displayName: String,
|
||||
|
||||
@@ -7,6 +7,8 @@ import ai.openclaw.app.GatewayNodeApprovalState
|
||||
import ai.openclaw.app.GatewayNodeSummary
|
||||
import ai.openclaw.app.GatewayNodesDevicesSummary
|
||||
import ai.openclaw.app.GatewayPendingDeviceSummary
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
@@ -155,7 +157,46 @@ class ShellScreenLogicTest {
|
||||
assertEquals("Node approval pending", rows.single().subtitle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsSectionTitlesGroupPowerSettingsByMeaning() {
|
||||
assertEquals("Connection", settingsSectionTitleForRoute(SettingsRoute.Gateway))
|
||||
assertEquals("Connection", settingsSectionTitleForRoute(SettingsRoute.NodesDevices))
|
||||
assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.Approvals))
|
||||
assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.CronJobs))
|
||||
assertEquals("Phone context & privacy", settingsSectionTitleForRoute(SettingsRoute.PhoneCapabilities))
|
||||
assertEquals("Phone context & privacy", settingsSectionTitleForRoute(SettingsRoute.Notifications))
|
||||
assertEquals("Profile & device", settingsSectionTitleForRoute(SettingsRoute.Appearance))
|
||||
assertEquals("Diagnostics", settingsSectionTitleForRoute(SettingsRoute.Health))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsSectionsPreserveMeaningfulOrder() {
|
||||
val sections =
|
||||
settingsSections(
|
||||
listOf(
|
||||
settingsRow(SettingsRoute.Voice),
|
||||
settingsRow(SettingsRoute.Agents),
|
||||
settingsRow(SettingsRoute.Gateway),
|
||||
settingsRow(SettingsRoute.Appearance),
|
||||
settingsRow(SettingsRoute.Health),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"Connection",
|
||||
"Agents & automation",
|
||||
"Phone context & privacy",
|
||||
"Profile & device",
|
||||
"Diagnostics",
|
||||
),
|
||||
sections.map { it.title },
|
||||
)
|
||||
}
|
||||
|
||||
private fun emptyChannels(): GatewayChannelsSummary = GatewayChannelsSummary(channels = emptyList())
|
||||
|
||||
private fun emptyNodesDevices(): GatewayNodesDevicesSummary = GatewayNodesDevicesSummary(nodes = emptyList(), pendingDevices = emptyList(), pairedDevices = emptyList())
|
||||
|
||||
private fun settingsRow(route: SettingsRoute): SettingsRow = SettingsRow(route.name, "Value", Icons.Default.Settings, route = route)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ report_include:
|
||||
- Sources/**
|
||||
- ShareExtension/**
|
||||
- ActivityWidget/**
|
||||
- WatchExtension/Sources/**
|
||||
- WatchApp/Sources/**
|
||||
build_arguments:
|
||||
- -destination
|
||||
- generic/platform=iOS Simulator
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"signingRepo": "git@github.com:openclaw/apps-signing.git",
|
||||
"signingBranch": "main",
|
||||
"profileType": "appstore",
|
||||
"appGroupId": "group.ai.openclawfoundation.app.shared",
|
||||
"targets": [
|
||||
{
|
||||
"target": "OpenClaw",
|
||||
@@ -11,7 +12,8 @@
|
||||
"platform": "IOS",
|
||||
"profileKey": "OPENCLAW_APP_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app",
|
||||
"capabilities": ["PUSH_NOTIFICATIONS"]
|
||||
"capabilities": ["PUSH_NOTIFICATIONS", "APP_GROUPS"],
|
||||
"appGroups": ["group.ai.openclawfoundation.app.shared"]
|
||||
},
|
||||
{
|
||||
"target": "OpenClawShareExtension",
|
||||
@@ -20,7 +22,8 @@
|
||||
"platform": "IOS",
|
||||
"profileKey": "OPENCLAW_SHARE_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app.share",
|
||||
"capabilities": []
|
||||
"capabilities": ["APP_GROUPS"],
|
||||
"appGroups": ["group.ai.openclawfoundation.app.shared"]
|
||||
},
|
||||
{
|
||||
"target": "OpenClawActivityWidget",
|
||||
@@ -39,15 +42,6 @@
|
||||
"profileKey": "OPENCLAW_WATCH_APP_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app.watchkitapp",
|
||||
"capabilities": []
|
||||
},
|
||||
{
|
||||
"target": "OpenClawWatchExtension",
|
||||
"displayName": "OpenClaw Watch Extension",
|
||||
"bundleId": "ai.openclawfoundation.app.watchkitapp.extension",
|
||||
"platform": "IOS",
|
||||
"profileKey": "OPENCLAW_WATCH_EXTENSION_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app.watchkitapp.extension",
|
||||
"capabilities": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,12 +7,11 @@ OPENCLAW_DEVELOPMENT_TEAM = $(OPENCLAW_IOS_SELECTED_TEAM)
|
||||
OPENCLAW_CODE_SIGN_STYLE = Automatic
|
||||
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
|
||||
OPENCLAW_APP_GROUP_ID = group.ai.openclawfoundation.app.shared
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
|
||||
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
|
||||
OPENCLAW_WATCH_APP_PROFILE =
|
||||
OPENCLAW_WATCH_EXTENSION_PROFILE =
|
||||
|
||||
// Local contributors can override this by running scripts/ios-configure-signing.sh.
|
||||
// Keep include after defaults: xcconfig is evaluated top-to-bottom.
|
||||
|
||||
@@ -7,13 +7,12 @@ OPENCLAW_DEVELOPMENT_TEAM = YOUR_TEAM_ID
|
||||
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
|
||||
OPENCLAW_SHARE_BUNDLE_ID = ai.openclawfoundation.app.share
|
||||
OPENCLAW_APP_GROUP_ID = group.ai.openclawfoundation.app.shared
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
|
||||
|
||||
// Leave empty with automatic signing.
|
||||
OPENCLAW_APP_PROFILE =
|
||||
OPENCLAW_SHARE_PROFILE =
|
||||
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
|
||||
OPENCLAW_WATCH_APP_PROFILE =
|
||||
OPENCLAW_WATCH_EXTENSION_PROFILE =
|
||||
|
||||
@@ -101,6 +101,7 @@ Release-owner secrets:
|
||||
|
||||
- App Store Connect API auth uses Keychain for private key material plus non-secret `apps/ios/fastlane/.env` variables.
|
||||
- The encrypted signing repo password lives outside this repo in the release-owner vault and is exposed locally as `MATCH_PASSWORD`.
|
||||
- The share sheet requires the Apple Developer App Group in `apps/ios/Config/AppStoreSigning.json` to be associated with both the app and share-extension bundle IDs before App Store profiles are regenerated.
|
||||
- Apple Distribution private keys, certificates, provisioning profiles, and decrypted signing sync output stay under `apps/ios/build/` or Keychain and are gitignored.
|
||||
- Rotating release signing means refreshing Fastlane `match` assets and pushing a fresh encrypted sync state.
|
||||
|
||||
@@ -155,7 +156,8 @@ This should create `apps/ios/fastlane/.env` with non-secret App Store Connect va
|
||||
- `ai.openclawfoundation.app.share`
|
||||
- `ai.openclawfoundation.app.activitywidget`
|
||||
- `ai.openclawfoundation.app.watchkitapp`
|
||||
- `ai.openclawfoundation.app.watchkitapp.extension`
|
||||
|
||||
The main app and share extension must both be associated with the App Group pinned in `apps/ios/Config/AppStoreSigning.json`.
|
||||
|
||||
Use `pnpm ios:release:signing:setup` for the initial portal setup, then `MATCH_PASSWORD=... pnpm ios:release:signing:sync:push` to publish encrypted Fastlane match assets to the shared private repo.
|
||||
|
||||
|
||||
@@ -41,5 +41,7 @@
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||
</dict>
|
||||
<key>OpenClawAppGroupIdentifier</key>
|
||||
<string>$(OPENCLAW_APP_GROUP_ID)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
10
apps/ios/ShareExtension/OpenClawShareExtension.entitlements
Normal file
10
apps/ios/ShareExtension/OpenClawShareExtension.entitlements
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>$(OPENCLAW_APP_GROUP_ID)</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -10,8 +10,8 @@ OPENCLAW_DEVELOPMENT_TEAM = FWJYW4S8P8
|
||||
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
|
||||
OPENCLAW_SHARE_BUNDLE_ID = ai.openclawfoundation.app.share
|
||||
OPENCLAW_APP_GROUP_ID = group.ai.openclawfoundation.app.shared
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
|
||||
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT = development
|
||||
|
||||
@@ -19,7 +19,6 @@ OPENCLAW_APP_PROFILE = ai.openclawfoundation.app Development
|
||||
OPENCLAW_SHARE_PROFILE = ai.openclawfoundation.app.share Development
|
||||
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
|
||||
OPENCLAW_WATCH_APP_PROFILE =
|
||||
OPENCLAW_WATCH_EXTENSION_PROFILE =
|
||||
|
||||
// Keep local includes after defaults: xcconfig is evaluated top-to-bottom,
|
||||
// so later assignments in local files override the defaults above.
|
||||
|
||||
@@ -53,8 +53,7 @@ struct SettingsProTab: View {
|
||||
@State var suppressCredentialPersist = false
|
||||
@State var locationStatusText: String?
|
||||
@State var previousLocationModeRaw: String = OpenClawLocationMode.off.rawValue
|
||||
@State var notificationStatusText = "Checking"
|
||||
@State var notificationActionText = "Request Access"
|
||||
@State var notificationStatus: SettingsNotificationStatus = .checking
|
||||
@State var diagnosticsLastRunText = "Not run"
|
||||
@State var diagnosticsIssueCount: Int?
|
||||
@State var showTalkIssueDetails = false
|
||||
|
||||
@@ -65,7 +65,7 @@ extension SettingsProTab {
|
||||
title: "Notifications",
|
||||
detail: "Approval and event alert channel",
|
||||
value: self.notificationStatusText,
|
||||
color: self.notificationStatusText == "Allowed" ? OpenClawBrand.ok : .secondary)
|
||||
color: self.notificationStatus.color)
|
||||
Divider().padding(.leading, 60)
|
||||
self.diagnosticCheckRow(
|
||||
icon: "rectangle.on.rectangle",
|
||||
@@ -157,7 +157,7 @@ extension SettingsProTab {
|
||||
gatewayConnected: self.gatewayDiagnosticConnected,
|
||||
discoveredGatewayCount: self.gatewayController.gateways.count,
|
||||
talkConfigLoaded: self.gatewayDiagnosticTalkConfigLoaded,
|
||||
notificationStatusText: self.notificationStatusText)
|
||||
notificationsAllowed: self.notificationStatus == .allowed)
|
||||
self.diagnosticsIssueCount = issueCount
|
||||
self.diagnosticsLastRunText = SettingsDiagnostics.timestamp(Date())
|
||||
}
|
||||
@@ -422,8 +422,8 @@ extension SettingsProTab {
|
||||
}
|
||||
|
||||
func handleNotificationAction() {
|
||||
if self.notificationStatusText == "Allowed" || self.notificationStatusText == "Not Allowed" {
|
||||
self.openSystemSettings()
|
||||
if self.notificationStatus.shouldOpenNotificationSettings {
|
||||
self.openNotificationSettings()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -434,28 +434,14 @@ extension SettingsProTab {
|
||||
.sound,
|
||||
])) ?? false
|
||||
await MainActor.run {
|
||||
self.notificationStatusText = granted ? "Allowed" : "Not Allowed"
|
||||
self.notificationActionText = granted ? "Open System Settings" : "Open System Settings"
|
||||
self.notificationStatus = granted ? .allowed : .notAllowed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func applyNotificationStatus(_ status: UNAuthorizationStatus) {
|
||||
switch status {
|
||||
case .authorized, .provisional, .ephemeral:
|
||||
self.notificationStatusText = "Allowed"
|
||||
self.notificationActionText = "Open System Settings"
|
||||
case .denied:
|
||||
self.notificationStatusText = "Not Allowed"
|
||||
self.notificationActionText = "Open System Settings"
|
||||
case .notDetermined:
|
||||
self.notificationStatusText = "Not Set"
|
||||
self.notificationActionText = "Request Access"
|
||||
@unknown default:
|
||||
self.notificationStatusText = "Unknown"
|
||||
self.notificationActionText = "Open System Settings"
|
||||
}
|
||||
self.notificationStatus = SettingsNotificationStatus(status)
|
||||
}
|
||||
|
||||
func persistGatewayToken(_ value: String) {
|
||||
@@ -476,8 +462,8 @@ extension SettingsProTab {
|
||||
instanceId: instanceId)
|
||||
}
|
||||
|
||||
func openSystemSettings() {
|
||||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
||||
func openNotificationSettings() {
|
||||
guard let url = URL(string: UIApplication.openNotificationSettingsURLString) else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
|
||||
@@ -777,4 +763,12 @@ extension SettingsProTab {
|
||||
case .always: "Always"
|
||||
}
|
||||
}
|
||||
|
||||
var notificationStatusText: String {
|
||||
self.notificationStatus.text
|
||||
}
|
||||
|
||||
var notificationActionText: String {
|
||||
self.notificationStatus.actionTitle
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,7 +492,7 @@ extension SettingsProTab {
|
||||
title: "Notifications",
|
||||
detail: "Approvals and event alerts from OpenClaw.",
|
||||
value: self.notificationStatusText,
|
||||
color: self.notificationStatusText == "Allowed" ? OpenClawBrand.ok : .secondary)
|
||||
color: self.notificationStatus.color)
|
||||
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
@@ -501,7 +501,7 @@ extension SettingsProTab {
|
||||
} label: {
|
||||
Label(
|
||||
self.notificationActionText,
|
||||
systemImage: self.notificationStatusText == "Allowed" ? "gear" : "bell.badge")
|
||||
systemImage: self.notificationStatus.actionIcon)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Darwin
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
|
||||
enum SettingsRoute: Hashable {
|
||||
case gateway
|
||||
@@ -65,6 +66,63 @@ struct SettingsApprovalRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
enum SettingsNotificationStatus: Equatable {
|
||||
case checking
|
||||
case allowed
|
||||
case notAllowed
|
||||
case notSet
|
||||
case unknown
|
||||
|
||||
init(_ status: UNAuthorizationStatus) {
|
||||
switch status {
|
||||
case .authorized, .provisional, .ephemeral:
|
||||
self = .allowed
|
||||
case .denied:
|
||||
self = .notAllowed
|
||||
case .notDetermined:
|
||||
self = .notSet
|
||||
@unknown default:
|
||||
self = .unknown
|
||||
}
|
||||
}
|
||||
|
||||
var text: String {
|
||||
switch self {
|
||||
case .checking: "Checking"
|
||||
case .allowed: "Allowed"
|
||||
case .notAllowed: "Not Allowed"
|
||||
case .notSet: "Not Set"
|
||||
case .unknown: "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
var actionTitle: String {
|
||||
switch self {
|
||||
case .notSet, .checking:
|
||||
"Request Access"
|
||||
case .allowed, .notAllowed, .unknown:
|
||||
"Open System Settings"
|
||||
}
|
||||
}
|
||||
|
||||
var actionIcon: String {
|
||||
self == .allowed ? "gear" : "bell.badge"
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
self == .allowed ? OpenClawBrand.ok : .secondary
|
||||
}
|
||||
|
||||
var shouldOpenNotificationSettings: Bool {
|
||||
switch self {
|
||||
case .allowed, .notAllowed, .unknown:
|
||||
true
|
||||
case .checking, .notSet:
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SettingsDiagnosticIssue: String, Equatable, CaseIterable {
|
||||
case gatewayOffline
|
||||
case discoveryUnavailable
|
||||
@@ -77,13 +135,13 @@ enum SettingsDiagnostics {
|
||||
gatewayConnected: Bool,
|
||||
discoveredGatewayCount: Int,
|
||||
talkConfigLoaded: Bool,
|
||||
notificationStatusText: String) -> [SettingsDiagnosticIssue]
|
||||
notificationsAllowed: Bool) -> [SettingsDiagnosticIssue]
|
||||
{
|
||||
var issues: [SettingsDiagnosticIssue] = []
|
||||
if !gatewayConnected { issues.append(.gatewayOffline) }
|
||||
if discoveredGatewayCount == 0 { issues.append(.discoveryUnavailable) }
|
||||
if gatewayConnected, !talkConfigLoaded { issues.append(.talkConfigMissing) }
|
||||
if notificationStatusText != "Allowed" { issues.append(.notificationsUnavailable) }
|
||||
if !notificationsAllowed { issues.append(.notificationsUnavailable) }
|
||||
return issues
|
||||
}
|
||||
|
||||
@@ -91,13 +149,13 @@ enum SettingsDiagnostics {
|
||||
gatewayConnected: Bool,
|
||||
discoveredGatewayCount: Int,
|
||||
talkConfigLoaded: Bool,
|
||||
notificationStatusText: String) -> Int
|
||||
notificationsAllowed: Bool) -> Int
|
||||
{
|
||||
self.issues(
|
||||
gatewayConnected: gatewayConnected,
|
||||
discoveredGatewayCount: discoveredGatewayCount,
|
||||
talkConfigLoaded: talkConfigLoaded,
|
||||
notificationStatusText: notificationStatusText).count
|
||||
notificationsAllowed: notificationsAllowed).count
|
||||
}
|
||||
|
||||
static func timestamp(_ date: Date) -> String {
|
||||
|
||||
@@ -78,6 +78,8 @@
|
||||
<string>OpenClaw uses on-device speech recognition for talk mode and voice wake.</string>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>OpenClawAppGroupIdentifier</key>
|
||||
<string>$(OPENCLAW_APP_GROUP_ID)</string>
|
||||
<key>OpenClawCanonicalVersion</key>
|
||||
<string>$(OPENCLAW_IOS_VERSION)</string>
|
||||
<key>OpenClawPushAPNsEnvironment</key>
|
||||
|
||||
@@ -4,5 +4,9 @@
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>$(OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT)</string>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>$(OPENCLAW_APP_GROUP_ID)</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -109,10 +109,10 @@ Sources/Voice/VoiceWakePreferences.swift
|
||||
ShareExtension/ShareViewController.swift
|
||||
ActivityWidget/OpenClawActivityWidgetBundle.swift
|
||||
ActivityWidget/OpenClawLiveActivity.swift
|
||||
WatchExtension/Sources/OpenClawWatchApp.swift
|
||||
WatchExtension/Sources/WatchConnectivityReceiver.swift
|
||||
WatchExtension/Sources/WatchInboxStore.swift
|
||||
WatchExtension/Sources/WatchInboxView.swift
|
||||
WatchApp/Sources/OpenClawWatchApp.swift
|
||||
WatchApp/Sources/WatchConnectivityReceiver.swift
|
||||
WatchApp/Sources/WatchInboxStore.swift
|
||||
WatchApp/Sources/WatchInboxView.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift
|
||||
|
||||
@@ -8,7 +8,7 @@ import Testing
|
||||
gatewayConnected: false,
|
||||
discoveredGatewayCount: 0,
|
||||
talkConfigLoaded: false,
|
||||
notificationStatusText: "Not Set") == [
|
||||
notificationsAllowed: false) == [
|
||||
.gatewayOffline,
|
||||
.discoveryUnavailable,
|
||||
.notificationsUnavailable,
|
||||
@@ -21,12 +21,12 @@ import Testing
|
||||
gatewayConnected: true,
|
||||
discoveredGatewayCount: 1,
|
||||
talkConfigLoaded: false,
|
||||
notificationStatusText: "Allowed") == [.talkConfigMissing])
|
||||
notificationsAllowed: true) == [.talkConfigMissing])
|
||||
#expect(
|
||||
SettingsDiagnostics.issueCount(
|
||||
gatewayConnected: true,
|
||||
discoveredGatewayCount: 1,
|
||||
talkConfigLoaded: true,
|
||||
notificationStatusText: "Allowed") == 0)
|
||||
notificationsAllowed: true) == 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ import OpenClawKit
|
||||
import Testing
|
||||
|
||||
@Suite struct ShareToAgentDeepLinkTests {
|
||||
@Test func appGroupIdentifierUsesCanonicalOpenClawGroup() {
|
||||
#expect(OpenClawAppGroup.canonicalIdentifier == "group.ai.openclawfoundation.app.shared")
|
||||
}
|
||||
|
||||
@Test func buildMessageIncludesSharedFields() {
|
||||
let payload = SharedContentPayload(
|
||||
title: "Article",
|
||||
|
||||
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
@@ -20,9 +20,9 @@
|
||||
<string>$(OPENCLAW_MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(OPENCLAW_BUILD_VERSION)</string>
|
||||
<key>WKApplication</key>
|
||||
<true/>
|
||||
<key>WKCompanionAppBundleIdentifier</key>
|
||||
<string>$(OPENCLAW_APP_BUNDLE_ID)</string>
|
||||
<key>WKWatchKitApp</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1146,7 +1146,7 @@ private enum WatchNativeTextInput {
|
||||
suggestions: [String],
|
||||
onSubmit: @escaping (String) -> Void)
|
||||
{
|
||||
WKExtension.shared().visibleInterfaceController?.presentTextInputController(
|
||||
WKApplication.shared().visibleInterfaceController?.presentTextInputController(
|
||||
withSuggestions: suggestions,
|
||||
allowedInputMode: .allowEmoji)
|
||||
{ results in
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>OpenClaw</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(OPENCLAW_MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(OPENCLAW_BUILD_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>WKAppBundleIdentifier</key>
|
||||
<string>$(OPENCLAW_WATCH_APP_BUNDLE_ID)</string>
|
||||
</dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.watchkit</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -293,6 +293,8 @@ def capture_watch_screenshot
|
||||
Dir[File.join(output_dir, "Apple Watch*-*.png")].each { |path| FileUtils.rm_f(path) }
|
||||
FileUtils.rm_rf(derived_data_path)
|
||||
|
||||
# Single-target watch apps only expose generic simulator build destinations in Xcode.
|
||||
# Keep the selected UDID for install/launch/screenshot below.
|
||||
sh(
|
||||
xcodebuild_shell_join([
|
||||
"xcodebuild",
|
||||
@@ -303,7 +305,7 @@ def capture_watch_screenshot
|
||||
"-configuration",
|
||||
"Debug",
|
||||
"-destination",
|
||||
"platform=watchOS Simulator,id=#{udid}",
|
||||
"generic/platform=watchOS Simulator",
|
||||
"-derivedDataPath",
|
||||
derived_data_path,
|
||||
"build",
|
||||
@@ -311,10 +313,8 @@ def capture_watch_screenshot
|
||||
)
|
||||
|
||||
UI.user_error!("Watch screenshot build did not produce #{app_path}.") unless File.exist?(app_path)
|
||||
extension_path = File.join(app_path, "PlugIns", "OpenClawWatchExtension.appex")
|
||||
watch_app_identifier = bundle_identifier_for_product(app_path)
|
||||
watch_extension_identifier = bundle_identifier_for_product(extension_path)
|
||||
screenshot_mode_bundle_identifiers = [watch_app_identifier, watch_extension_identifier]
|
||||
screenshot_mode_bundle_identifiers = [watch_app_identifier]
|
||||
|
||||
sh("#{shell_join(["xcrun", "simctl", "boot", udid])} >/dev/null 2>&1 || true")
|
||||
sh(shell_join(["xcrun", "simctl", "bootstatus", udid, "-b"]))
|
||||
@@ -492,6 +492,9 @@ def produce_services_for_target(target)
|
||||
if target.fetch("capabilities").include?("PUSH_NOTIFICATIONS")
|
||||
services[:push_notification] = "on"
|
||||
end
|
||||
if target.fetch("capabilities").include?("APP_GROUPS")
|
||||
services[:app_group] = "on"
|
||||
end
|
||||
services
|
||||
end
|
||||
|
||||
@@ -567,6 +570,15 @@ def profile_plist_value(profile_path, key_path)
|
||||
end
|
||||
end
|
||||
|
||||
def profile_plist_array_values(profile_path, key_path)
|
||||
raw = profile_plist_value(profile_path, key_path)
|
||||
return [] unless raw
|
||||
|
||||
raw.lines.map(&:strip).reject do |line|
|
||||
line.empty? || line == "Array {" || line == "}"
|
||||
end
|
||||
end
|
||||
|
||||
def validate_match_profile_capabilities!(target)
|
||||
capabilities = target.fetch("capabilities")
|
||||
return if capabilities.empty?
|
||||
@@ -582,6 +594,17 @@ def validate_match_profile_capabilities!(target)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
if capabilities.include?("APP_GROUPS")
|
||||
expected_app_groups = target.fetch("appGroups")
|
||||
actual_app_groups = profile_plist_array_values(profile_path, "Entitlements:com.apple.security.application-groups")
|
||||
missing = expected_app_groups - actual_app_groups
|
||||
unless missing.empty?
|
||||
UI.user_error!(
|
||||
"Provisioning profile #{target.fetch("profileName")} for #{target.fetch("bundleId")} is missing App Groups #{missing.join(", ")}; actual groups: #{actual_app_groups.empty? ? "missing" : actual_app_groups.join(", ")}."
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def sync_app_store_signing!(readonly:)
|
||||
|
||||
@@ -65,7 +65,7 @@ pnpm ios:release:signing:check
|
||||
pnpm ios:release:signing:setup
|
||||
```
|
||||
|
||||
`signing:setup` uses Fastlane `produce` and `modify_services` to create Developer Portal bundle IDs and enable required services before running `match`. If Fastlane does not already have a valid Apple Developer Portal session, run `fastlane spaceauth` for a release-owner Apple ID and export the resulting `FASTLANE_SESSION`.
|
||||
`signing:setup` uses Fastlane `produce` and `modify_services` to create Developer Portal bundle IDs and enable required services before running `match`. The main app and share extension also require the shared App Group from `apps/ios/Config/AppStoreSigning.json`; associate that group with both bundle IDs in the Apple Developer Portal before regenerating profiles. If Fastlane does not already have a valid Apple Developer Portal session, run `fastlane spaceauth` for a release-owner Apple ID and export the resulting `FASTLANE_SESSION`.
|
||||
|
||||
Shared encrypted signing storage:
|
||||
|
||||
|
||||
@@ -65,6 +65,8 @@ targets:
|
||||
embed: true
|
||||
- target: OpenClawActivityWidget
|
||||
embed: true
|
||||
# A companion watch application belongs in the standard Watch bundle location.
|
||||
# PlugIns is for extension products and breaks paired watch installation.
|
||||
- target: OpenClawWatchApp
|
||||
- package: OpenClawKit
|
||||
- package: OpenClawKit
|
||||
@@ -88,7 +90,7 @@ targets:
|
||||
exit 1
|
||||
fi
|
||||
swiftformat --lint --config "$SRCROOT/../../config/swiftformat" \
|
||||
--unexclude "$SRCROOT/Sources,$SRCROOT/ShareExtension,$SRCROOT/ActivityWidget,$SRCROOT/WatchExtension,$SRCROOT/../shared/OpenClawKit,$SRCROOT/../swabble" \
|
||||
--unexclude "$SRCROOT/Sources,$SRCROOT/ShareExtension,$SRCROOT/ActivityWidget,$SRCROOT/WatchApp,$SRCROOT/../shared/OpenClawKit,$SRCROOT/../swabble" \
|
||||
--filelist "$SRCROOT/SwiftSources.input.xcfilelist"
|
||||
- name: SwiftLint
|
||||
basedOnDependencyAnalysis: false
|
||||
@@ -140,6 +142,7 @@ targets:
|
||||
- openclaw
|
||||
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
|
||||
OpenClawCanonicalVersion: "$(OPENCLAW_IOS_VERSION)"
|
||||
OpenClawAppGroupIdentifier: "$(OPENCLAW_APP_GROUP_ID)"
|
||||
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
|
||||
UILaunchScreen: {}
|
||||
UIApplicationSceneManifest:
|
||||
@@ -192,6 +195,7 @@ targets:
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
|
||||
CODE_SIGN_ENTITLEMENTS: ShareExtension/OpenClawShareExtension.entitlements
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
ENABLE_APPINTENTS_METADATA: NO
|
||||
@@ -206,6 +210,7 @@ targets:
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw Share
|
||||
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
|
||||
OpenClawAppGroupIdentifier: "$(OPENCLAW_APP_GROUP_ID)"
|
||||
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
|
||||
NSExtension:
|
||||
NSExtensionPointIdentifier: com.apple.share-services
|
||||
@@ -251,13 +256,17 @@ targets:
|
||||
NSExtensionPointIdentifier: com.apple.widgetkit-extension
|
||||
|
||||
OpenClawWatchApp:
|
||||
type: application.watchapp2
|
||||
type: application
|
||||
platform: watchOS
|
||||
deploymentTarget: "11.0"
|
||||
sources:
|
||||
- path: WatchApp
|
||||
excludes:
|
||||
- Info.plist
|
||||
dependencies:
|
||||
- target: OpenClawWatchExtension
|
||||
- sdk: AppIntents.framework
|
||||
- sdk: WatchConnectivity.framework
|
||||
- sdk: UserNotifications.framework
|
||||
configFiles:
|
||||
Debug: Config/Signing.xcconfig
|
||||
Release: Config/Signing.xcconfig
|
||||
@@ -274,6 +283,8 @@ targets:
|
||||
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
|
||||
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_WATCH_APP_PROFILE)"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
SWIFT_VERSION: "6.0"
|
||||
info:
|
||||
path: WatchApp/Info.plist
|
||||
properties:
|
||||
@@ -281,42 +292,7 @@ targets:
|
||||
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
|
||||
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
|
||||
WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)"
|
||||
WKWatchKitApp: true
|
||||
|
||||
OpenClawWatchExtension:
|
||||
type: watchkit2-extension
|
||||
platform: watchOS
|
||||
deploymentTarget: "11.0"
|
||||
sources:
|
||||
- path: WatchExtension/Sources
|
||||
- path: WatchExtension/Assets.xcassets
|
||||
dependencies:
|
||||
- sdk: AppIntents.framework
|
||||
- sdk: WatchConnectivity.framework
|
||||
- sdk: UserNotifications.framework
|
||||
configFiles:
|
||||
Debug: Config/Signing.xcconfig
|
||||
Release: Config/Signing.xcconfig
|
||||
attributes:
|
||||
DevelopmentTeam: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
ProvisioningStyle: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_EXTENSION_BUNDLE_ID)"
|
||||
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_WATCH_EXTENSION_PROFILE)"
|
||||
info:
|
||||
path: WatchExtension/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw
|
||||
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
|
||||
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
|
||||
NSExtension:
|
||||
NSExtensionAttributes:
|
||||
WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
|
||||
NSExtensionPointIdentifier: com.apple.watchkit
|
||||
WKApplication: true
|
||||
|
||||
OpenClawTests:
|
||||
type: bundle.unit-test
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import AppKit
|
||||
import WebKit
|
||||
|
||||
extension CanvasWindowController {
|
||||
// MARK: - WKUIDelegate
|
||||
|
||||
/// Bridges `<input type="file">` clicks in canvas HTML to a native `NSOpenPanel`.
|
||||
/// Without a `WKUIDelegate`, WebKit silently drops the request and file-picker
|
||||
/// buttons in canvas pages do nothing.
|
||||
@MainActor
|
||||
func webView(
|
||||
_ webView: WKWebView,
|
||||
runOpenPanelWith parameters: WKOpenPanelParameters,
|
||||
initiatedByFrame frame: WKFrameInfo,
|
||||
completionHandler: @escaping @MainActor @Sendable ([URL]?) -> Void)
|
||||
{
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = parameters.allowsDirectories
|
||||
panel.allowsMultipleSelection = parameters.allowsMultipleSelection
|
||||
panel.resolvesAliases = true
|
||||
if let window = self.window {
|
||||
panel.beginSheetModal(for: window) { response in
|
||||
completionHandler(response == .OK ? panel.urls : nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
panel.begin { response in
|
||||
completionHandler(response == .OK ? panel.urls : nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import OpenClawKit
|
||||
import WebKit
|
||||
|
||||
@MainActor
|
||||
final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate {
|
||||
final class CanvasWindowController: NSWindowController, WKNavigationDelegate, WKUIDelegate, NSWindowDelegate {
|
||||
let sessionKey: String
|
||||
private let root: URL
|
||||
private let sessionDir: URL
|
||||
@@ -159,6 +159,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
}
|
||||
|
||||
self.webView.navigationDelegate = self
|
||||
self.webView.uiDelegate = self
|
||||
self.window?.delegate = self
|
||||
self.container.onClose = { [weak self] in
|
||||
self?.hideCanvas()
|
||||
|
||||
@@ -19,7 +19,7 @@ private final class DashboardWindowDragRegionView: NSView {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class DashboardWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate {
|
||||
final class DashboardWindowController: NSWindowController, WKNavigationDelegate, WKUIDelegate, NSWindowDelegate {
|
||||
private let webView: WKWebView
|
||||
private var currentURL: URL
|
||||
private var auth: DashboardWindowAuth
|
||||
@@ -44,9 +44,37 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
|
||||
super.init(window: window)
|
||||
|
||||
self.webView.navigationDelegate = self
|
||||
self.webView.uiDelegate = self
|
||||
self.window?.delegate = self
|
||||
}
|
||||
|
||||
// MARK: - WKUIDelegate
|
||||
|
||||
/// Bridges `<input type="file">` clicks in the embedded Control UI to a native
|
||||
/// `NSOpenPanel`; without a `WKUIDelegate`, WebKit silently drops the request
|
||||
/// and "Choose image" / file-picker buttons do nothing.
|
||||
func webView(
|
||||
_ webView: WKWebView,
|
||||
runOpenPanelWith parameters: WKOpenPanelParameters,
|
||||
initiatedByFrame frame: WKFrameInfo,
|
||||
completionHandler: @escaping @MainActor @Sendable ([URL]?) -> Void)
|
||||
{
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = parameters.allowsDirectories
|
||||
panel.allowsMultipleSelection = parameters.allowsMultipleSelection
|
||||
panel.resolvesAliases = true
|
||||
if let window = self.window {
|
||||
panel.beginSheetModal(for: window) { response in
|
||||
completionHandler(response == .OK ? panel.urls : nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
panel.begin { response in
|
||||
completionHandler(response == .OK ? panel.urls : nil)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawAppGroup {
|
||||
public static let canonicalIdentifier = "group.ai.openclawfoundation.app.shared"
|
||||
|
||||
public static var identifier: String {
|
||||
let raw = Bundle.main.object(forInfoDictionaryKey: "OpenClawAppGroupIdentifier") as? String
|
||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? self.canonicalIdentifier : trimmed
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ public struct ShareGatewayRelayConfig: Codable, Sendable, Equatable {
|
||||
}
|
||||
|
||||
public enum ShareGatewayRelaySettings {
|
||||
private static let suiteName = "group.ai.openclaw.shared"
|
||||
private static var suiteName: String { OpenClawAppGroup.identifier }
|
||||
private static let relayConfigKey = "share.gatewayRelay.config.v1"
|
||||
private static let lastEventKey = "share.gatewayRelay.event.v1"
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
public enum ShareToAgentSettings {
|
||||
private static let suiteName = "group.ai.openclaw.shared"
|
||||
private static var suiteName: String { OpenClawAppGroup.identifier }
|
||||
private static let defaultInstructionKey = "share.defaultInstruction"
|
||||
|
||||
private static var defaults: UserDefaults {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
e78623d6eace69e46950cd5d9a5cf14aa910dac1ecdf9d054a0bd9999e936061 config-baseline.json
|
||||
5ecafa3c9a59fc0675f964f6e3238b2f20625376ebad1835278c5dd7323770d3 config-baseline.core.json
|
||||
ac06b6c20a93a8543ec1bd3748ef4f7bdae5006839dd93b3fff874d0da4244aa config-baseline.json
|
||||
e7965566fdaedef445bcd562141f4f3ea1a499cf8ea5956418af7c98049bf242 config-baseline.core.json
|
||||
2d735389858305509528e74329b6f8c65d311e1471c3b4e91dc17aaab8e63a80 config-baseline.channel.json
|
||||
7c2c51b795d32e4c4c325080d59fec8fd11317c41db7db642f70e436779738bc config-baseline.plugin.json
|
||||
0039da0cf2ba2845b37db52c4cf3a0f25e367cf3d2d507c5d6f8a5e5bdfdc4d4 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
f24065e760a9fafbd2a50962beba4d752b2d6166043170d37cdd6137640e7eef plugin-sdk-api-baseline.json
|
||||
89a332c206f639d5faef730bac2d23f75751b306419e5dfeae1b731166bbc41c plugin-sdk-api-baseline.jsonl
|
||||
c0ead0a6a428d4517c7ee5f09aa0151ba18f7051bc5c9806562dec544dfad20b plugin-sdk-api-baseline.json
|
||||
d4a0b6915c2ec8c68371b18b7a0999e48678ee243e7e9d41932d4d96390540cf plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -315,7 +315,7 @@ Current existing-session limits:
|
||||
- `hover`, `scrollintoview`, `drag`, `select`, `fill`, and `evaluate` reject
|
||||
per-call timeout overrides
|
||||
- `select` supports one value only
|
||||
- `wait --load networkidle` is not supported
|
||||
- `wait --load networkidle` is not supported on existing-session profiles (works on managed and raw/remote CDP)
|
||||
- file uploads require `--ref` / `--input-ref`, do not support CSS
|
||||
`--element`, and currently support one file at a time
|
||||
- dialog hooks do not support `--timeout`
|
||||
|
||||
@@ -59,6 +59,9 @@ selected-category counts and missing coverage IDs; the individual evidence
|
||||
entries remain the source of truth for the tests, coverage roles, and results.
|
||||
Taxonomy feature coverage IDs are exact proof targets, not aliases. Primary
|
||||
scenario coverage fulfills matching IDs; secondary coverage stays advisory.
|
||||
Coverage IDs use dotted `namespace.behavior` form with lowercase
|
||||
alphanumeric/dash segments; profile, surface, and category IDs may still use
|
||||
the existing dashed or dotted taxonomy IDs.
|
||||
Slim evidence omits per-entry `execution` and sets `evidenceMode: "slim"`;
|
||||
`smoke-ci` defaults to slim, and `--evidence-mode full` restores full entries:
|
||||
|
||||
|
||||
@@ -315,9 +315,15 @@ The same section also includes the OpenClaw source location. Git checkouts expos
|
||||
source root so the agent can inspect code directly. Package installs include the GitHub
|
||||
source URL and tell the agent to review source there whenever the docs are incomplete or
|
||||
stale. The prompt also notes the public docs mirror, community Discord, and ClawHub
|
||||
([https://clawhub.ai](https://clawhub.ai)) for skills discovery. It tells the model to
|
||||
consult docs first for OpenClaw behavior, commands, configuration, or architecture, and to
|
||||
run `openclaw status` itself when possible (asking the user only when it lacks access).
|
||||
([https://clawhub.ai](https://clawhub.ai)) for skills discovery. It frames docs as the
|
||||
authority for OpenClaw self-knowledge before the model understands how OpenClaw works,
|
||||
including memory/daily notes, sessions, tools, Gateway, config, commands, or project
|
||||
context. The prompt tells the model to use local docs (or the docs mirror when local docs
|
||||
are unavailable) first, and to treat AGENTS.md, project context, workspace/profile/memory
|
||||
notes, and `memory_search` as instruction context or user memory rather than OpenClaw
|
||||
design or implementation knowledge. If docs are silent or stale, the model should say so
|
||||
and inspect source. The prompt also tells the model to run `openclaw status` itself when
|
||||
possible, asking the user only when it lacks access.
|
||||
For configuration specifically, it points agents to the `gateway` tool action
|
||||
`config.schema.lookup` for exact field-level docs and constraints, then to
|
||||
`docs/gateway/configuration.md` and `docs/gateway/configuration-reference.md`
|
||||
|
||||
@@ -1096,6 +1096,7 @@ Notes:
|
||||
traces: true,
|
||||
metrics: true,
|
||||
logs: false,
|
||||
logsExporter: "otlp",
|
||||
sampleRate: 1.0,
|
||||
flushIntervalMs: 5000,
|
||||
captureContent: {
|
||||
@@ -1132,6 +1133,7 @@ Notes:
|
||||
- `otel.headers`: extra HTTP/gRPC metadata headers sent with OTel export requests.
|
||||
- `otel.serviceName`: service name for resource attributes.
|
||||
- `otel.traces` / `otel.metrics` / `otel.logs`: enable trace, metrics, or log export.
|
||||
- `otel.logsExporter`: log export sink: `"otlp"` (default), `"stdout"` for one JSON object per stdout line, or `"both"`.
|
||||
- `otel.sampleRate`: trace sampling rate `0`-`1`.
|
||||
- `otel.flushIntervalMs`: periodic telemetry flush interval in ms.
|
||||
- `otel.captureContent`: opt-in raw content capture for OTEL span attributes. Defaults to off. Boolean `true` captures non-system message/tool content; the object form lets you enable `inputMessages`, `outputMessages`, `toolInputs`, `toolOutputs`, `systemPrompt`, and `toolDefinitions` explicitly.
|
||||
|
||||
@@ -397,6 +397,7 @@ That stages grounded durable candidates into the short-term dreaming store while
|
||||
- **State dir permissions**: verifies writability; offers to repair permissions (and emits a `chown` hint when owner/group mismatch is detected).
|
||||
- **macOS cloud-synced state dir**: warns when state resolves under iCloud Drive (`~/Library/Mobile Documents/com~apple~CloudDocs/...`) or `~/Library/CloudStorage/...` because sync-backed paths can cause slower I/O and lock/sync races.
|
||||
- **Linux SD or eMMC state dir**: warns when state resolves to an `mmcblk*` mount source, because SD or eMMC-backed random I/O can be slower and wear faster under session and credential writes.
|
||||
- **Linux volatile state dir**: warns when state resolves to `tmpfs` or `ramfs`, because sessions, credentials, config, and SQLite state with its WAL/journal sidecars will disappear on reboot. Docker `overlay` mounts are intentionally not flagged because their writable layers persist across host reboots while the container remains.
|
||||
- **Session dirs missing**: `sessions/` and the session store directory are required to persist history and avoid `ENOENT` crashes.
|
||||
- **Transcript mismatch**: warns when recent session entries have missing transcript files.
|
||||
- **Main session "1-line JSONL"**: flags when the main transcript has only one line (history is not accumulating).
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Export OpenClaw diagnostics to any OpenTelemetry collector via the diagnostics-otel plugin (OTLP/HTTP)"
|
||||
summary: "Export OpenClaw diagnostics to OpenTelemetry collectors or stdout JSONL via the diagnostics-otel plugin"
|
||||
title: "OpenTelemetry export"
|
||||
read_when:
|
||||
- You want to send OpenClaw model usage, message flow, or session metrics to an OpenTelemetry collector
|
||||
@@ -8,9 +8,10 @@ read_when:
|
||||
---
|
||||
|
||||
OpenClaw exports diagnostics through the official `diagnostics-otel` plugin
|
||||
using **OTLP/HTTP (protobuf)**. Any collector or backend that accepts OTLP/HTTP
|
||||
works without code changes. For local file logs and how to read them, see
|
||||
[Logging](/logging).
|
||||
using **OTLP/HTTP (protobuf)**. Logs can also be written as stdout JSONL for
|
||||
container and sandbox log pipelines. Any collector or backend that accepts
|
||||
OTLP/HTTP works without code changes. For local file logs and how to read them,
|
||||
see [Logging](/logging).
|
||||
|
||||
## How it fits together
|
||||
|
||||
@@ -18,7 +19,8 @@ works without code changes. For local file logs and how to read them, see
|
||||
Gateway and bundled plugins for model runs, message flow, sessions, queues,
|
||||
and exec.
|
||||
- **`diagnostics-otel` plugin** subscribes to those events and exports them as
|
||||
OpenTelemetry **metrics**, **traces**, and **logs** over OTLP/HTTP.
|
||||
OpenTelemetry **metrics**, **traces**, and **logs** over OTLP/HTTP. It can
|
||||
also mirror diagnostic log records to stdout JSONL.
|
||||
- **Provider calls** receive a W3C `traceparent` header from OpenClaw's
|
||||
trusted model-call span context when the provider transport accepts custom
|
||||
headers. Plugin-emitted trace context is not propagated.
|
||||
@@ -74,11 +76,13 @@ openclaw plugins enable diagnostics-otel
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Metrics** | Counters and histograms for token usage, cost, run duration, failover, skill usage, message flow, Talk events, queue lanes, session state/recovery, tool execution, oversized payloads, exec, and memory pressure. |
|
||||
| **Traces** | Spans for model usage, model calls, harness lifecycle, skill usage, tool execution, exec, webhook/message processing, context assembly, and tool loops. |
|
||||
| **Logs** | Structured `logging.file` records exported over OTLP when `diagnostics.otel.logs` is enabled; log bodies are withheld unless content capture is explicitly enabled. |
|
||||
| **Logs** | Structured `logging.file` records exported over OTLP or stdout JSONL when `diagnostics.otel.logs` is enabled; log bodies are withheld unless content capture is explicitly enabled. |
|
||||
|
||||
Toggle `traces`, `metrics`, and `logs` independently. Traces and metrics
|
||||
default to on when `diagnostics.otel.enabled` is true. Logs default to off and
|
||||
are exported only when `diagnostics.otel.logs` is explicitly `true`.
|
||||
are exported only when `diagnostics.otel.logs` is explicitly `true`. Log export
|
||||
defaults to OTLP; set `diagnostics.otel.logsExporter` to `stdout` for JSONL on
|
||||
stdout, or `both` to send each diagnostic log record to OTLP and stdout.
|
||||
|
||||
## Configuration reference
|
||||
|
||||
@@ -98,6 +102,7 @@ are exported only when `diagnostics.otel.logs` is explicitly `true`.
|
||||
traces: true,
|
||||
metrics: true,
|
||||
logs: true,
|
||||
logsExporter: "otlp", // otlp | stdout | both
|
||||
sampleRate: 0.2, // root-span sampler, 0.0..1.0
|
||||
flushIntervalMs: 60000, // metric export interval (min 1000ms)
|
||||
captureContent: {
|
||||
@@ -176,6 +181,11 @@ on the public diagnostic event bus.
|
||||
- **Logs:** OTLP logs respect `logging.level` (file log level). They use the
|
||||
diagnostic log-record redaction path, not console formatting. High-volume
|
||||
installs should prefer OTLP collector sampling/filtering over local sampling.
|
||||
Set `diagnostics.otel.logsExporter: "stdout"` when your platform already
|
||||
ships stdout/stderr to a log processor and you do not have an OTLP logs
|
||||
collector. Stdout records are one JSON object per line with `ts`, `signal`,
|
||||
`service.name`, severity, body, redacted attributes, and trusted trace fields
|
||||
when available.
|
||||
- **File-log correlation:** JSONL file logs include top-level `traceId`,
|
||||
`spanId`, `parentSpanId`, and `traceFlags` when the log call carries a valid
|
||||
diagnostic trace context, which lets log processors join local log lines with
|
||||
|
||||
@@ -224,8 +224,10 @@ model-call traces become children of the active request trace, so local logs,
|
||||
diagnostic snapshots, OTEL spans, and trusted provider `traceparent` headers can
|
||||
be joined by `traceId` without logging raw request or model content.
|
||||
|
||||
Talk lifecycle log records also flow to OTLP logs when OpenTelemetry log export
|
||||
is enabled, using the same bounded attributes as file logs.
|
||||
Talk lifecycle log records also flow to diagnostics-otel log export when
|
||||
OpenTelemetry log export is enabled, using the same bounded attributes as file
|
||||
logs. Configure `diagnostics.otel.logsExporter` to choose OTLP, stdout JSONL, or
|
||||
both sinks.
|
||||
|
||||
### Model call size and timing
|
||||
|
||||
|
||||
@@ -91,8 +91,8 @@ Supported `appServer` fields:
|
||||
| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary. |
|
||||
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
|
||||
| `url` | unset | WebSocket app-server URL. |
|
||||
| `authToken` | unset | Bearer token for WebSocket transport. |
|
||||
| `headers` | `{}` | Extra WebSocket headers. |
|
||||
| `authToken` | unset | Bearer token for WebSocket transport. Accepts a literal string or SecretInput such as `${CODEX_APP_SERVER_TOKEN}`. |
|
||||
| `headers` | `{}` | Extra WebSocket headers. Header values accept literal strings or SecretInput values, for example `x-codex-client-session-token: "${CODEX_CLIENT_SESSION_TOKEN}"`. |
|
||||
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. |
|
||||
| `remoteWorkspaceRoot` | unset | Remote Codex app-server workspace root. When set, OpenClaw infers the local workspace root from the resolved OpenClaw workspace, preserves the current cwd suffix under this remote root, and sends only the final app-server cwd to Codex. If the cwd is outside the resolved OpenClaw workspace root, OpenClaw fails closed instead of sending a gateway-local path to the remote app-server. |
|
||||
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
|
||||
@@ -149,11 +149,15 @@ must report stable version `0.125.0` or newer.
|
||||
|
||||
OpenClaw treats non-loopback WebSocket app-server URLs as remote and requires
|
||||
identity-bearing WebSocket auth through `appServer.authToken` or an
|
||||
`Authorization` header. When native Codex plugins are configured, OpenClaw uses
|
||||
the connected app-server's plugin control plane to install or refresh those
|
||||
plugins and then refreshes app inventory so plugin-owned apps are visible to the
|
||||
Codex thread. Only connect OpenClaw to remote app-servers that are trusted to
|
||||
accept OpenClaw-managed plugin installs and app inventory refreshes.
|
||||
`Authorization` header. `appServer.authToken` and each `appServer.headers.*`
|
||||
value can be a SecretInput; the secrets runtime resolves SecretRefs and env
|
||||
shorthand before OpenClaw builds app-server start options, and unresolved
|
||||
structured SecretRefs fail before any token or header is sent. When native Codex
|
||||
plugins are configured, OpenClaw uses the connected app-server's plugin control
|
||||
plane to install or refresh those plugins and then refreshes app inventory so
|
||||
plugin-owned apps are visible to the Codex thread. Only connect OpenClaw to
|
||||
remote app-servers that are trusted to accept OpenClaw-managed plugin installs
|
||||
and app inventory refreshes.
|
||||
|
||||
## Approval and sandbox modes
|
||||
|
||||
|
||||
@@ -552,8 +552,8 @@ Supported `appServer` fields:
|
||||
| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary; set it only for an explicit override. |
|
||||
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
|
||||
| `url` | unset | WebSocket app-server URL. |
|
||||
| `authToken` | unset | Bearer token for WebSocket transport. |
|
||||
| `headers` | `{}` | Extra WebSocket headers. |
|
||||
| `authToken` | unset | Bearer token for WebSocket transport. Accepts a literal string or SecretInput such as `${CODEX_APP_SERVER_TOKEN}`. |
|
||||
| `headers` | `{}` | Extra WebSocket headers. Header values accept literal strings or SecretInput values, for example `x-codex-client-session-token: "${CODEX_CLIENT_SESSION_TOKEN}"`. |
|
||||
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. OpenClaw keeps per-agent `CODEX_HOME` and inherited `HOME` for local launches. |
|
||||
| `codeModeOnly` | `false` | Opt into Codex's code-mode-only tool surface. OpenClaw dynamic tools remain registered with Codex so nested `tools.*` calls return through the app-server `item/tool/call` bridge. |
|
||||
| `remoteWorkspaceRoot` | unset | Remote Codex app-server workspace root. When set, OpenClaw infers the local workspace root from the resolved OpenClaw workspace, preserves the current cwd suffix under this remote root, and sends only the final app-server cwd to Codex. If the cwd is outside the resolved OpenClaw workspace root, OpenClaw fails closed instead of sending a gateway-local path to the remote app-server. |
|
||||
|
||||
@@ -227,7 +227,7 @@ Each entry lists the package, distribution route, and description.
|
||||
|
||||
- **[deepseek](/plugins/reference/deepseek)** (`@openclaw/deepseek-provider`) - npm; ClawHub: `clawhub:@openclaw/deepseek-provider`. Adds DeepSeek model provider support to OpenClaw.
|
||||
|
||||
- **[diagnostics-otel](/plugins/reference/diagnostics-otel)** (`@openclaw/diagnostics-otel`) - npm; ClawHub: `clawhub:@openclaw/diagnostics-otel`. OpenClaw diagnostics OpenTelemetry exporter for metrics and traces.
|
||||
- **[diagnostics-otel](/plugins/reference/diagnostics-otel)** (`@openclaw/diagnostics-otel`) - npm; ClawHub: `clawhub:@openclaw/diagnostics-otel`. OpenClaw diagnostics OpenTelemetry exporter for metrics, traces, and logs.
|
||||
|
||||
- **[diagnostics-prometheus](/plugins/reference/diagnostics-prometheus)** (`@openclaw/diagnostics-prometheus`) - npm; ClawHub: `clawhub:@openclaw/diagnostics-prometheus`. OpenClaw diagnostics Prometheus exporter for runtime metrics.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "OpenClaw diagnostics OpenTelemetry exporter for metrics and traces."
|
||||
summary: "OpenClaw diagnostics OpenTelemetry exporter for metrics, traces, and logs."
|
||||
read_when:
|
||||
- You are installing, configuring, or auditing the diagnostics-otel plugin
|
||||
title: "Diagnostics OpenTelemetry plugin"
|
||||
@@ -7,7 +7,7 @@ title: "Diagnostics OpenTelemetry plugin"
|
||||
|
||||
# Diagnostics OpenTelemetry plugin
|
||||
|
||||
OpenClaw diagnostics OpenTelemetry exporter for metrics and traces.
|
||||
OpenClaw diagnostics OpenTelemetry exporter for metrics, traces, and logs.
|
||||
|
||||
## Distribution
|
||||
|
||||
|
||||
@@ -164,7 +164,9 @@ two-party event loops that do not go through the shared inbound reply runner.
|
||||
});
|
||||
```
|
||||
|
||||
Prefer `getSessionEntry(...)`, `listSessionEntries(...)`, `patchSessionEntry(...)`, or `upsertSessionEntry(...)` for session workflows. These helpers address sessions by agent/session identity so plugins do not depend on the legacy `sessions.json` storage shape. Use `preserveActivity: true` for metadata-only patches that should not refresh session activity, and `replaceEntry: true` only when the callback returns a complete entry and deleted fields must stay deleted. `loadSessionStore(...)` remains as a deprecated compatibility escape hatch for callers that intentionally need a mutable whole-store clone.
|
||||
Prefer `getSessionEntry(...)`, `listSessionEntries(...)`, `patchSessionEntry(...)`, or `upsertSessionEntry(...)` for session workflows. These helpers address sessions by agent/session identity so plugins do not depend on the legacy `sessions.json` storage shape. Use `preserveActivity: true` for metadata-only patches that should not refresh session activity, and `replaceEntry: true` only when the callback returns a complete entry and deleted fields must stay deleted.
|
||||
|
||||
`loadSessionStore(...)`, `saveSessionStore(...)`, `updateSessionStore(...)`, and `resolveSessionFilePath(...)` are kept only during the transition before SQLite migration for plugins that still intentionally depend on the legacy whole-store or transcript-file shape. New plugin code must not use those helpers, and existing callers must migrate to entry helpers before the SQLite storage flip.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="api.runtime.agent.defaults">
|
||||
|
||||
@@ -247,7 +247,7 @@ usage endpoint failed or returned no usable usage data.
|
||||
| `plugin-sdk/reply-history` | Shared short-window reply-history helpers. New message-turn code should use `createChannelHistoryWindow`; lower-level map helpers remain deprecated compatibility exports only |
|
||||
| `plugin-sdk/reply-reference` | `createReplyReferencePlanner` |
|
||||
| `plugin-sdk/reply-chunking` | Narrow text/markdown chunking helpers |
|
||||
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), legacy session store path/session-key helpers, updated-at reads, and deprecated whole-store mutation helpers |
|
||||
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), legacy session store path/session-key helpers, updated-at reads, and transition-only whole-store/file-path compatibility helpers |
|
||||
| `plugin-sdk/sqlite-runtime` | Focused SQLite agent-schema, path, and transaction helpers for first-party runtime |
|
||||
| `plugin-sdk/cron-store-runtime` | Cron store path/load/save helpers |
|
||||
| `plugin-sdk/state-paths` | State/OAuth dir path helpers |
|
||||
|
||||
@@ -43,6 +43,8 @@ Scope intent:
|
||||
- `tools.web.fetch.firecrawl.apiKey`
|
||||
- `plugins.entries.acpx.config.mcpServers.*.env.*`
|
||||
- `plugins.entries.brave.config.webSearch.apiKey`
|
||||
- `plugins.entries.codex.config.appServer.authToken`
|
||||
- `plugins.entries.codex.config.appServer.headers.*`
|
||||
- `plugins.entries.exa.config.webSearch.apiKey`
|
||||
- `plugins.entries.google-meet.config.realtime.providers.*.apiKey`
|
||||
- `plugins.entries.google.config.webSearch.apiKey`
|
||||
|
||||
@@ -554,6 +554,20 @@
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "plugins.entries.codex.config.appServer.authToken",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "plugins.entries.codex.config.appServer.authToken",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "plugins.entries.codex.config.appServer.headers.*",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "plugins.entries.codex.config.appServer.headers.*",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "plugins.entries.exa.config.webSearch.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
|
||||
@@ -322,6 +322,7 @@ You can wait on more than just time/text:
|
||||
- `openclaw browser wait --url "**/dash"`
|
||||
- Wait for load state:
|
||||
- `openclaw browser wait --load networkidle`
|
||||
- Supported on managed `openclaw` and raw/remote CDP profiles. The `user` and `existing-session` profiles reject `networkidle`; use `--url`, `--text`, a selector, or `--fn` waits there.
|
||||
- Wait for a JS predicate:
|
||||
- `openclaw browser wait --fn "window.ready===true"`
|
||||
- Wait for a selector to become visible:
|
||||
|
||||
@@ -743,7 +743,7 @@ Compared to the managed `openclaw` profile, existing-session drivers are more co
|
||||
|
||||
- **Screenshots** - page captures and `--ref` element captures work; CSS `--element` selectors do not. `--full-page` cannot combine with `--ref` or `--element`. Playwright is not required for page or ref-based element screenshots.
|
||||
- **Actions** - `click`, `type`, `hover`, `scrollIntoView`, `drag`, and `select` require snapshot refs (no CSS selectors). `click-coords` clicks visible viewport coordinates and does not require a snapshot ref. `click` is left-button only. `type` does not support `slowly=true`; use `fill` or `press`. `press` does not support `delayMs`. `type`, `hover`, `scrollIntoView`, `drag`, `select`, `fill`, and `evaluate` do not support per-call timeouts. `select` accepts a single value.
|
||||
- **Wait / upload / dialog** - `wait --url` supports exact, substring, and glob patterns; `wait --load networkidle` is not supported. Upload hooks require `ref` or `inputRef`, one file at a time, no CSS `element`. Dialog hooks do not support timeout overrides or `dialogId`.
|
||||
- **Wait / upload / dialog** - `wait --url` supports exact, substring, and glob patterns; `wait --load networkidle` is not supported on existing-session profiles (it works on managed and raw/remote CDP profiles). Upload hooks require `ref` or `inputRef`, one file at a time, no CSS `element`. Dialog hooks do not support timeout overrides or `dialogId`.
|
||||
- **Dialog visibility** - Managed browser action responses include `blockedByDialog` and `browserState.dialogs.pending` when an action opens a modal dialog; snapshots also include pending dialog state. Respond with `browser dialog --accept/--dismiss --dialog-id <id>` while a dialog is pending. Dialogs handled outside OpenClaw appear under `browserState.dialogs.recent`.
|
||||
- **Managed-only features** - batch actions, PDF export, download interception, and `responsebody` still require the managed browser path.
|
||||
|
||||
|
||||
@@ -173,6 +173,7 @@ plugins.
|
||||
| --- | --- |
|
||||
| `/new [model]` | Archive the current session and start a fresh one |
|
||||
| `/reset [soft [message]]` | Reset the current session in place. `soft` keeps the transcript, drops reused CLI backend session ids, and reruns startup |
|
||||
| `/name <title>` | Name or rename the current session. Omit the title to see the current name and a suggestion |
|
||||
| `/compact [instructions]` | Compact the session context. See [Compaction](/concepts/compaction) |
|
||||
| `/stop` | Abort the current run |
|
||||
| `/session idle <duration\|off>` | Manage thread-binding idle expiry |
|
||||
|
||||
@@ -297,8 +297,3 @@ export function renderIsolatedCodexConfig(params: {
|
||||
.filter((line, index, lines) => !(line === "" && lines[index - 1] === ""))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** Render only the project trust section for a session-local Codex config. */
|
||||
export function renderIsolatedCodexProjectTrustConfig(projectPaths: string[]): string {
|
||||
return renderIsolatedCodexConfig({ projectPaths });
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// Bonjour tests cover ciao plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const { classifyCiaoUnhandledRejection, ignoreCiaoUnhandledRejection } = await import("./ciao.js");
|
||||
const { classifyCiaoProcessError } = await import("./ciao.js");
|
||||
|
||||
describe("bonjour-ciao", () => {
|
||||
it("classifies ciao cancellation rejections separately from side effects", () => {
|
||||
expect(classifyCiaoUnhandledRejection(new Error("CIAO PROBING CANCELLED"))).toEqual({
|
||||
expect(classifyCiaoProcessError(new Error("CIAO PROBING CANCELLED"))).toEqual({
|
||||
kind: "cancellation",
|
||||
formatted: "CIAO PROBING CANCELLED",
|
||||
});
|
||||
@@ -13,7 +13,7 @@ describe("bonjour-ciao", () => {
|
||||
|
||||
it("classifies ciao interface assertions separately from side effects", () => {
|
||||
expect(
|
||||
classifyCiaoUnhandledRejection(
|
||||
classifyCiaoProcessError(
|
||||
new Error("Reached illegal state! IPV4 address change from defined to undefined!"),
|
||||
),
|
||||
).toEqual({
|
||||
@@ -24,7 +24,7 @@ describe("bonjour-ciao", () => {
|
||||
|
||||
it("classifies ciao interface assertions using changed wording", () => {
|
||||
expect(
|
||||
classifyCiaoUnhandledRejection(
|
||||
classifyCiaoProcessError(
|
||||
new Error("Reached illegal state! IPv4 address changed from undefined to defined!"),
|
||||
),
|
||||
).toEqual({
|
||||
@@ -35,7 +35,7 @@ describe("bonjour-ciao", () => {
|
||||
|
||||
it("classifies ciao netmask assertions separately from side effects", () => {
|
||||
expect(
|
||||
classifyCiaoUnhandledRejection(
|
||||
classifyCiaoProcessError(
|
||||
Object.assign(
|
||||
new Error(
|
||||
"IP address version must match. Netmask cannot have a version different from the address!",
|
||||
@@ -52,7 +52,7 @@ describe("bonjour-ciao", () => {
|
||||
|
||||
it("classifies ciao self-probe races separately from side effects", () => {
|
||||
expect(
|
||||
classifyCiaoUnhandledRejection(
|
||||
classifyCiaoProcessError(
|
||||
new Error(
|
||||
"Can't probe for a service which is announced already. Received announcing for service OpenClaw Gateway._openclaw._tcp.local.",
|
||||
),
|
||||
@@ -65,18 +65,18 @@ describe("bonjour-ciao", () => {
|
||||
});
|
||||
|
||||
it("suppresses ciao announcement cancellation rejections", () => {
|
||||
expect(ignoreCiaoUnhandledRejection(new Error("Ciao announcement cancelled by shutdown"))).toBe(
|
||||
true,
|
||||
expect(classifyCiaoProcessError(new Error("Ciao announcement cancelled by shutdown"))).not.toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("suppresses ciao probing cancellation rejections", () => {
|
||||
expect(ignoreCiaoUnhandledRejection(new Error("CIAO PROBING CANCELLED"))).toBe(true);
|
||||
expect(classifyCiaoProcessError(new Error("CIAO PROBING CANCELLED"))).not.toBe(null);
|
||||
});
|
||||
|
||||
it("suppresses wrapped ciao cancellation rejections", () => {
|
||||
expect(
|
||||
classifyCiaoUnhandledRejection({
|
||||
classifyCiaoProcessError({
|
||||
reason: new Error("CIAO ANNOUNCEMENT CANCELLED"),
|
||||
}),
|
||||
).toEqual({
|
||||
@@ -87,7 +87,7 @@ describe("bonjour-ciao", () => {
|
||||
|
||||
it("suppresses aggregate ciao assertion rejections", () => {
|
||||
expect(
|
||||
classifyCiaoUnhandledRejection(
|
||||
classifyCiaoProcessError(
|
||||
new AggregateError([
|
||||
Object.assign(
|
||||
new Error("Reached illegal state! IPV4 address change from defined to undefined!"),
|
||||
@@ -103,7 +103,7 @@ describe("bonjour-ciao", () => {
|
||||
});
|
||||
|
||||
it("suppresses lower-case string cancellation reasons too", () => {
|
||||
expect(ignoreCiaoUnhandledRejection("ciao announcement cancelled during cleanup")).toBe(true);
|
||||
expect(classifyCiaoProcessError("ciao announcement cancelled during cleanup")).not.toBe(null);
|
||||
});
|
||||
|
||||
it("suppresses ciao interface assertion rejections as non-fatal", () => {
|
||||
@@ -112,7 +112,7 @@ describe("bonjour-ciao", () => {
|
||||
{ name: "AssertionError" },
|
||||
);
|
||||
|
||||
expect(ignoreCiaoUnhandledRejection(error)).toBe(true);
|
||||
expect(classifyCiaoProcessError(error)).not.toBe(null);
|
||||
});
|
||||
|
||||
it("suppresses ciao netmask assertion errors as non-fatal", () => {
|
||||
@@ -123,7 +123,7 @@ describe("bonjour-ciao", () => {
|
||||
{ name: "AssertionError" },
|
||||
);
|
||||
|
||||
expect(ignoreCiaoUnhandledRejection(error)).toBe(true);
|
||||
expect(classifyCiaoProcessError(error)).not.toBe(null);
|
||||
});
|
||||
|
||||
it("classifies networkInterfaces SystemError failures (restricted sandboxes)", () => {
|
||||
@@ -131,7 +131,7 @@ describe("bonjour-ciao", () => {
|
||||
new Error("A system error occurred: uv_interface_addresses returned Unknown system error 1"),
|
||||
{ name: "SystemError" },
|
||||
);
|
||||
expect(classifyCiaoUnhandledRejection(err)).toEqual({
|
||||
expect(classifyCiaoProcessError(err)).toEqual({
|
||||
kind: "interface-enumeration-failure",
|
||||
formatted:
|
||||
"SystemError: A system error occurred: uv_interface_addresses returned Unknown system error 1",
|
||||
@@ -144,10 +144,10 @@ describe("bonjour-ciao", () => {
|
||||
{ name: "SystemError" },
|
||||
);
|
||||
const wrapper = new Error("ciao NetworkManager init failed", { cause: inner });
|
||||
expect(ignoreCiaoUnhandledRejection(wrapper)).toBe(true);
|
||||
expect(classifyCiaoProcessError(wrapper)).not.toBe(null);
|
||||
});
|
||||
|
||||
it("keeps unrelated rejections visible", () => {
|
||||
expect(ignoreCiaoUnhandledRejection(new Error("boom"))).toBe(false);
|
||||
expect(classifyCiaoProcessError(new Error("boom"))).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,15 +55,3 @@ export function classifyCiaoProcessError(reason: unknown): CiaoProcessErrorClass
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compatible alias for unhandled-rejection classification.
|
||||
*
|
||||
* @deprecated Use classifyCiaoProcessError.
|
||||
*/
|
||||
export const classifyCiaoUnhandledRejection = classifyCiaoProcessError;
|
||||
|
||||
/** Return whether a ciao unhandled rejection is known and ignorable. */
|
||||
export function ignoreCiaoUnhandledRejection(reason: unknown): boolean {
|
||||
return classifyCiaoProcessError(reason) !== null;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
type AriaSnapshotNode,
|
||||
captureScreenshot,
|
||||
createTargetViaCdp,
|
||||
evaluateJavaScript,
|
||||
formatAriaSnapshot,
|
||||
normalizeCdpWsUrl,
|
||||
type RawAXNode,
|
||||
@@ -329,47 +328,6 @@ describe("cdp internal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateJavaScript", () => {
|
||||
it("throws when Runtime.evaluate returns no result", async () => {
|
||||
const server = await startMockWsServer((msg, socket) => {
|
||||
if (msg.method === "Runtime.enable") {
|
||||
socket.send(JSON.stringify({ id: msg.id, result: {} }));
|
||||
return;
|
||||
}
|
||||
if (msg.method === "Runtime.evaluate") {
|
||||
socket.send(JSON.stringify({ id: msg.id, result: {} }));
|
||||
}
|
||||
});
|
||||
wss = server.wss;
|
||||
await expect(evaluateJavaScript({ wsUrl: server.wsUrl, expression: "1" })).rejects.toThrow(
|
||||
/Runtime\.evaluate returned no result/,
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces CDP exceptionDetails alongside result", async () => {
|
||||
const server = await startMockWsServer((msg, socket) => {
|
||||
if (msg.method === "Runtime.enable") {
|
||||
socket.send(JSON.stringify({ id: msg.id, result: {} }));
|
||||
return;
|
||||
}
|
||||
if (msg.method === "Runtime.evaluate") {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
id: msg.id,
|
||||
result: {
|
||||
result: { type: "undefined" },
|
||||
exceptionDetails: { text: "ReferenceError", lineNumber: 1 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
wss = server.wss;
|
||||
const res = await evaluateJavaScript({ wsUrl: server.wsUrl, expression: "boom" });
|
||||
expect(res.exceptionDetails?.text).toBe("ReferenceError");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatAriaSnapshot", () => {
|
||||
it("returns an empty array when the AX tree is empty", () => {
|
||||
expect(formatAriaSnapshot([], 100)).toStrictEqual([]);
|
||||
@@ -939,27 +897,6 @@ describe("cdp internal", () => {
|
||||
expect(snap.nodes).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("swallows a failing Runtime.enable in evaluateJavaScript", async () => {
|
||||
// Exercises the `.catch(() => {})` arrow on `Runtime.enable`.
|
||||
const server = await startMockWsServer((msg, socket) => {
|
||||
if (msg.method === "Runtime.enable") {
|
||||
socket.send(JSON.stringify({ id: msg.id, error: { message: "denied" } }));
|
||||
return;
|
||||
}
|
||||
if (msg.method === "Runtime.evaluate") {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
id: msg.id,
|
||||
result: { result: { type: "number", value: 1 } },
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
wss = server.wss;
|
||||
const res = await evaluateJavaScript({ wsUrl: server.wsUrl, expression: "1" });
|
||||
expect(res.result.value).toBe(1);
|
||||
});
|
||||
|
||||
it("swallows a failing Emulation.clearDeviceMetricsOverride in the screenshot finally", async () => {
|
||||
// Exercises the `.catch(() => {})` on clearDeviceMetricsOverride inside
|
||||
// the fullPage finally block.
|
||||
@@ -1008,5 +945,4 @@ describe("cdp internal", () => {
|
||||
expect(buf.toString("utf8")).toBe("S");
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
isWebSocketUrl,
|
||||
parseBrowserHttpUrl as parseHttpUrl,
|
||||
} from "./cdp.helpers.js";
|
||||
import { createTargetViaCdp, evaluateJavaScript, normalizeCdpWsUrl, snapshotAria } from "./cdp.js";
|
||||
import { createTargetViaCdp, normalizeCdpWsUrl, snapshotAria } from "./cdp.js";
|
||||
import {
|
||||
BROWSER_ENDPOINT_BLOCKED_MESSAGE,
|
||||
BROWSER_NAVIGATION_BLOCKED_MESSAGE,
|
||||
@@ -412,32 +412,6 @@ describe("cdp", () => {
|
||||
).rejects.toBeInstanceOf(BrowserCdpEndpointBlockedError);
|
||||
});
|
||||
|
||||
it("evaluates javascript via CDP", async () => {
|
||||
const wsPort = await startWsServerWithMessages((msg, socket) => {
|
||||
if (msg.method === "Runtime.enable") {
|
||||
socket.send(JSON.stringify({ id: msg.id, result: {} }));
|
||||
return;
|
||||
}
|
||||
if (msg.method === "Runtime.evaluate") {
|
||||
expect(msg.params?.expression).toBe("1+1");
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
id: msg.id,
|
||||
result: { result: { type: "number", value: 2 } },
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const res = await evaluateJavaScript({
|
||||
wsUrl: `ws://127.0.0.1:${wsPort}`,
|
||||
expression: "1+1",
|
||||
});
|
||||
|
||||
expect(res.result.type).toBe("number");
|
||||
expect(res.result.value).toBe(2);
|
||||
});
|
||||
|
||||
it("fails when /json/version omits webSocketDebuggerUrl for an HTTP cdpUrl", async () => {
|
||||
const httpPort = await startVersionHttpServer({});
|
||||
await expect(
|
||||
|
||||
@@ -318,37 +318,6 @@ export type CdpExceptionDetails = {
|
||||
stackTrace?: unknown;
|
||||
};
|
||||
|
||||
/** Evaluate JavaScript in a CDP target and return by value when possible. */
|
||||
export async function evaluateJavaScript(opts: {
|
||||
wsUrl: string;
|
||||
expression: string;
|
||||
awaitPromise?: boolean;
|
||||
returnByValue?: boolean;
|
||||
}): Promise<{
|
||||
result: CdpRemoteObject;
|
||||
exceptionDetails?: CdpExceptionDetails;
|
||||
}> {
|
||||
return await withCdpSocket(opts.wsUrl, async (send) => {
|
||||
await send("Runtime.enable").catch(() => {});
|
||||
const evaluated = (await send("Runtime.evaluate", {
|
||||
expression: opts.expression,
|
||||
awaitPromise: Boolean(opts.awaitPromise),
|
||||
returnByValue: opts.returnByValue ?? true,
|
||||
userGesture: true,
|
||||
includeCommandLineAPI: true,
|
||||
})) as {
|
||||
result?: CdpRemoteObject;
|
||||
exceptionDetails?: CdpExceptionDetails;
|
||||
};
|
||||
|
||||
const result = evaluated?.result;
|
||||
if (!result) {
|
||||
throw new Error("CDP Runtime.evaluate returned no result");
|
||||
}
|
||||
return { result, exceptionDetails: evaluated.exceptionDetails };
|
||||
});
|
||||
}
|
||||
|
||||
/** Normalized accessibility tree node returned by ARIA snapshots. */
|
||||
export type AriaSnapshotNode = {
|
||||
ref: string;
|
||||
|
||||
@@ -7,7 +7,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clickChromeMcpCoords,
|
||||
clickChromeMcpElement,
|
||||
buildChromeMcpArgs,
|
||||
decodeChromeMcpStderrTail,
|
||||
ensureChromeMcpAvailable,
|
||||
evaluateChromeMcpScript,
|
||||
@@ -212,114 +211,6 @@ describe("chrome MCP page parsing", () => {
|
||||
).resolves.toEqual(Buffer.from("screenshot:jpeg"));
|
||||
});
|
||||
|
||||
it("adds --userDataDir when an explicit Chromium profile path is configured", () => {
|
||||
expect(buildChromeMcpArgs("/tmp/brave-profile")).toEqual([
|
||||
"-y",
|
||||
"chrome-devtools-mcp@latest",
|
||||
"--autoConnect",
|
||||
"--no-usage-statistics",
|
||||
"--experimentalStructuredContent",
|
||||
"--experimental-page-id-routing",
|
||||
"--userDataDir",
|
||||
"/tmp/brave-profile",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses browserUrl for existing-session cdpUrl without also passing userDataDir", () => {
|
||||
expect(
|
||||
buildChromeMcpArgs({
|
||||
cdpUrl: "http://127.0.0.1:9222",
|
||||
userDataDir: "/tmp/brave-profile",
|
||||
}),
|
||||
).toEqual([
|
||||
"-y",
|
||||
"chrome-devtools-mcp@latest",
|
||||
"--browserUrl",
|
||||
"http://127.0.0.1:9222",
|
||||
"--no-usage-statistics",
|
||||
"--experimentalStructuredContent",
|
||||
"--experimental-page-id-routing",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses wsEndpoint for direct existing-session websocket cdpUrl", () => {
|
||||
expect(
|
||||
buildChromeMcpArgs({
|
||||
cdpUrl: "ws://127.0.0.1:9222/devtools/browser/abc",
|
||||
}),
|
||||
).toEqual([
|
||||
"-y",
|
||||
"chrome-devtools-mcp@latest",
|
||||
"--wsEndpoint",
|
||||
"ws://127.0.0.1:9222/devtools/browser/abc",
|
||||
"--no-usage-statistics",
|
||||
"--experimentalStructuredContent",
|
||||
"--experimental-page-id-routing",
|
||||
]);
|
||||
});
|
||||
|
||||
it("appends custom Chrome MCP args and lets explicit endpoint args override auto-connect", () => {
|
||||
expect(
|
||||
buildChromeMcpArgs({
|
||||
userDataDir: "/tmp/brave-profile",
|
||||
mcpArgs: ["--browserUrl", "http://127.0.0.1:9222", "--no-usage-statistics"],
|
||||
}),
|
||||
).toEqual([
|
||||
"-y",
|
||||
"chrome-devtools-mcp@latest",
|
||||
"--experimentalStructuredContent",
|
||||
"--experimental-page-id-routing",
|
||||
"--browserUrl",
|
||||
"http://127.0.0.1:9222",
|
||||
"--no-usage-statistics",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lets explicit Chrome MCP usage-statistics args override the default opt-out", () => {
|
||||
expect(
|
||||
buildChromeMcpArgs({
|
||||
mcpArgs: ["--usage-statistics"],
|
||||
}),
|
||||
).toEqual([
|
||||
"-y",
|
||||
"chrome-devtools-mcp@latest",
|
||||
"--autoConnect",
|
||||
"--experimentalStructuredContent",
|
||||
"--experimental-page-id-routing",
|
||||
"--usage-statistics",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not duplicate an explicit Chrome MCP usage-statistics opt-out", () => {
|
||||
expect(
|
||||
buildChromeMcpArgs({
|
||||
mcpArgs: ["--no-usage-statistics"],
|
||||
}),
|
||||
).toEqual([
|
||||
"-y",
|
||||
"chrome-devtools-mcp@latest",
|
||||
"--autoConnect",
|
||||
"--experimentalStructuredContent",
|
||||
"--experimental-page-id-routing",
|
||||
"--no-usage-statistics",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits the npx package prefix for a custom Chrome MCP command", () => {
|
||||
expect(
|
||||
buildChromeMcpArgs({
|
||||
mcpCommand: "/usr/local/bin/chrome-devtools-mcp",
|
||||
cdpUrl: "http://127.0.0.1:9222",
|
||||
}),
|
||||
).toEqual([
|
||||
"--browserUrl",
|
||||
"http://127.0.0.1:9222",
|
||||
"--no-usage-statistics",
|
||||
"--experimentalStructuredContent",
|
||||
"--experimental-page-id-routing",
|
||||
]);
|
||||
});
|
||||
|
||||
it("terminates the owned Chrome MCP subprocess tree when closing temporary sessions", async () => {
|
||||
const session = createFakeSession();
|
||||
Object.assign(session, { ownsProcessTree: true });
|
||||
|
||||
@@ -462,11 +462,6 @@ function buildChromeMcpArgsFromOptions(options: NormalizedChromeMcpProfileOption
|
||||
];
|
||||
}
|
||||
|
||||
/** Build command-line args for launching chrome-devtools-mcp. */
|
||||
export function buildChromeMcpArgs(input?: string | ChromeMcpProfileOptions): string[] {
|
||||
return buildChromeMcpArgsFromOptions(normalizeChromeMcpOptions(input));
|
||||
}
|
||||
|
||||
function drainStderr(transport: StdioClientTransport): () => string {
|
||||
const stream = transport.stderr;
|
||||
if (!stream) {
|
||||
|
||||
@@ -36,12 +36,133 @@ function createElementProgram(): Command {
|
||||
return program;
|
||||
}
|
||||
|
||||
function getLastActionBody(): Record<string, unknown> | undefined {
|
||||
return (mocks.callBrowserRequest.mock.calls.at(-1)?.[1] as { body?: Record<string, unknown> })
|
||||
?.body;
|
||||
}
|
||||
|
||||
describe("browser element commands", () => {
|
||||
beforeEach(() => {
|
||||
mocks.callBrowserRequest.mockClear();
|
||||
getBrowserCliRuntimeCapture().resetRuntimeCapture();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "click",
|
||||
argv: [
|
||||
"browser",
|
||||
"click",
|
||||
" ref-1 ",
|
||||
"--target-id",
|
||||
"tab-1",
|
||||
"--double",
|
||||
"--button",
|
||||
"right",
|
||||
"--modifiers",
|
||||
"Shift, Alt",
|
||||
],
|
||||
expectedBody: {
|
||||
kind: "click",
|
||||
ref: "ref-1",
|
||||
targetId: "tab-1",
|
||||
doubleClick: true,
|
||||
button: "right",
|
||||
modifiers: ["Shift", "Alt"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "click-coords",
|
||||
argv: [
|
||||
"browser",
|
||||
"click-coords",
|
||||
"12.5",
|
||||
"42",
|
||||
"--target-id",
|
||||
"tab-2",
|
||||
"--double",
|
||||
"--button",
|
||||
"middle",
|
||||
"--delay-ms",
|
||||
"25",
|
||||
],
|
||||
expectedBody: {
|
||||
kind: "clickCoords",
|
||||
x: 12.5,
|
||||
y: 42,
|
||||
targetId: "tab-2",
|
||||
doubleClick: true,
|
||||
button: "middle",
|
||||
delayMs: 25,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "type",
|
||||
argv: ["browser", "type", "input-1", "hello", "--submit", "--slowly", "--target-id", "tab-2"],
|
||||
expectedBody: {
|
||||
kind: "type",
|
||||
ref: "input-1",
|
||||
text: "hello",
|
||||
submit: true,
|
||||
slowly: true,
|
||||
targetId: "tab-2",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "press",
|
||||
argv: ["browser", "press", "Enter", "--target-id", "tab-3"],
|
||||
expectedBody: { kind: "press", key: "Enter", targetId: "tab-3" },
|
||||
},
|
||||
{
|
||||
name: "hover",
|
||||
argv: ["browser", "hover", "node-1", "--target-id", "tab-4"],
|
||||
expectedBody: { kind: "hover", ref: "node-1", targetId: "tab-4" },
|
||||
},
|
||||
{
|
||||
name: "scrollintoview",
|
||||
argv: ["browser", "scrollintoview", "node-2", "--target-id", "tab-5"],
|
||||
expectedBody: { kind: "scrollIntoView", ref: "node-2", targetId: "tab-5" },
|
||||
},
|
||||
{
|
||||
name: "drag",
|
||||
argv: ["browser", "drag", "start-1", "end-1", "--target-id", "tab-6"],
|
||||
expectedBody: {
|
||||
kind: "drag",
|
||||
startRef: "start-1",
|
||||
endRef: "end-1",
|
||||
targetId: "tab-6",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "select",
|
||||
argv: ["browser", "select", "select-1", "alpha", "beta", "--target-id", "tab-7"],
|
||||
expectedBody: {
|
||||
kind: "select",
|
||||
ref: "select-1",
|
||||
values: ["alpha", "beta"],
|
||||
targetId: "tab-7",
|
||||
},
|
||||
},
|
||||
])("sends the expected $name action body", async ({ argv, expectedBody }) => {
|
||||
const program = createElementProgram();
|
||||
|
||||
await program.parseAsync(argv, { from: "user" });
|
||||
|
||||
expect(getLastActionBody()).toMatchObject(expectedBody);
|
||||
});
|
||||
|
||||
it("rejects a blank required ref before dispatch", async () => {
|
||||
const program = createElementProgram();
|
||||
|
||||
await expect(program.parseAsync(["browser", "click", " "], { from: "user" })).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
|
||||
const capture = getBrowserCliRuntimeCapture();
|
||||
expect(capture.runtimeErrors.join("\n")).toContain("ref is required");
|
||||
expect(mocks.callBrowserRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects non-decimal coordinate values before dispatch", async () => {
|
||||
const program = createElementProgram();
|
||||
|
||||
|
||||
@@ -31,6 +31,10 @@ vi.spyOn(cliCoreApiModule.defaultRuntime, "writeJson").mockImplementation(
|
||||
);
|
||||
vi.spyOn(cliCoreApiModule.defaultRuntime, "error").mockImplementation(browserCliRuntime.error);
|
||||
vi.spyOn(cliCoreApiModule.defaultRuntime, "exit").mockImplementation(browserCliRuntime.exit);
|
||||
vi.spyOn(cliCoreApiModule, "resolveExistingUploadPaths").mockResolvedValue({
|
||||
ok: true,
|
||||
paths: ["/tmp/openclaw/uploads/a.pdf", "/tmp/openclaw/uploads/b.pdf"],
|
||||
});
|
||||
|
||||
const { registerBrowserActionInputCommands } = await import("./register.js");
|
||||
|
||||
@@ -47,10 +51,51 @@ function getLastRequestOptions(): { timeoutMs?: number } | undefined {
|
||||
describe("browser action input file/download commands", () => {
|
||||
beforeEach(() => {
|
||||
mocks.callBrowserRequest.mockClear();
|
||||
vi.mocked(cliCoreApiModule.resolveExistingUploadPaths).mockClear();
|
||||
getBrowserCliRuntimeCapture().resetRuntimeCapture();
|
||||
getBrowserCliRuntime().exit.mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it("arms uploads with normalized paths and element targeting options", async () => {
|
||||
const program = createActionInputProgram();
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
"browser",
|
||||
"upload",
|
||||
"/tmp/openclaw/uploads/a.pdf",
|
||||
"media://inbound/b",
|
||||
"--input-ref",
|
||||
"file-input",
|
||||
"--element",
|
||||
"input[type=file]",
|
||||
"--target-id",
|
||||
"tab-1",
|
||||
"--timeout-ms",
|
||||
"45000",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(cliCoreApiModule.resolveExistingUploadPaths).toHaveBeenCalledWith({
|
||||
requestedPaths: ["/tmp/openclaw/uploads/a.pdf", "media://inbound/b"],
|
||||
});
|
||||
const request = mocks.callBrowserRequest.mock.calls.at(-1)?.[1] as
|
||||
| { path?: string; body?: Record<string, unknown> }
|
||||
| undefined;
|
||||
expect(request).toMatchObject({
|
||||
path: "/hooks/file-chooser",
|
||||
body: {
|
||||
paths: ["/tmp/openclaw/uploads/a.pdf", "/tmp/openclaw/uploads/b.pdf"],
|
||||
inputRef: "file-input",
|
||||
element: "input[type=file]",
|
||||
targetId: "tab-1",
|
||||
timeoutMs: 45000,
|
||||
},
|
||||
});
|
||||
expect(getLastRequestOptions()?.timeoutMs).toBeGreaterThan(45000);
|
||||
});
|
||||
|
||||
it("keeps the outer waitfordownload request open for the advertised default wait", async () => {
|
||||
const program = createActionInputProgram();
|
||||
|
||||
|
||||
@@ -36,6 +36,43 @@ function createActionInputProgram(): Command {
|
||||
return program;
|
||||
}
|
||||
|
||||
function getLastActionBody(): Record<string, unknown> | undefined {
|
||||
return (mocks.callBrowserRequest.mock.calls.at(-1)?.[1] as { body?: Record<string, unknown> })
|
||||
?.body;
|
||||
}
|
||||
|
||||
describe("browser action input fill command", () => {
|
||||
beforeEach(() => {
|
||||
mocks.callBrowserRequest.mockClear();
|
||||
getBrowserCliRuntimeCapture().resetRuntimeCapture();
|
||||
});
|
||||
|
||||
it("sends normalized fill fields and target id to the act route", async () => {
|
||||
const program = createActionInputProgram();
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
"browser",
|
||||
"fill",
|
||||
"--fields",
|
||||
'[{"ref":"name","value":"Ada"},{"ref":"enabled","value":true}]',
|
||||
"--target-id",
|
||||
"tab-1",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(getLastActionBody()).toMatchObject({
|
||||
kind: "fill",
|
||||
fields: [
|
||||
{ ref: "name", type: "text", value: "Ada" },
|
||||
{ ref: "enabled", type: "text", value: true },
|
||||
],
|
||||
targetId: "tab-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("browser action input wait command", () => {
|
||||
beforeEach(() => {
|
||||
mocks.callBrowserRequest.mockClear();
|
||||
@@ -99,6 +136,31 @@ describe("browser action input evaluate command", () => {
|
||||
getBrowserCliRuntimeCapture().resetRuntimeCapture();
|
||||
});
|
||||
|
||||
it("sends evaluate function, ref, and target id to the act route", async () => {
|
||||
const program = createActionInputProgram();
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
"browser",
|
||||
"evaluate",
|
||||
"--fn",
|
||||
"el => el.textContent",
|
||||
"--ref",
|
||||
"button-1",
|
||||
"--target-id",
|
||||
"tab-2",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(getLastActionBody()).toMatchObject({
|
||||
kind: "evaluate",
|
||||
fn: "el => el.textContent",
|
||||
ref: "button-1",
|
||||
targetId: "tab-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes timeout-ms through to the evaluate action and outer request", async () => {
|
||||
const program = createActionInputProgram();
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Command } from "commander";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as browserCliResizeModule from "../browser-cli-resize.js";
|
||||
import * as browserCliSharedModule from "../browser-cli-shared.js";
|
||||
import {
|
||||
createBrowserProgram,
|
||||
getBrowserCliRuntime,
|
||||
@@ -10,9 +11,17 @@ import {
|
||||
import * as cliCoreApiModule from "../core-api.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
callBrowserRequest: vi.fn<
|
||||
(
|
||||
opts?: unknown,
|
||||
req?: unknown,
|
||||
extra?: { timeoutMs?: number },
|
||||
) => Promise<Record<string, unknown>>
|
||||
>(async () => ({ url: "https://example.test/landing" })),
|
||||
runBrowserResizeWithOutput: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.spyOn(browserCliSharedModule, "callBrowserRequest").mockImplementation(mocks.callBrowserRequest);
|
||||
vi.spyOn(browserCliResizeModule, "runBrowserResizeWithOutput").mockImplementation(
|
||||
mocks.runBrowserResizeWithOutput,
|
||||
);
|
||||
@@ -34,10 +43,51 @@ function createNavigationProgram(): Command {
|
||||
|
||||
describe("browser navigation commands", () => {
|
||||
beforeEach(() => {
|
||||
mocks.callBrowserRequest.mockClear();
|
||||
mocks.runBrowserResizeWithOutput.mockClear();
|
||||
getBrowserCliRuntimeCapture().resetRuntimeCapture();
|
||||
});
|
||||
|
||||
it("sends navigate requests with the URL and target id", async () => {
|
||||
const program = createNavigationProgram();
|
||||
|
||||
await program.parseAsync(
|
||||
["browser", "navigate", "https://example.test/page", "--target-id", "tab-1"],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
const request = mocks.callBrowserRequest.mock.calls.at(-1)?.[1] as
|
||||
| { method?: string; path?: string; body?: Record<string, unknown> }
|
||||
| undefined;
|
||||
const options = mocks.callBrowserRequest.mock.calls.at(-1)?.[2] as
|
||||
| { timeoutMs?: number }
|
||||
| undefined;
|
||||
expect(request).toMatchObject({
|
||||
method: "POST",
|
||||
path: "/navigate",
|
||||
body: { url: "https://example.test/page", targetId: "tab-1" },
|
||||
});
|
||||
expect(options?.timeoutMs).toBe(20000);
|
||||
});
|
||||
|
||||
it("passes normalized resize dimensions and target id to the resize helper", async () => {
|
||||
const program = createNavigationProgram();
|
||||
|
||||
await program.parseAsync(["browser", "resize", "1024", "768", "--target-id", "tab-2"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(mocks.runBrowserResizeWithOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
width: 1024,
|
||||
height: 768,
|
||||
targetId: "tab-2",
|
||||
timeoutMs: 20000,
|
||||
successMessage: "resized to 1024x768",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-decimal resize dimensions before dispatch", async () => {
|
||||
const program = createNavigationProgram();
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
isLiveTestEnabled,
|
||||
} from "openclaw/plugin-sdk/test-env";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BYTEPLUS_CODING_BASE_URL, BYTEPLUS_DEFAULT_COST } from "./models.js";
|
||||
import { BYTEPLUS_CODING_BASE_URL } from "./models.js";
|
||||
|
||||
const BYTEPLUS_KEY = process.env.BYTEPLUS_API_KEY ?? "";
|
||||
const BYTEPLUS_CODING_MODEL = process.env.BYTEPLUS_CODING_MODEL?.trim() || "ark-code-latest";
|
||||
@@ -33,7 +33,7 @@ describeLive("byteplus coding plan live", () => {
|
||||
baseUrl: BYTEPLUS_CODING_BASE_URL,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: BYTEPLUS_DEFAULT_COST,
|
||||
cost: { input: 0.0001, output: 0.0002, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 256000,
|
||||
maxTokens: 4096,
|
||||
};
|
||||
|
||||
@@ -20,14 +20,6 @@ export const BYTEPLUS_BASE_URL = BYTEPLUS_MANIFEST_PROVIDER.baseUrl;
|
||||
/** Base URL for BytePlus Plan coding APIs from the manifest catalog. */
|
||||
export const BYTEPLUS_CODING_BASE_URL = BYTEPLUS_CODING_MANIFEST_PROVIDER.baseUrl;
|
||||
|
||||
/** Fallback cost shape retained for callers that need BytePlus defaults. */
|
||||
export const BYTEPLUS_DEFAULT_COST = {
|
||||
input: 0.0001,
|
||||
output: 0.0002,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
/** BytePlus general model catalog entries. */
|
||||
export const BYTEPLUS_MODEL_CATALOG: ModelDefinitionConfig[] = BYTEPLUS_MANIFEST_PROVIDER.models;
|
||||
/** BytePlus coding/planning model catalog entries. */
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export declare function isBundleHashInputPath(filePath: string, repoRoot?: string): boolean;
|
||||
export declare function getLocalRolldownCliCandidates(repoRoot?: string): string[];
|
||||
export declare function getBundleHashRepoInputPaths(repoRoot?: string): string[];
|
||||
export declare function getBundleHashInputPaths(repoRoot?: string): string[];
|
||||
export declare function compareNormalizedPaths(left: string, right: string): number;
|
||||
|
||||
@@ -18,9 +18,7 @@ const require = createRequire(import.meta.url);
|
||||
const hashFile = path.join(pluginDir, "src", "host", "a2ui", ".bundle.hash");
|
||||
const outputFile = path.join(pluginDir, "src", "host", "a2ui", "a2ui.bundle.js");
|
||||
const a2uiAppDir = path.join(pluginDir, "src", "host", "a2ui-app");
|
||||
const rootPackageFile = path.join(rootDir, "package.json");
|
||||
const lockFile = path.join(rootDir, "pnpm-lock.yaml");
|
||||
const repoInputPaths = [rootPackageFile, lockFile, a2uiAppDir];
|
||||
const repoInputPaths = getBundleHashRepoInputPaths(rootDir);
|
||||
const relativeRepoInputPaths = repoInputPaths.map((inputPath) =>
|
||||
normalizePath(path.relative(rootDir, inputPath)),
|
||||
);
|
||||
@@ -77,11 +75,6 @@ export function getBundleHashRepoInputPaths(repoRoot = rootDir) {
|
||||
];
|
||||
}
|
||||
|
||||
/** Returns A2UI bundle hash input paths. */
|
||||
export function getBundleHashInputPaths(repoRoot = rootDir) {
|
||||
return getBundleHashRepoInputPaths(repoRoot);
|
||||
}
|
||||
|
||||
/** Compares paths after normalizing separators to POSIX slashes. */
|
||||
export function compareNormalizedPaths(left, right) {
|
||||
const normalizedLeft = normalizePath(left);
|
||||
|
||||
@@ -3,7 +3,6 @@ import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
compareNormalizedPaths,
|
||||
getBundleHashInputPaths,
|
||||
getBundleHashRepoInputPaths,
|
||||
getLocalRolldownCliCandidates,
|
||||
isBundleHashInputPath,
|
||||
@@ -52,7 +51,7 @@ describe("scripts/bundle-a2ui.mjs", () => {
|
||||
|
||||
it("keeps local node_modules state out of bundle hash inputs", () => {
|
||||
const repoRoot = process.cwd();
|
||||
const inputPaths = getBundleHashInputPaths(repoRoot);
|
||||
const inputPaths = getBundleHashRepoInputPaths(repoRoot);
|
||||
|
||||
expect(inputPaths).not.toContain(path.join(repoRoot, "node_modules", "lit", "package.json"));
|
||||
expect(inputPaths).not.toContain(
|
||||
|
||||
@@ -5,7 +5,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedClickClackAccount } from "./types.js";
|
||||
|
||||
class FakeSocket extends EventEmitter {
|
||||
emitErrorOnClose = false;
|
||||
|
||||
close = vi.fn(() => {
|
||||
if (this.emitErrorOnClose) {
|
||||
this.emit("error", new Error("socket closed while connecting"));
|
||||
}
|
||||
this.emit("close");
|
||||
});
|
||||
}
|
||||
@@ -191,6 +196,46 @@ describe("ClickClack gateway", () => {
|
||||
await run;
|
||||
});
|
||||
|
||||
it("reconnects after ClickClack websocket errors", async () => {
|
||||
const firstSocket = new FakeSocket();
|
||||
firstSocket.emitErrorOnClose = true;
|
||||
const secondSocket = new FakeSocket();
|
||||
mocks.client.websocket.mockReturnValueOnce(firstSocket).mockReturnValueOnce(secondSocket);
|
||||
const abort = new AbortController();
|
||||
const ctx = createGatewayContext(abort.signal);
|
||||
const run = startClickClackGatewayAccount(ctx);
|
||||
|
||||
await vi.waitFor(() => expect(mocks.client.websocket).toHaveBeenCalledTimes(1));
|
||||
|
||||
firstSocket.emit("error", new Error("gateway dropped"));
|
||||
|
||||
await vi.waitFor(() => expect(mocks.client.websocket).toHaveBeenCalledTimes(2));
|
||||
expect(ctx.log?.warn).toHaveBeenCalledWith(
|
||||
"[default] ClickClack websocket error; reconnecting: gateway dropped",
|
||||
);
|
||||
abort.abort();
|
||||
await run;
|
||||
});
|
||||
|
||||
it("does not log reconnect warnings when abort closes a connecting websocket", async () => {
|
||||
const socket = new FakeSocket();
|
||||
socket.emitErrorOnClose = true;
|
||||
mocks.client.websocket.mockReturnValue(socket);
|
||||
const abort = new AbortController();
|
||||
const ctx = createGatewayContext(abort.signal);
|
||||
const run = startClickClackGatewayAccount(ctx);
|
||||
|
||||
await vi.waitFor(() => expect(mocks.client.websocket).toHaveBeenCalledTimes(1));
|
||||
|
||||
abort.abort();
|
||||
await run;
|
||||
|
||||
expect(ctx.log?.warn).not.toHaveBeenCalledWith(
|
||||
"[default] ClickClack websocket error; reconnecting: socket closed while connecting",
|
||||
);
|
||||
expect(mocks.client.websocket).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clears running status when backlog polling fails", async () => {
|
||||
mocks.client.events.mockRejectedValue(new Error("clickclack unavailable"));
|
||||
const abort = new AbortController();
|
||||
|
||||
@@ -170,11 +170,23 @@ export async function startClickClackGatewayAccount(
|
||||
}
|
||||
const socket = client.websocket(workspaceId, afterCursor);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const abort = () => {
|
||||
socket.close();
|
||||
let settled = false;
|
||||
let removeAbortListener: (() => void) | undefined;
|
||||
const finishSocketCycle = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
removeAbortListener?.();
|
||||
removeAbortListener = undefined;
|
||||
resolve();
|
||||
};
|
||||
const abort = () => {
|
||||
socket.close();
|
||||
finishSocketCycle();
|
||||
};
|
||||
ctx.abortSignal.addEventListener("abort", abort, { once: true });
|
||||
removeAbortListener = () => ctx.abortSignal.removeEventListener("abort", abort);
|
||||
socket.on("message", (data) => {
|
||||
void (async () => {
|
||||
const event = parseSocketEvent(data);
|
||||
@@ -194,11 +206,20 @@ export async function startClickClackGatewayAccount(
|
||||
});
|
||||
})().catch(reject);
|
||||
});
|
||||
socket.on("close", () => {
|
||||
ctx.abortSignal.removeEventListener("abort", abort);
|
||||
resolve();
|
||||
socket.on("close", finishSocketCycle);
|
||||
socket.on("error", (error) => {
|
||||
if (settled || ctx.abortSignal.aborted) {
|
||||
finishSocketCycle();
|
||||
return;
|
||||
}
|
||||
ctx.log?.warn?.(
|
||||
`[${account.accountId}] ClickClack websocket error; reconnecting: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
finishSocketCycle();
|
||||
socket.close();
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
if (!ctx.abortSignal.aborted) {
|
||||
await new Promise((resolve) => {
|
||||
|
||||
@@ -152,10 +152,10 @@
|
||||
]
|
||||
},
|
||||
"url": { "type": "string" },
|
||||
"authToken": { "type": "string" },
|
||||
"authToken": { "type": ["string", "object"] },
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" }
|
||||
"additionalProperties": { "type": ["string", "object"] }
|
||||
},
|
||||
"clearEnv": {
|
||||
"type": "array",
|
||||
@@ -254,6 +254,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"configContracts": {
|
||||
"secretInputs": {
|
||||
"paths": [
|
||||
{ "path": "appServer.authToken", "expected": "string" },
|
||||
{ "path": "appServer.headers.*", "expected": "string" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"uiHints": {
|
||||
"codexDynamicToolsLoading": {
|
||||
"label": "Dynamic Tools Loading",
|
||||
@@ -382,6 +390,7 @@
|
||||
"appServer.headers": {
|
||||
"label": "Headers",
|
||||
"help": "Additional headers sent to the WebSocket app-server.",
|
||||
"sensitive": true,
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.clearEnv": {
|
||||
|
||||
@@ -28,6 +28,10 @@ function resolveRuntimeForTest(params: RuntimeOptionsParams = {}) {
|
||||
return resolveCodexAppServerRuntimeOptions({ env: {}, requirementsToml: null, ...params });
|
||||
}
|
||||
|
||||
function envRef(id: string) {
|
||||
return { source: "env" as const, provider: "default", id };
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`Expected ${label}`);
|
||||
@@ -413,6 +417,65 @@ describe("Codex app-server config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes resolved app-server SecretInput strings through to auth token and headers", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
transport: "websocket",
|
||||
url: "wss://codex-app-server.example.internal/ws",
|
||||
authToken: " resolved-capability-token ",
|
||||
headers: {
|
||||
" x-codex-client-session-token ": " resolved-session-token ",
|
||||
Authorization: " Bearer explicit-token ",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expectFields(runtime.start, "runtime start", {
|
||||
authToken: "resolved-capability-token",
|
||||
headers: {
|
||||
"x-codex-client-session-token": "resolved-session-token",
|
||||
Authorization: "Bearer explicit-token",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unresolved app-server auth token SecretRefs at runtime option resolution", () => {
|
||||
expect(() =>
|
||||
resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
transport: "websocket",
|
||||
url: "wss://codex-app-server.example.internal/ws",
|
||||
authToken: envRef("CODEX_APP_SERVER_TOKEN"),
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toThrow(
|
||||
'plugins.entries.codex.config.appServer.authToken: unresolved SecretRef "env:default:CODEX_APP_SERVER_TOKEN"',
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unresolved app-server header SecretRefs at runtime option resolution", () => {
|
||||
expect(() =>
|
||||
resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
transport: "websocket",
|
||||
url: "wss://codex-app-server.example.internal/ws",
|
||||
authToken: "capability-token",
|
||||
headers: {
|
||||
"x-codex-client-session-token": envRef("CODEX_CLIENT_SESSION_TOKEN"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toThrow(
|
||||
'plugins.entries.codex.config.appServer.headers.x-codex-client-session-token: unresolved SecretRef "env:default:CODEX_CLIENT_SESSION_TOKEN"',
|
||||
);
|
||||
});
|
||||
|
||||
it("treats IPv6 loopback websocket app-servers as local loopback", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
@@ -2314,6 +2377,47 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
expect(second).not.toContain("sk-second");
|
||||
});
|
||||
|
||||
it("derives distinct shared-client keys for distinct headers without exposing them", () => {
|
||||
const first = codexAppServerStartOptionsKey({
|
||||
transport: "websocket",
|
||||
command: "codex",
|
||||
args: [],
|
||||
url: "ws://127.0.0.1:39175",
|
||||
headers: {
|
||||
Authorization: "Bearer first",
|
||||
"x-codex-client-session-token": "session-first",
|
||||
},
|
||||
});
|
||||
const second = codexAppServerStartOptionsKey({
|
||||
transport: "websocket",
|
||||
command: "codex",
|
||||
args: [],
|
||||
url: "ws://127.0.0.1:39175",
|
||||
headers: {
|
||||
Authorization: "Bearer second",
|
||||
"x-codex-client-session-token": "session-second",
|
||||
},
|
||||
});
|
||||
|
||||
expect(first).not.toEqual(second);
|
||||
expect(
|
||||
codexAppServerStartOptionsKey({
|
||||
transport: "websocket",
|
||||
command: "codex",
|
||||
args: [],
|
||||
url: "ws://127.0.0.1:39175",
|
||||
headers: {
|
||||
Authorization: "Bearer first",
|
||||
"x-codex-client-session-token": "session-first",
|
||||
},
|
||||
}),
|
||||
).toEqual(first);
|
||||
expect(first).not.toContain("Bearer first");
|
||||
expect(first).not.toContain("session-first");
|
||||
expect(second).not.toContain("Bearer second");
|
||||
expect(second).not.toContain("session-second");
|
||||
});
|
||||
|
||||
it("keeps secret-derived shared-client keys stable across module reloads", async () => {
|
||||
const startOptions = {
|
||||
transport: "websocket" as const,
|
||||
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
} from "openclaw/plugin-sdk/exec-approvals-runtime";
|
||||
import { resolvePositiveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
||||
import {
|
||||
buildSecretInputSchema,
|
||||
normalizeResolvedSecretInputString,
|
||||
type SecretInput,
|
||||
} from "openclaw/plugin-sdk/secret-input";
|
||||
import { normalizeTrimmedStringList } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { detectWindowsSpawnCommandInlineArgs } from "openclaw/plugin-sdk/windows-spawn";
|
||||
import { z } from "zod";
|
||||
@@ -211,8 +216,8 @@ export type CodexPluginConfig = {
|
||||
command?: string;
|
||||
args?: string[] | string;
|
||||
url?: string;
|
||||
authToken?: string;
|
||||
headers?: Record<string, string>;
|
||||
authToken?: SecretInput;
|
||||
headers?: Record<string, SecretInput>;
|
||||
clearEnv?: string[];
|
||||
remoteWorkspaceRoot?: string;
|
||||
codeModeOnly?: boolean;
|
||||
@@ -294,6 +299,7 @@ const DEFAULT_CODEX_COMPUTER_USE_MARKETPLACE_DISCOVERY_TIMEOUT_MS = 60_000;
|
||||
const DEFAULT_CODEX_APP_SERVER_NETWORK_PROXY_PROFILE_PREFIX = "openclaw-network";
|
||||
|
||||
const codexAppServerTransportSchema = z.enum(["stdio", "websocket"]);
|
||||
const SecretInputSchema = buildSecretInputSchema();
|
||||
const codexAppServerPolicyModeSchema = z.enum(["yolo", "guardian"]);
|
||||
const codexAppServerApprovalPolicySchema = z.enum([
|
||||
"never",
|
||||
@@ -387,8 +393,8 @@ const codexPluginConfigSchema = z
|
||||
command: z.string().optional(),
|
||||
args: z.union([z.array(z.string()), z.string()]).optional(),
|
||||
url: z.string().optional(),
|
||||
authToken: z.string().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
authToken: SecretInputSchema.optional(),
|
||||
headers: z.record(z.string(), SecretInputSchema).optional(),
|
||||
clearEnv: z.array(z.string()).optional(),
|
||||
remoteWorkspaceRoot: codexAppServerRemoteWorkspaceRootSchema.optional(),
|
||||
codeModeOnly: z.boolean().optional(),
|
||||
@@ -531,7 +537,10 @@ export function resolveCodexAppServerRuntimeOptions(
|
||||
const args = resolveArgs(config.args, env.OPENCLAW_CODEX_APP_SERVER_ARGS);
|
||||
const headers = normalizeHeaders(config.headers);
|
||||
const clearEnv = normalizeStringList(config.clearEnv);
|
||||
const authToken = readNonEmptyString(config.authToken);
|
||||
const authToken = normalizeCodexAppServerSecretInput({
|
||||
value: config.authToken,
|
||||
path: "plugins.entries.codex.config.appServer.authToken",
|
||||
});
|
||||
const url = readNonEmptyString(config.url);
|
||||
const connectionClass = inferCodexAppServerConnectionClass({ transport, url });
|
||||
const remoteAppsSubstrate: CodexAppServerRemoteAppsSubstrate = "preconfigured";
|
||||
@@ -868,9 +877,9 @@ export function codexAppServerStartOptionsKey(
|
||||
args: options.args,
|
||||
url: options.url ?? null,
|
||||
authToken: hashSecretForKey(options.authToken, "authToken"),
|
||||
headers: Object.entries(options.headers).toSorted(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
),
|
||||
headers: Object.entries(options.headers)
|
||||
.toSorted(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => [key, hashSecretForKey(value, `header:${key}`)]),
|
||||
env: Object.entries(options.env ?? {})
|
||||
.toSorted(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => [key, hashSecretForKey(value, `env:${key}`)]),
|
||||
@@ -2037,11 +2046,27 @@ function normalizeHeaders(value: unknown): Record<string, string> {
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.map(([key, child]) => [key.trim(), readNonEmptyString(child)] as const)
|
||||
.map(
|
||||
([key, child]) =>
|
||||
[
|
||||
key.trim(),
|
||||
normalizeCodexAppServerSecretInput({
|
||||
value: child,
|
||||
path: `plugins.entries.codex.config.appServer.headers.${key}`,
|
||||
}),
|
||||
] as const,
|
||||
)
|
||||
.filter((entry): entry is readonly [string, string] => Boolean(entry[0] && entry[1])),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCodexAppServerSecretInput(params: {
|
||||
value: unknown;
|
||||
path: string;
|
||||
}): string | undefined {
|
||||
return normalizeResolvedSecretInputString(params);
|
||||
}
|
||||
|
||||
function normalizeStringList(value: unknown): string[] {
|
||||
return normalizeTrimmedStringList(value);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { AgentMessage } from "openclaw/plugin-sdk/agent-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CODEX_TURN_START_TEXT_INPUT_MAX_CHARS,
|
||||
fitCodexProjectedContextForTurnStart,
|
||||
projectContextEngineAssemblyForCodex,
|
||||
resolveCodexContextEngineProjectionMaxChars,
|
||||
@@ -226,6 +227,92 @@ describe("projectContextEngineAssemblyForCodex", () => {
|
||||
expect(fitted).not.toContain("old context");
|
||||
});
|
||||
|
||||
it("bounds output when the non-context text alone exceeds the turn limit", () => {
|
||||
// A large older-context header prefix pushes before + after over maxChars
|
||||
// while the trailing user request stays small enough to keep its label.
|
||||
const before = `OpenClaw assembled context for this turn:\n${"prefix ".repeat(120)}`;
|
||||
const context = "older context ".repeat(40);
|
||||
const prompt = `urgent request ${"q".repeat(120)}`;
|
||||
const after = `\n</conversation_context>\n\nCurrent user request:\n${prompt}`;
|
||||
const promptText = `${before}${context}${after}`;
|
||||
const maxChars = 420;
|
||||
// before + after already exceed maxChars, so the context budget is non-positive.
|
||||
expect(before.length + after.length).toBeGreaterThan(maxChars);
|
||||
|
||||
const fitted = fitCodexProjectedContextForTurnStart({
|
||||
promptText,
|
||||
contextRange: { start: before.length, end: before.length + context.length },
|
||||
maxChars,
|
||||
});
|
||||
|
||||
expect(fitted.length).toBeLessThanOrEqual(maxChars);
|
||||
// The user's actual request is the priority tail and must survive truncation.
|
||||
expect(fitted).toContain("Current user request:");
|
||||
expect(fitted.endsWith("q".repeat(40))).toBe(true);
|
||||
// The dropped older context is reported, not silently lost.
|
||||
expect(fitted).toContain("[truncated ");
|
||||
});
|
||||
|
||||
it("bounds output for a large request under the default Codex turn limit", () => {
|
||||
const maxChars = CODEX_TURN_START_TEXT_INPUT_MAX_CHARS;
|
||||
// A large assembled header prefix already over the cap forces the
|
||||
// non-positive context budget on the real default limit (1 << 20).
|
||||
const before = `header\n${"older history ".repeat(90_000)}`;
|
||||
const context = "x".repeat(2_000);
|
||||
const prompt = `urgent request ${"u".repeat(2_000)}`;
|
||||
const after = `\n</conversation_context>\n\nCurrent user request:\n${prompt}`;
|
||||
const promptText = `${before}${context}${after}`;
|
||||
expect(before.length + after.length).toBeGreaterThan(maxChars);
|
||||
|
||||
const fitted = fitCodexProjectedContextForTurnStart({
|
||||
promptText,
|
||||
contextRange: { start: before.length, end: before.length + context.length },
|
||||
// maxChars omitted -> defaults to CODEX_TURN_START_TEXT_INPUT_MAX_CHARS.
|
||||
});
|
||||
|
||||
expect(fitted.length).toBeLessThanOrEqual(maxChars);
|
||||
// The user request is the priority tail and survives even though the older
|
||||
// header text is truncated to satisfy the limit.
|
||||
expect(fitted).toContain("Current user request:");
|
||||
expect(fitted.endsWith("u".repeat(1_000))).toBe(true);
|
||||
});
|
||||
|
||||
it("never splits a UTF-16 surrogate pair at the truncation boundary", () => {
|
||||
// Drive the non-positive-budget path with an emoji (surrogate pair) sitting
|
||||
// across the kept-tail cut. A naive code-unit slice would orphan the low
|
||||
// surrogate into U+FFFD; the boundary must stay on a whole code point.
|
||||
const before = `OpenClaw assembled context for this turn:\n${"H".repeat(300)}`;
|
||||
const context = "older context ".repeat(20);
|
||||
// Emoji immediately before the user text so the cut can fall mid-pair.
|
||||
const prompt = `\u{1F600}${"U".repeat(60)}`;
|
||||
const after = `\n</conversation_context>\n\nCurrent user request:\n${prompt}`;
|
||||
const promptText = `${before}${context}${after}`;
|
||||
const contextRange = { start: before.length, end: before.length + context.length };
|
||||
|
||||
// Sweep cap sizes around the cut so the test is not brittle to marker length;
|
||||
// at least one value lands the boundary inside the surrogate pair.
|
||||
for (let maxChars = 90; maxChars <= 140; maxChars += 1) {
|
||||
const fitted = fitCodexProjectedContextForTurnStart({ promptText, contextRange, maxChars });
|
||||
expect(fitted.length).toBeLessThanOrEqual(maxChars);
|
||||
// U+FFFD only appears when a lone surrogate is rendered, i.e. a split pair.
|
||||
expect(fitted).not.toContain("<22>");
|
||||
// Any surviving emoji must be the complete pair, not a lone low surrogate.
|
||||
for (let i = 0; i < fitted.length; i += 1) {
|
||||
const code = fitted.charCodeAt(i);
|
||||
const isLowSurrogate = code >= 0xdc00 && code <= 0xdfff;
|
||||
const isHighSurrogate = code >= 0xd800 && code <= 0xdbff;
|
||||
if (isLowSurrogate) {
|
||||
const prev = fitted.charCodeAt(i - 1);
|
||||
expect(prev >= 0xd800 && prev <= 0xdbff).toBe(true);
|
||||
}
|
||||
if (isHighSurrogate) {
|
||||
const next = fitted.charCodeAt(i + 1);
|
||||
expect(next >= 0xdc00 && next <= 0xdfff).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the old conservative cap when no runtime budget is available", () => {
|
||||
expect(resolveCodexContextEngineProjectionMaxChars({})).toBe(24_000);
|
||||
expect(resolveCodexContextEngineProjectionMaxChars({ contextTokenBudget: 0 })).toBe(24_000);
|
||||
|
||||
@@ -139,8 +139,16 @@ export function fitCodexProjectedContextForTurnStart(params: {
|
||||
const context = params.promptText.slice(range.start, range.end);
|
||||
const afterContext = params.promptText.slice(range.end);
|
||||
const contextBudget = maxChars - beforeContext.length - afterContext.length;
|
||||
const fittedContext = truncateOlderContext(context, contextBudget);
|
||||
return `${beforeContext}${fittedContext}${afterContext}`;
|
||||
if (contextBudget > 0) {
|
||||
const fittedContext = truncateOlderContext(context, contextBudget);
|
||||
return `${beforeContext}${fittedContext}${afterContext}`;
|
||||
}
|
||||
// The header plus the trailing user request already fill the limit, so the
|
||||
// older context drops entirely and the remaining text must still be bounded;
|
||||
// otherwise Codex app-server rejects the turn for exceeding
|
||||
// MAX_USER_INPUT_TEXT_CHARS. truncateOlderContext keeps the tail, preserving
|
||||
// the user's actual request over the older header text.
|
||||
return truncateOlderContext(`${beforeContext}${afterContext}`, maxChars);
|
||||
}
|
||||
|
||||
function normalizeProjectedContextRange(
|
||||
@@ -457,5 +465,20 @@ function truncateOlderContext(text: string, maxChars: number): string {
|
||||
return marker.slice(0, maxChars);
|
||||
}
|
||||
tailChars = maxChars - marker.length;
|
||||
return `${marker}${text.slice(text.length - tailChars).trimStart()}`;
|
||||
return `${marker}${sliceTailFromCodePointBoundary(text, tailChars).trimStart()}`;
|
||||
}
|
||||
|
||||
// Keep the kept tail at a code-point boundary so a UTF-16 surrogate pair is
|
||||
// never split at the cut: a tail start that lands on a low surrogate would
|
||||
// orphan it into U+FFFD, corrupting the first character. Dropping that unit
|
||||
// stays within maxChars (it only removes a char), so the bound still holds.
|
||||
function sliceTailFromCodePointBoundary(text: string, tailChars: number): string {
|
||||
let start = text.length - tailChars;
|
||||
if (start > 0 && start < text.length) {
|
||||
const code = text.charCodeAt(start);
|
||||
if (code >= 0xdc00 && code <= 0xdfff) {
|
||||
start += 1;
|
||||
}
|
||||
}
|
||||
return text.slice(start);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
CodexNativeSubagentMonitor,
|
||||
registerCodexNativeSubagentMonitor,
|
||||
} from "./native-subagent-monitor.js";
|
||||
import type { CodexServerNotification } from "./protocol.js";
|
||||
import type { CodexServerNotification, JsonValue } from "./protocol.js";
|
||||
|
||||
function createClient() {
|
||||
const handlers = new Set<(notification: CodexServerNotification) => Promise<void> | void>();
|
||||
@@ -158,6 +158,27 @@ function nativeCompletionNotification(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function childTurnCompletedNotification(params: {
|
||||
status: "completed" | "failed" | "interrupted";
|
||||
error?: string;
|
||||
turnId?: string;
|
||||
items?: JsonValue[];
|
||||
}): CodexServerNotification {
|
||||
const turnId = params.turnId ?? "child-turn";
|
||||
return {
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "child-thread",
|
||||
turn: {
|
||||
id: turnId,
|
||||
status: params.status,
|
||||
...(params.items ? { items: params.items } : {}),
|
||||
...(params.error ? { error: { message: params.error } } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("CodexNativeSubagentMonitor", () => {
|
||||
it("keeps native subagent task mirroring alive on the shared client", async () => {
|
||||
const client = createClient();
|
||||
@@ -314,12 +335,10 @@ describe("CodexNativeSubagentMonitor", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("delivers child agent-message completion when a native subagent becomes idle", async () => {
|
||||
it("delivers a completed child turn with its final agent message", async () => {
|
||||
const client = createClient();
|
||||
const runtime = createRuntime();
|
||||
const monitor = new CodexNativeSubagentMonitor(client, runtime, {
|
||||
codexHome: "/tmp/codex-home",
|
||||
});
|
||||
const monitor = new CodexNativeSubagentMonitor(client, runtime);
|
||||
monitor.registerParent({
|
||||
parentThreadId: "parent-thread",
|
||||
requesterSessionKey: "agent:main:discord:channel:C123",
|
||||
@@ -329,17 +348,36 @@ describe("CodexNativeSubagentMonitor", () => {
|
||||
|
||||
await notifyChildStarted(client);
|
||||
await client.notify({
|
||||
method: "item/completed",
|
||||
method: "item/started",
|
||||
params: {
|
||||
threadId: "child-thread",
|
||||
turnId: "child-turn",
|
||||
item: {
|
||||
type: "agentMessage",
|
||||
id: "msg-child-final",
|
||||
phase: "final_answer",
|
||||
text: "child final result",
|
||||
text: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
await client.notify({
|
||||
method: "item/agentMessage/delta",
|
||||
params: {
|
||||
threadId: "child-thread",
|
||||
turnId: "child-turn",
|
||||
itemId: "msg-child-final",
|
||||
delta: "child ",
|
||||
},
|
||||
});
|
||||
await client.notify({
|
||||
method: "item/agentMessage/delta",
|
||||
params: {
|
||||
threadId: "child-thread",
|
||||
turnId: "child-turn",
|
||||
itemId: "msg-child-final",
|
||||
delta: "final result",
|
||||
},
|
||||
});
|
||||
|
||||
expect(runtime.deliverAgentHarnessTaskCompletion).not.toHaveBeenCalled();
|
||||
|
||||
@@ -351,6 +389,9 @@ describe("CodexNativeSubagentMonitor", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(runtime.deliverAgentHarnessTaskCompletion).not.toHaveBeenCalled();
|
||||
await client.notify(childTurnCompletedNotification({ status: "completed" }));
|
||||
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runId: "codex-thread:child-thread",
|
||||
@@ -362,7 +403,7 @@ describe("CodexNativeSubagentMonitor", () => {
|
||||
expect.objectContaining({
|
||||
childSessionId: "child-thread",
|
||||
status: "succeeded",
|
||||
statusLabel: "agent_message",
|
||||
statusLabel: "turn_completed",
|
||||
result: "child final result",
|
||||
}),
|
||||
);
|
||||
@@ -370,12 +411,10 @@ describe("CodexNativeSubagentMonitor", () => {
|
||||
client.close();
|
||||
});
|
||||
|
||||
it("does not deliver commentary-only child messages as native subagent completion", async () => {
|
||||
it("does not deliver a commentary delta when the completion snapshot is absent", async () => {
|
||||
const client = createClient();
|
||||
const runtime = createRuntime();
|
||||
const monitor = new CodexNativeSubagentMonitor(client, runtime, {
|
||||
codexHome: "/tmp/codex-home",
|
||||
});
|
||||
const monitor = new CodexNativeSubagentMonitor(client, runtime);
|
||||
monitor.registerParent({
|
||||
parentThreadId: "parent-thread",
|
||||
requesterSessionKey: "agent:main:discord:channel:C123",
|
||||
@@ -384,10 +423,66 @@ describe("CodexNativeSubagentMonitor", () => {
|
||||
});
|
||||
|
||||
await notifyChildStarted(client);
|
||||
await client.notify({
|
||||
method: "item/started",
|
||||
params: {
|
||||
threadId: "child-thread",
|
||||
turnId: "child-turn",
|
||||
item: {
|
||||
type: "agentMessage",
|
||||
id: "msg-child-commentary",
|
||||
phase: "commentary",
|
||||
text: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
await client.notify({
|
||||
method: "item/agentMessage/delta",
|
||||
params: {
|
||||
threadId: "child-thread",
|
||||
turnId: "child-turn",
|
||||
itemId: "msg-child-commentary",
|
||||
delta: "checking now",
|
||||
},
|
||||
});
|
||||
await client.notify(childTurnCompletedNotification({ status: "completed" }));
|
||||
|
||||
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
childSessionId: "child-thread",
|
||||
result: "Codex native subagent completed without a final assistant message.",
|
||||
}),
|
||||
);
|
||||
|
||||
client.close();
|
||||
});
|
||||
|
||||
it("does not complete commentary-only child messages before a terminal turn", async () => {
|
||||
const client = createClient();
|
||||
const runtime = createRuntime();
|
||||
const monitor = new CodexNativeSubagentMonitor(client, runtime);
|
||||
monitor.registerParent({
|
||||
parentThreadId: "parent-thread",
|
||||
requesterSessionKey: "agent:main:discord:channel:C123",
|
||||
taskRuntimeScope: createTaskScope(),
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
await notifyChildStarted(client);
|
||||
await client.notify({
|
||||
method: "item/agentMessage/delta",
|
||||
params: {
|
||||
threadId: "child-thread",
|
||||
turnId: "child-turn",
|
||||
itemId: "msg-child-commentary",
|
||||
delta: "checking now",
|
||||
},
|
||||
});
|
||||
await client.notify({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: "child-thread",
|
||||
turnId: "child-turn",
|
||||
item: {
|
||||
type: "agentMessage",
|
||||
id: "msg-child-commentary",
|
||||
@@ -407,6 +502,162 @@ describe("CodexNativeSubagentMonitor", () => {
|
||||
expect(runtime.finalizeTaskRunByRunId).not.toHaveBeenCalled();
|
||||
expect(runtime.deliverAgentHarnessTaskCompletion).not.toHaveBeenCalled();
|
||||
|
||||
await client.notify(childTurnCompletedNotification({ status: "completed" }));
|
||||
|
||||
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
childSessionId: "child-thread",
|
||||
result: "Codex native subagent completed without a final assistant message.",
|
||||
}),
|
||||
);
|
||||
|
||||
client.close();
|
||||
});
|
||||
|
||||
it("delivers a completed child turn with its snapshot-only final message", async () => {
|
||||
const client = createClient();
|
||||
const runtime = createRuntime();
|
||||
const monitor = new CodexNativeSubagentMonitor(client, runtime);
|
||||
monitor.registerParent({
|
||||
parentThreadId: "parent-thread",
|
||||
requesterSessionKey: "agent:main:discord:channel:C123",
|
||||
taskRuntimeScope: createTaskScope(),
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
await notifyChildStarted(client);
|
||||
await client.notify(
|
||||
childTurnCompletedNotification({
|
||||
status: "completed",
|
||||
items: [
|
||||
{
|
||||
id: "msg-child-snapshot",
|
||||
type: "agentMessage",
|
||||
phase: "final_answer",
|
||||
text: "snapshot final result",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
childSessionId: "child-thread",
|
||||
result: "snapshot final result",
|
||||
}),
|
||||
);
|
||||
|
||||
client.close();
|
||||
});
|
||||
|
||||
it("reconciles transcript text for a completed child turn without a final message", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-subagent-"));
|
||||
const codexHome = path.join(tempDir, "codex-home");
|
||||
const transcriptDir = path.join(codexHome, "sessions", "2026", "06", "09");
|
||||
await fs.mkdir(transcriptDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(transcriptDir, "rollout-2026-06-09T10-11-12-child-thread.jsonl"),
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "session_meta",
|
||||
payload: {
|
||||
source: {
|
||||
subagent: {
|
||||
thread_spawn: {
|
||||
parent_thread_id: "parent-thread",
|
||||
depth: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: "2026-06-09T10:12:00.000Z",
|
||||
type: "event_msg",
|
||||
payload: {
|
||||
type: "task_complete",
|
||||
last_agent_message: "child turn transcript result",
|
||||
completed_at: 1781009520,
|
||||
},
|
||||
}),
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
const client = createClient();
|
||||
const runtime = createRuntime();
|
||||
const monitor = new CodexNativeSubagentMonitor(client, runtime, {
|
||||
codexHome,
|
||||
transcriptPollDelaysMs: [60_000],
|
||||
});
|
||||
monitor.registerParent({
|
||||
parentThreadId: "parent-thread",
|
||||
requesterSessionKey: "agent:main:discord:channel:C123",
|
||||
taskRuntimeScope: createTaskScope(),
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
await notifyChildStarted(client);
|
||||
await client.notify(childTurnCompletedNotification({ status: "completed" }));
|
||||
|
||||
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
childSessionId: "child-thread",
|
||||
statusLabel: "task_complete",
|
||||
result: "child turn transcript result",
|
||||
}),
|
||||
);
|
||||
|
||||
client.close();
|
||||
});
|
||||
|
||||
it("does not reuse an interrupted child turn's message after resuming", async () => {
|
||||
const client = createClient();
|
||||
const runtime = createRuntime();
|
||||
const monitor = new CodexNativeSubagentMonitor(client, runtime);
|
||||
monitor.registerParent({
|
||||
parentThreadId: "parent-thread",
|
||||
requesterSessionKey: "agent:main:discord:channel:C123",
|
||||
taskRuntimeScope: createTaskScope(),
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
await notifyChildStarted(client);
|
||||
await client.notify({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: "child-thread",
|
||||
turnId: "child-turn",
|
||||
item: {
|
||||
type: "agentMessage",
|
||||
id: "msg-child-partial",
|
||||
text: "partial child result",
|
||||
},
|
||||
},
|
||||
});
|
||||
await client.notify({
|
||||
method: "thread/status/changed",
|
||||
params: {
|
||||
threadId: "child-thread",
|
||||
status: { type: "idle" },
|
||||
},
|
||||
});
|
||||
await client.notify(childTurnCompletedNotification({ status: "interrupted" }));
|
||||
|
||||
expect(runtime.deliverAgentHarnessTaskCompletion).not.toHaveBeenCalled();
|
||||
|
||||
await client.notify(
|
||||
childTurnCompletedNotification({ status: "completed", turnId: "resumed-child-turn" }),
|
||||
);
|
||||
|
||||
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
childSessionId: "child-thread",
|
||||
status: "succeeded",
|
||||
result: "Codex native subagent completed without a final assistant message.",
|
||||
}),
|
||||
);
|
||||
|
||||
client.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -51,14 +51,11 @@ type ParentState = {
|
||||
type ChildState = {
|
||||
childThreadId: string;
|
||||
parentThreadId: string;
|
||||
assistantMessagesByTurn: Map<string, ChildAssistantMessages>;
|
||||
transcriptPath?: string;
|
||||
transcriptPollAttempt: number;
|
||||
transcriptPollTimer?: ReturnType<typeof setTimeout>;
|
||||
transcriptTerminal: boolean;
|
||||
idle: boolean;
|
||||
lastAgentMessage?: string;
|
||||
lastAgentMessageAt?: number;
|
||||
agentMessageCompletionDelivered: boolean;
|
||||
pendingCompletion?: CodexNativeSubagentCompletion;
|
||||
pendingCompletionEventAt?: number;
|
||||
completionDeliveryAttempt: number;
|
||||
@@ -67,6 +64,13 @@ type ChildState = {
|
||||
noFinalCompletionFallbackTimer?: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
type ChildAssistantMessages = {
|
||||
texts: Map<string, string>;
|
||||
order: string[];
|
||||
commentaryIds: Set<string>;
|
||||
finalMessageIds: Set<string>;
|
||||
};
|
||||
|
||||
type TranscriptCompletion = CodexNativeSubagentCompletion & {
|
||||
parentThreadId?: string;
|
||||
completedAt?: number;
|
||||
@@ -215,10 +219,9 @@ export class CodexNativeSubagentMonitor {
|
||||
});
|
||||
}
|
||||
}
|
||||
const childThreadId = this.recordChildAgentMessage(notification);
|
||||
const idleChildThreadId = this.recordChildIdle(notification);
|
||||
this.captureChildAssistantMessage(notification);
|
||||
await this.handleChildTurnCompletion(notification);
|
||||
await this.handleCompletionNotification(notification);
|
||||
await this.processChildAgentMessageCompletion(childThreadId ?? idleChildThreadId);
|
||||
}
|
||||
|
||||
private ensureParentTaskRuntime(state: ParentState): void {
|
||||
@@ -299,7 +302,11 @@ export class CodexNativeSubagentMonitor {
|
||||
nativeCompletion.agentPath,
|
||||
);
|
||||
const childState = childThreadId ? this.childStates.get(childThreadId) : undefined;
|
||||
if (!childState || childState.parentThreadId !== state.parentThreadId) {
|
||||
if (
|
||||
!childState ||
|
||||
childState.parentThreadId !== state.parentThreadId ||
|
||||
childState.transcriptTerminal
|
||||
) {
|
||||
embeddedAgentLog.warn(
|
||||
"Ignoring Codex native subagent completion for unknown child thread",
|
||||
{
|
||||
@@ -310,21 +317,151 @@ export class CodexNativeSubagentMonitor {
|
||||
continue;
|
||||
}
|
||||
const completion = toThreadCompletion(nativeCompletion, childState.childThreadId);
|
||||
if (shouldWaitForTranscriptCompletion(completion, this.codexHome)) {
|
||||
// Codex can notify `completed: null` before the child transcript exposes
|
||||
// its final assistant message; poll briefly before delivering the no-final fallback.
|
||||
const eventAt = Date.now();
|
||||
const reconciled = await this.reconcileChildTranscript(childState.childThreadId);
|
||||
if (!reconciled) {
|
||||
this.scheduleTranscriptPoll(childState);
|
||||
this.scheduleNoFinalCompletionFallback(state, childState, completion, eventAt);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
await this.processCompletion(state, completion);
|
||||
await this.processChildCompletion(state, childState, completion);
|
||||
}
|
||||
}
|
||||
|
||||
private captureChildAssistantMessage(notification: CodexServerNotification): void {
|
||||
const params = isJsonObject(notification.params) ? notification.params : undefined;
|
||||
const childThreadId = readString(params, "threadId")?.trim();
|
||||
const childState = childThreadId ? this.childStates.get(childThreadId) : undefined;
|
||||
if (!childState || childState.transcriptTerminal) {
|
||||
return;
|
||||
}
|
||||
if (notification.method === "item/agentMessage/delta") {
|
||||
const turnId = readString(params, "turnId");
|
||||
const itemId = readString(params, "itemId");
|
||||
const delta = readString(params, "delta");
|
||||
if (turnId && itemId && delta) {
|
||||
this.recordChildAssistantMessage(childState, turnId, itemId, delta);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (notification.method !== "item/started" && notification.method !== "item/completed") {
|
||||
return;
|
||||
}
|
||||
const turnId = readString(params, "turnId");
|
||||
const item = isJsonObject(params?.item) ? params.item : undefined;
|
||||
this.captureChildAssistantMessageItem(childState, turnId, item);
|
||||
}
|
||||
|
||||
private captureChildAssistantMessageItem(
|
||||
childState: ChildState,
|
||||
turnId: string | undefined,
|
||||
item: JsonObject | undefined,
|
||||
): void {
|
||||
if (readString(item, "type") !== "agentMessage") {
|
||||
return;
|
||||
}
|
||||
const itemId = readString(item, "id");
|
||||
if (!turnId || !itemId) {
|
||||
return;
|
||||
}
|
||||
const assistantMessages = this.getChildAssistantMessages(childState, turnId);
|
||||
const phase = readString(item, "phase");
|
||||
if (phase === "commentary") {
|
||||
assistantMessages.commentaryIds.add(itemId);
|
||||
} else {
|
||||
assistantMessages.finalMessageIds.add(itemId);
|
||||
}
|
||||
const text = readString(item, "text");
|
||||
if (text) {
|
||||
this.recordChildAssistantMessage(childState, turnId, itemId, text, { replace: true });
|
||||
}
|
||||
}
|
||||
|
||||
private captureChildTurnAssistantMessages(childState: ChildState, turn: JsonObject): void {
|
||||
const turnId = readString(turn, "id");
|
||||
if (!turnId || !Array.isArray(turn.items)) {
|
||||
return;
|
||||
}
|
||||
for (const item of turn.items) {
|
||||
this.captureChildAssistantMessageItem(
|
||||
childState,
|
||||
turnId,
|
||||
isJsonObject(item) ? item : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private recordChildAssistantMessage(
|
||||
childState: ChildState,
|
||||
turnId: string,
|
||||
itemId: string,
|
||||
text: string,
|
||||
options: { replace?: boolean } = {},
|
||||
): void {
|
||||
const assistantMessages = this.getChildAssistantMessages(childState, turnId);
|
||||
if (!assistantMessages.texts.has(itemId)) {
|
||||
assistantMessages.order.push(itemId);
|
||||
}
|
||||
const existing = assistantMessages.texts.get(itemId) ?? "";
|
||||
assistantMessages.texts.set(itemId, options.replace ? text : `${existing}${text}`);
|
||||
}
|
||||
|
||||
private getChildAssistantMessages(
|
||||
childState: ChildState,
|
||||
turnId: string,
|
||||
): ChildAssistantMessages {
|
||||
const existing = childState.assistantMessagesByTurn.get(turnId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const assistantMessages: ChildAssistantMessages = {
|
||||
texts: new Map<string, string>(),
|
||||
order: [],
|
||||
commentaryIds: new Set<string>(),
|
||||
finalMessageIds: new Set<string>(),
|
||||
};
|
||||
childState.assistantMessagesByTurn.set(turnId, assistantMessages);
|
||||
return assistantMessages;
|
||||
}
|
||||
|
||||
private async handleChildTurnCompletion(notification: CodexServerNotification): Promise<void> {
|
||||
if (notification.method !== "turn/completed") {
|
||||
return;
|
||||
}
|
||||
const params = isJsonObject(notification.params) ? notification.params : undefined;
|
||||
const childThreadId = readString(params, "threadId")?.trim();
|
||||
const childState = childThreadId ? this.childStates.get(childThreadId) : undefined;
|
||||
const state = childState ? this.parentStates.get(childState.parentThreadId) : undefined;
|
||||
const turn = isJsonObject(params?.turn) ? params.turn : undefined;
|
||||
if (childState && turn && readString(turn, "status") === "interrupted") {
|
||||
const turnId = readString(turn, "id");
|
||||
if (turnId) {
|
||||
childState.assistantMessagesByTurn.delete(turnId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (childState && turn) {
|
||||
this.captureChildTurnAssistantMessages(childState, turn);
|
||||
}
|
||||
const completion = childState && turn ? toChildTurnCompletion(childState, turn) : undefined;
|
||||
if (!state || !childState || childState.transcriptTerminal || !completion) {
|
||||
return;
|
||||
}
|
||||
await this.processChildCompletion(state, childState, completion);
|
||||
}
|
||||
|
||||
private async processChildCompletion(
|
||||
state: ParentState,
|
||||
childState: ChildState,
|
||||
completion: CodexNativeSubagentCompletion,
|
||||
): Promise<void> {
|
||||
if (shouldWaitForTranscriptCompletion(completion, this.codexHome)) {
|
||||
// Codex can notify `completed: null` before the child transcript exposes
|
||||
// its final assistant message; poll briefly before delivering the no-final fallback.
|
||||
const eventAt = Date.now();
|
||||
const reconciled = await this.reconcileChildTranscript(childState.childThreadId);
|
||||
if (!reconciled) {
|
||||
this.scheduleTranscriptPoll(childState);
|
||||
this.scheduleNoFinalCompletionFallback(state, childState, completion, eventAt);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await this.processCompletion(state, completion);
|
||||
}
|
||||
|
||||
async reconcileChildTranscript(
|
||||
childThreadId: string,
|
||||
options: { allowTreeScan?: boolean } = {},
|
||||
@@ -557,10 +694,9 @@ export class CodexNativeSubagentMonitor {
|
||||
childState = {
|
||||
childThreadId: normalizedChildThreadId,
|
||||
parentThreadId: normalizedParentThreadId,
|
||||
assistantMessagesByTurn: new Map<string, ChildAssistantMessages>(),
|
||||
transcriptPollAttempt: 0,
|
||||
transcriptTerminal: false,
|
||||
idle: false,
|
||||
agentMessageCompletionDelivered: false,
|
||||
completionDeliveryAttempt: 0,
|
||||
};
|
||||
this.childStates.set(normalizedChildThreadId, childState);
|
||||
@@ -570,83 +706,6 @@ export class CodexNativeSubagentMonitor {
|
||||
}
|
||||
}
|
||||
|
||||
private recordChildAgentMessage(notification: CodexServerNotification): string | undefined {
|
||||
if (notification.method !== "item/completed") {
|
||||
return undefined;
|
||||
}
|
||||
const params = isJsonObject(notification.params) ? notification.params : undefined;
|
||||
const item = isJsonObject(params?.item) ? params.item : undefined;
|
||||
if (!params || !item || readString(item, "type") !== "agentMessage") {
|
||||
return undefined;
|
||||
}
|
||||
const childThreadId = readString(params, "threadId")?.trim();
|
||||
const childState = childThreadId ? this.childStates.get(childThreadId) : undefined;
|
||||
if (!childState || childState.transcriptTerminal) {
|
||||
return undefined;
|
||||
}
|
||||
// Codex app-server can report the child final answer as the child thread's
|
||||
// own agentMessage without also emitting a parent subagent notification.
|
||||
// Pair it with idle below so commentary does not become a false terminal.
|
||||
const phase = readString(item, "phase");
|
||||
if (phase === "commentary") {
|
||||
return undefined;
|
||||
}
|
||||
const text = readString(item, "text")?.trim();
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
childState.lastAgentMessage = text;
|
||||
childState.lastAgentMessageAt = Date.now();
|
||||
return childState.childThreadId;
|
||||
}
|
||||
|
||||
private recordChildIdle(notification: CodexServerNotification): string | undefined {
|
||||
if (notification.method !== "thread/status/changed") {
|
||||
return undefined;
|
||||
}
|
||||
const params = isJsonObject(notification.params) ? notification.params : undefined;
|
||||
if (!params || !isJsonObject(params.status) || readString(params.status, "type") !== "idle") {
|
||||
return undefined;
|
||||
}
|
||||
const childThreadId = readString(params, "threadId")?.trim();
|
||||
const childState = childThreadId ? this.childStates.get(childThreadId) : undefined;
|
||||
if (!childState || childState.transcriptTerminal) {
|
||||
return undefined;
|
||||
}
|
||||
childState.idle = true;
|
||||
return childState.childThreadId;
|
||||
}
|
||||
|
||||
private async processChildAgentMessageCompletion(
|
||||
childThreadId: string | undefined,
|
||||
): Promise<void> {
|
||||
const childState = childThreadId ? this.childStates.get(childThreadId) : undefined;
|
||||
if (
|
||||
!childState ||
|
||||
!childState.idle ||
|
||||
childState.transcriptTerminal ||
|
||||
childState.agentMessageCompletionDelivered ||
|
||||
!childState.lastAgentMessage
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const state = this.parentStates.get(childState.parentThreadId);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
childState.agentMessageCompletionDelivered = true;
|
||||
await this.processCompletion(
|
||||
state,
|
||||
{
|
||||
childThreadId: childState.childThreadId,
|
||||
status: "succeeded",
|
||||
statusLabel: "agent_message",
|
||||
result: childState.lastAgentMessage,
|
||||
},
|
||||
childState.lastAgentMessageAt,
|
||||
);
|
||||
}
|
||||
|
||||
private ensureChildState(parentThreadId: string, childThreadId: string): ChildState {
|
||||
this.registerChildThread(parentThreadId, childThreadId);
|
||||
return this.childStates.get(childThreadId.trim())!;
|
||||
@@ -970,6 +1029,62 @@ function buildCompletionDedupeKey(
|
||||
return `${parentThreadId}:${completion.childThreadId}:${completion.status}:${hash}`;
|
||||
}
|
||||
|
||||
function toChildTurnCompletion(
|
||||
childState: ChildState,
|
||||
turn: JsonObject,
|
||||
): CodexNativeSubagentCompletion | undefined {
|
||||
const status = readString(turn, "status");
|
||||
if (status === "completed") {
|
||||
const turnId = readString(turn, "id");
|
||||
const result = turnId ? lastChildAssistantMessage(childState, turnId) : undefined;
|
||||
return {
|
||||
childThreadId: childState.childThreadId,
|
||||
status: "succeeded",
|
||||
statusLabel: result ? "turn_completed" : "completed_without_final_message",
|
||||
result: result ?? "Codex native subagent completed without a final assistant message.",
|
||||
};
|
||||
}
|
||||
if (status === "failed") {
|
||||
return {
|
||||
childThreadId: childState.childThreadId,
|
||||
status: "failed",
|
||||
statusLabel: "turn_failed",
|
||||
result: readTurnErrorMessage(turn) ?? "Codex native subagent failed.",
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function lastChildAssistantMessage(childState: ChildState, turnId: string): string | undefined {
|
||||
const assistantMessages = childState.assistantMessagesByTurn.get(turnId);
|
||||
if (!assistantMessages) {
|
||||
return undefined;
|
||||
}
|
||||
for (let index = assistantMessages.order.length - 1; index >= 0; index -= 1) {
|
||||
const itemId = assistantMessages.order[index];
|
||||
if (
|
||||
assistantMessages.finalMessageIds.has(itemId) &&
|
||||
!assistantMessages.commentaryIds.has(itemId)
|
||||
) {
|
||||
const text = normalizeOptionalString(assistantMessages.texts.get(itemId));
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readTurnErrorMessage(turn: JsonObject): string | undefined {
|
||||
const error = isJsonObject(turn.error) ? turn.error : undefined;
|
||||
return (
|
||||
normalizeOptionalString(readString(error, "message")) ??
|
||||
normalizeOptionalString(
|
||||
isJsonObject(error?.codexErrorInfo) ? readString(error.codexErrorInfo, "message") : undefined,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function buildParentAgentPathKey(parentThreadId: string, agentPath: string): string {
|
||||
return `${parentThreadId}\0${agentPath}`;
|
||||
}
|
||||
|
||||
@@ -4,14 +4,13 @@ import { describe, expect, it } from "vitest";
|
||||
import { buildCohereCatalogModels, COHERE_BASE_URL, COHERE_MODEL_CATALOG } from "./models.js";
|
||||
import {
|
||||
applyCohereConfig,
|
||||
applyCohereProviderConfig,
|
||||
COHERE_DEFAULT_MODEL_ID,
|
||||
COHERE_DEFAULT_MODEL_REF,
|
||||
} from "./onboard.js";
|
||||
|
||||
describe("Cohere onboarding", () => {
|
||||
it("registers the manifest catalog through the compatibility endpoint", () => {
|
||||
const result = applyCohereProviderConfig({});
|
||||
it("registers the manifest catalog through the onboarding preset", () => {
|
||||
const result = applyCohereConfig({});
|
||||
const provider = result.models?.providers?.cohere;
|
||||
|
||||
expect(provider).toMatchObject({
|
||||
|
||||
@@ -18,10 +18,6 @@ const coherePresetAppliers = createModelCatalogPresetAppliers({
|
||||
}),
|
||||
});
|
||||
|
||||
export function applyCohereProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
|
||||
return coherePresetAppliers.applyProviderConfig(cfg);
|
||||
}
|
||||
|
||||
export function applyCohereConfig(cfg: OpenClawConfig): OpenClawConfig {
|
||||
return coherePresetAppliers.applyConfig(cfg);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
COPILOT_DEFAULT_AGENT_ID,
|
||||
COPILOT_TOKEN_PROFILE_ERROR,
|
||||
normalizeCopilotHomePath,
|
||||
resolveCopilotAuth,
|
||||
sanitizeAgentId,
|
||||
tokenFingerprint,
|
||||
@@ -508,18 +507,3 @@ describe("resolveCopilotAuth - defaults wiring", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeCopilotHomePath", () => {
|
||||
it("resolves to absolute and strips trailing separators", () => {
|
||||
const normalized = normalizeCopilotHomePath("./foo/bar/");
|
||||
expect(normalized).toBe(resolve("./foo/bar"));
|
||||
expect(normalized.endsWith("/")).toBe(false);
|
||||
expect(normalized.endsWith("\\")).toBe(false);
|
||||
});
|
||||
|
||||
it("is idempotent", () => {
|
||||
const once = normalizeCopilotHomePath("/some/path/");
|
||||
const twice = normalizeCopilotHomePath(once);
|
||||
expect(twice).toBe(once);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copilot plugin module implements auth bridge behavior.
|
||||
import { createHash } from "node:crypto";
|
||||
import { homedir as osHomedir } from "node:os";
|
||||
import { join, normalize, resolve, sep } from "node:path";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* Pure functional auth resolver for the copilot agent runtime.
|
||||
@@ -307,16 +307,3 @@ export function tokenFingerprint(token: string): string {
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a copilotHome path for cross-platform pool keying.
|
||||
* Re-exported so attempt.ts / runtime.ts can share the same
|
||||
* normalization without re-implementing.
|
||||
*/
|
||||
export function normalizeCopilotHomePath(value: string): string {
|
||||
return normalize(resolve(value)).replace(new RegExp(`${escapeForRegex(sep)}+$`), "");
|
||||
}
|
||||
|
||||
function escapeForRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
// Copilot tests cover permission bridge plugin behavior.
|
||||
import type {
|
||||
PermissionRequest as SdkPermissionRequest,
|
||||
PermissionRequestResult as SdkPermissionRequestResult,
|
||||
} from "@github/copilot-sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
allowListPolicy,
|
||||
allowOncePolicy,
|
||||
composePolicies,
|
||||
createPermissionBridge,
|
||||
delegatingPolicy,
|
||||
rejectAllPolicy,
|
||||
REJECT_ALL_FEEDBACK,
|
||||
type CopilotPermissionContext,
|
||||
@@ -52,168 +47,9 @@ describe("rejectAllPolicy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("allowOncePolicy", () => {
|
||||
it("returns approve-once for every request kind", async () => {
|
||||
for (const kind of [
|
||||
"shell",
|
||||
"write",
|
||||
"mcp",
|
||||
"read",
|
||||
"url",
|
||||
"custom-tool",
|
||||
"memory",
|
||||
"hook",
|
||||
] as const) {
|
||||
const result = await allowOncePolicy(makeCtx({ request: makeRequest({ kind }) }));
|
||||
expect(result).toEqual({ kind: "approve-once" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("allowListPolicy", () => {
|
||||
it("approves listed kinds and rejects others with default feedback", async () => {
|
||||
const policy = allowListPolicy({ kinds: ["read"] });
|
||||
const approved = await policy(makeCtx({ request: makeRequest({ kind: "read" }) }));
|
||||
expect(approved).toEqual({ kind: "approve-once" });
|
||||
const rejected = await policy(makeCtx({ request: makeRequest({ kind: "shell" }) }));
|
||||
expect(rejected).toEqual({ kind: "reject", feedback: REJECT_ALL_FEEDBACK });
|
||||
});
|
||||
|
||||
it("uses custom rejectFeedback when provided", async () => {
|
||||
const policy = allowListPolicy({
|
||||
kinds: ["read"],
|
||||
rejectFeedback: "only reads allowed",
|
||||
});
|
||||
const result = await policy(makeCtx({ request: makeRequest({ kind: "write" }) }));
|
||||
expect(result).toEqual({ kind: "reject", feedback: "only reads allowed" });
|
||||
});
|
||||
|
||||
it("supports multiple kinds in the allow-list", async () => {
|
||||
const policy = allowListPolicy({ kinds: ["read", "write"] });
|
||||
expect(await policy(makeCtx({ request: makeRequest({ kind: "read" }) }))).toEqual({
|
||||
kind: "approve-once",
|
||||
});
|
||||
expect(await policy(makeCtx({ request: makeRequest({ kind: "write" }) }))).toEqual({
|
||||
kind: "approve-once",
|
||||
});
|
||||
expect((await policy(makeCtx({ request: makeRequest({ kind: "mcp" }) })))?.kind).toBe("reject");
|
||||
});
|
||||
|
||||
it("rejects all when given an empty allow-list", async () => {
|
||||
const policy = allowListPolicy({ kinds: [] });
|
||||
for (const kind of ["shell", "read", "write"] as const) {
|
||||
const result = await policy(makeCtx({ request: makeRequest({ kind }) }));
|
||||
expect(result?.kind).toBe("reject");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("delegatingPolicy", () => {
|
||||
it("forwards the request to the host callback and returns its decision", async () => {
|
||||
const onRequest = vi.fn<CopilotPermissionPolicy>().mockResolvedValue({
|
||||
kind: "approve-for-session",
|
||||
} satisfies SdkPermissionRequestResult);
|
||||
const policy = delegatingPolicy({ onRequest });
|
||||
const ctx = makeCtx({ sessionId: "sess-xyz", request: makeRequest({ kind: "write" }) });
|
||||
const result = await policy(ctx);
|
||||
expect(result).toEqual({ kind: "approve-for-session" });
|
||||
expect(onRequest).toHaveBeenCalledTimes(1);
|
||||
expect(onRequest).toHaveBeenCalledWith(ctx);
|
||||
});
|
||||
|
||||
it("returns the rejectAll default when host callback returns undefined", async () => {
|
||||
const onRequest = vi.fn<CopilotPermissionPolicy>().mockResolvedValue(undefined);
|
||||
const policy = delegatingPolicy({ onRequest });
|
||||
const result = await policy(makeCtx());
|
||||
expect(result).toEqual({ kind: "reject", feedback: REJECT_ALL_FEEDBACK });
|
||||
});
|
||||
|
||||
it("rejects with the error message when host callback throws", async () => {
|
||||
const onRequest = vi
|
||||
.fn<CopilotPermissionPolicy>()
|
||||
.mockRejectedValue(new Error("host policy boom"));
|
||||
const policy = delegatingPolicy({ onRequest });
|
||||
const result = await policy(makeCtx());
|
||||
expect(result?.kind).toBe("reject");
|
||||
expect((result as { feedback?: string }).feedback).toContain("host policy boom");
|
||||
});
|
||||
|
||||
it("falls back to onError policy when host callback throws", async () => {
|
||||
const onError = vi.fn<CopilotPermissionPolicy>().mockResolvedValue({ kind: "approve-once" });
|
||||
const policy = delegatingPolicy({
|
||||
onRequest: () => {
|
||||
throw new Error("host policy boom");
|
||||
},
|
||||
onError,
|
||||
});
|
||||
const result = await policy(makeCtx());
|
||||
expect(result).toEqual({ kind: "approve-once" });
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls through to a hard-coded reject if onError also throws", async () => {
|
||||
const policy = delegatingPolicy({
|
||||
onRequest: () => {
|
||||
throw new Error("host boom");
|
||||
},
|
||||
onError: () => {
|
||||
throw new Error("fallback boom");
|
||||
},
|
||||
});
|
||||
const result = await policy(makeCtx());
|
||||
expect(result?.kind).toBe("reject");
|
||||
expect((result as { feedback?: string }).feedback).toContain("host boom");
|
||||
});
|
||||
|
||||
it("formats non-Error throws via JSON.stringify", async () => {
|
||||
const policy = delegatingPolicy({
|
||||
onRequest: () => {
|
||||
throw { code: 42, msg: "weird" } as unknown as Error;
|
||||
},
|
||||
});
|
||||
const result = await policy(makeCtx());
|
||||
expect((result as { feedback?: string }).feedback).toContain('"code":42');
|
||||
});
|
||||
});
|
||||
|
||||
describe("composePolicies", () => {
|
||||
it("returns the first non-undefined result and skips subsequent policies", async () => {
|
||||
const a: CopilotPermissionPolicy = () => undefined;
|
||||
const b: CopilotPermissionPolicy = () => ({ kind: "approve-once" });
|
||||
const c = vi.fn<CopilotPermissionPolicy>(() => ({
|
||||
kind: "reject",
|
||||
feedback: "should never run",
|
||||
}));
|
||||
const policy = composePolicies(a, b, c);
|
||||
const result = await policy(makeCtx());
|
||||
expect(result).toEqual({ kind: "approve-once" });
|
||||
expect(c).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls through to fail-closed reject when all policies return undefined", async () => {
|
||||
const policy = composePolicies(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
const result = await policy(makeCtx());
|
||||
expect(result).toEqual({ kind: "reject", feedback: REJECT_ALL_FEEDBACK });
|
||||
});
|
||||
|
||||
it("short-circuits to reject if any policy throws (does not consult later policies)", async () => {
|
||||
const later = vi.fn<CopilotPermissionPolicy>(() => ({ kind: "approve-once" }));
|
||||
const policy = composePolicies(() => {
|
||||
throw new Error("nope");
|
||||
}, later);
|
||||
const result = await policy(makeCtx());
|
||||
expect(result?.kind).toBe("reject");
|
||||
expect((result as { feedback?: string }).feedback).toContain("nope");
|
||||
expect(later).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPermissionBridge", () => {
|
||||
it("adapts a policy to the SDK PermissionHandler shape", async () => {
|
||||
const handler = createPermissionBridge(allowOncePolicy);
|
||||
const handler = createPermissionBridge(() => ({ kind: "approve-once" }));
|
||||
const result = await handler(makeRequest(), { sessionId: "sess-1" });
|
||||
expect(result).toEqual({ kind: "approve-once" });
|
||||
});
|
||||
@@ -251,7 +87,7 @@ describe("createPermissionBridge", () => {
|
||||
});
|
||||
|
||||
it("handles all SDK permission kinds without throwing", async () => {
|
||||
const handler = createPermissionBridge(allowOncePolicy);
|
||||
const handler = createPermissionBridge(() => ({ kind: "approve-once" }));
|
||||
for (const kind of [
|
||||
"shell",
|
||||
"write",
|
||||
|
||||
@@ -10,19 +10,13 @@
|
||||
* 1. Defines a small `CopilotPermissionPolicy` contract that the
|
||||
* host can implement to mirror PI's policy decisions for the
|
||||
* copilot agent runtime.
|
||||
* 2. Provides built-in policies for common defaults (fail-closed,
|
||||
* approve-all-for-test, allow-list-by-kind).
|
||||
* 3. Provides a `delegatingPolicy({ onRequest })` so the core layer
|
||||
* can plug in a host-side callback that calls into
|
||||
* `runBeforeToolCallHook` / `effective-tool-policy` and returns
|
||||
* the SDK-shaped decision.
|
||||
* 4. Adapts the resulting policy into the SDK's
|
||||
* 2. Adapts the resulting policy into the SDK's
|
||||
* `PermissionHandler` shape via `createPermissionBridge(policy)`.
|
||||
*
|
||||
* Cross-package boundary note: the heavy `pi-tools.before-tool-call`
|
||||
* surface cannot be imported here (`tsconfig.package-boundary.base.json`).
|
||||
* The host bridges core PI logic into this module by injecting a
|
||||
* `delegatingPolicy` from the core wiring layer that constructs
|
||||
* `CopilotPermissionPolicy` from the core wiring layer that constructs
|
||||
* `AgentHarnessAttemptParams` for the copilot agent runtime.
|
||||
*
|
||||
* If PI's permission semantics change materially, the contract here
|
||||
@@ -67,117 +61,6 @@ export const rejectAllPolicy: CopilotPermissionPolicy = () => ({
|
||||
feedback: REJECT_ALL_FEEDBACK,
|
||||
});
|
||||
|
||||
/**
|
||||
* Approve every request as "approve-once". Use only in tests / live
|
||||
* smoke runs where the operator has accepted the risk. This is the
|
||||
* SDK-bundled `approveAll` behavior re-exported as an explicit named
|
||||
* policy so test sites can opt in without `@github/copilot-sdk`
|
||||
* imports leaking into call sites.
|
||||
*/
|
||||
export const allowOncePolicy: CopilotPermissionPolicy = () => ({
|
||||
kind: "approve-once",
|
||||
});
|
||||
|
||||
export interface AllowListPolicyOptions {
|
||||
/** Permission kinds that should be approved once. */
|
||||
kinds: ReadonlyArray<SdkPermissionRequest["kind"]>;
|
||||
/** Optional feedback text attached to rejections. */
|
||||
rejectFeedback?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve requests whose `kind` is in the allow-list; reject everything
|
||||
* else with `rejectFeedback` (defaulting to `REJECT_ALL_FEEDBACK`).
|
||||
*/
|
||||
export function allowListPolicy(options: AllowListPolicyOptions): CopilotPermissionPolicy {
|
||||
const allowed = new Set<SdkPermissionRequest["kind"]>(options.kinds);
|
||||
const feedback = options.rejectFeedback ?? REJECT_ALL_FEEDBACK;
|
||||
return ({ request }) => {
|
||||
if (allowed.has(request.kind)) {
|
||||
return { kind: "approve-once" };
|
||||
}
|
||||
return { kind: "reject", feedback };
|
||||
};
|
||||
}
|
||||
|
||||
export interface DelegatingPolicyOptions {
|
||||
/**
|
||||
* Host-supplied callback. Returning `undefined` falls through to the
|
||||
* fail-closed default. Throwing falls back to the configured
|
||||
* `onError` policy if provided; otherwise the throw is converted to a
|
||||
* reject with the error message embedded in `feedback` (so the model
|
||||
* sees the diagnostic instead of a generic RPC failure).
|
||||
*/
|
||||
onRequest: CopilotPermissionPolicy;
|
||||
/**
|
||||
* Optional fallback when `onRequest` throws. If omitted, throws are
|
||||
* reflected back as `reject` with the error message in `feedback`.
|
||||
* If supplied and `onError` also throws, fall through to the
|
||||
* error-message reject.
|
||||
*/
|
||||
onError?: CopilotPermissionPolicy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a host callback into a policy, catching synchronous throws and
|
||||
* async rejections so the SDK never sees an exception (which would
|
||||
* surface as a generic RPC failure to the model).
|
||||
*/
|
||||
export function delegatingPolicy(options: DelegatingPolicyOptions): CopilotPermissionPolicy {
|
||||
const { onRequest, onError } = options;
|
||||
return async (ctx) => {
|
||||
try {
|
||||
const result = await onRequest(ctx);
|
||||
if (result !== undefined) {
|
||||
return result;
|
||||
}
|
||||
return { kind: "reject", feedback: REJECT_ALL_FEEDBACK };
|
||||
} catch (error) {
|
||||
if (onError) {
|
||||
try {
|
||||
const fallback = await onError(ctx);
|
||||
if (fallback !== undefined) {
|
||||
return fallback;
|
||||
}
|
||||
} catch {
|
||||
// fall through to error-message reject
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: "reject",
|
||||
feedback: `copilot permission policy threw: ${formatError(error)}`,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose policies in order. The first policy to return a non-undefined
|
||||
* result wins. If all return undefined, a fail-closed `reject` is
|
||||
* produced. Throws inside any policy short-circuit to `reject` with the
|
||||
* error message; downstream policies are not consulted after a throw
|
||||
* (so a misbehaving host policy cannot mask itself by being followed by
|
||||
* an allow-policy).
|
||||
*/
|
||||
export function composePolicies(...policies: CopilotPermissionPolicy[]): CopilotPermissionPolicy {
|
||||
return async (ctx) => {
|
||||
for (const policy of policies) {
|
||||
try {
|
||||
const result = await policy(ctx);
|
||||
if (result !== undefined) {
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
kind: "reject",
|
||||
feedback: `copilot permission policy threw: ${formatError(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { kind: "reject", feedback: REJECT_ALL_FEEDBACK };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapt a `CopilotPermissionPolicy` to the SDK's
|
||||
* `PermissionHandler` shape. The returned handler always resolves
|
||||
|
||||
@@ -4,7 +4,6 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
COPILOT_SDK_FALLBACK_DIR,
|
||||
COPILOT_SDK_SPEC,
|
||||
resetCopilotSdkCacheForTests,
|
||||
loadCopilotSdk,
|
||||
@@ -201,10 +200,6 @@ describe("sdk-loader", () => {
|
||||
expect(primaryImport).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("default fallback dir points at ~/.openclaw/npm-runtime/copilot", () => {
|
||||
expect(COPILOT_SDK_FALLBACK_DIR).toMatch(/\.openclaw[\\/]+npm-runtime[\\/]+copilot$/);
|
||||
});
|
||||
|
||||
it("resolves the fallback dir from OPENCLAW_STATE_DIR for relocated profiles", () => {
|
||||
expect(
|
||||
resolveCopilotSdkFallbackDir({
|
||||
@@ -220,9 +215,6 @@ describe("sdk-loader", () => {
|
||||
});
|
||||
|
||||
describe("sdk dependency constants", () => {
|
||||
it("COPILOT_SDK_FALLBACK_DIR keeps the legacy fallback path stable", () => {
|
||||
expect(COPILOT_SDK_FALLBACK_DIR).toMatch(/\.openclaw[\\/]+npm-runtime[\\/]+copilot$/);
|
||||
});
|
||||
it("COPILOT_SDK_SPEC pins the canonical SDK spec", () => {
|
||||
expect(COPILOT_SDK_SPEC).toBe("@github/copilot-sdk@1.0.0-beta.9");
|
||||
});
|
||||
|
||||
@@ -10,8 +10,6 @@ export function resolveCopilotSdkFallbackDir(env: NodeJS.ProcessEnv = process.en
|
||||
return path.join(resolveStateDir(env), "npm-runtime", "copilot");
|
||||
}
|
||||
|
||||
export const COPILOT_SDK_FALLBACK_DIR = resolveCopilotSdkFallbackDir();
|
||||
|
||||
export const COPILOT_SDK_SPEC = "@github/copilot-sdk@1.0.0-beta.9";
|
||||
|
||||
let cached: Promise<typeof Sdk> | undefined;
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import { captureEnv } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
applyDeepInfraProviderConfig,
|
||||
applyDeepInfraConfig,
|
||||
DEEPINFRA_BASE_URL,
|
||||
DEEPINFRA_DEFAULT_MODEL_REF,
|
||||
@@ -36,53 +35,6 @@ describe("DeepInfra provider config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyDeepInfraProviderConfig", () => {
|
||||
it("does not set provider models (discovery populates them at runtime)", () => {
|
||||
const result = applyDeepInfraProviderConfig(emptyCfg, DEEPINFRA_DEFAULT_MODEL_REF);
|
||||
expect(result.models?.providers?.deepinfra).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets DeepInfra alias on the provided model ref", () => {
|
||||
const result = applyDeepInfraProviderConfig(emptyCfg, DEEPINFRA_DEFAULT_MODEL_REF);
|
||||
const agentModel = result.agents?.defaults?.models?.[DEEPINFRA_DEFAULT_MODEL_REF];
|
||||
expect(agentModel).toEqual({ alias: "DeepInfra" });
|
||||
});
|
||||
|
||||
it("attaches the alias to a non-default model ref when provided", () => {
|
||||
const fallbackRef = "deepinfra/other/awesome-model";
|
||||
const result = applyDeepInfraProviderConfig(emptyCfg, fallbackRef);
|
||||
expect(result.agents?.defaults?.models?.[fallbackRef]?.alias).toBe("DeepInfra");
|
||||
expect(result.agents?.defaults?.models?.[DEEPINFRA_DEFAULT_MODEL_REF]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves existing alias if already set", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
[DEEPINFRA_DEFAULT_MODEL_REF]: { alias: "My Custom Alias" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = applyDeepInfraProviderConfig(cfg, DEEPINFRA_DEFAULT_MODEL_REF);
|
||||
const agentModel = result.agents?.defaults?.models?.[DEEPINFRA_DEFAULT_MODEL_REF];
|
||||
expect(agentModel?.alias).toBe("My Custom Alias");
|
||||
});
|
||||
|
||||
it("does not change the default model selection", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5" },
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = applyDeepInfraProviderConfig(cfg, DEEPINFRA_DEFAULT_MODEL_REF);
|
||||
expect(resolveAgentModelPrimaryValue(result.agents?.defaults?.model)).toBe("openai/gpt-5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyDeepInfraConfig", () => {
|
||||
it("sets the provided model ref as the primary default", () => {
|
||||
const result = applyDeepInfraConfig(emptyCfg, DEEPINFRA_DEFAULT_MODEL_REF);
|
||||
@@ -103,6 +55,22 @@ describe("DeepInfra provider config", () => {
|
||||
expect(resolveAgentModelPrimaryValue(result.agents?.defaults?.model)).toBe(fallbackRef);
|
||||
expect(result.agents?.defaults?.models?.[fallbackRef]?.alias).toBe("DeepInfra");
|
||||
});
|
||||
|
||||
it("preserves an existing alias on the selected model", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
[DEEPINFRA_DEFAULT_MODEL_REF]: { alias: "My Custom Alias" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = applyDeepInfraConfig(cfg, DEEPINFRA_DEFAULT_MODEL_REF);
|
||||
expect(result.agents?.defaults?.models?.[DEEPINFRA_DEFAULT_MODEL_REF]?.alias).toBe(
|
||||
"My Custom Alias",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("env var resolution", () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DEEPINFRA_BASE_URL, DEEPINFRA_DEFAULT_MODEL_REF } from "./provider-mode
|
||||
|
||||
export { DEEPINFRA_BASE_URL, DEEPINFRA_DEFAULT_MODEL_REF };
|
||||
|
||||
export function applyDeepInfraProviderConfig(
|
||||
export function applyDeepInfraConfig(
|
||||
cfg: OpenClawConfig,
|
||||
modelRef: string = DEEPINFRA_DEFAULT_MODEL_REF,
|
||||
): OpenClawConfig {
|
||||
@@ -17,7 +17,7 @@ export function applyDeepInfraProviderConfig(
|
||||
alias: models[modelRef]?.alias ?? "DeepInfra",
|
||||
};
|
||||
|
||||
return {
|
||||
return applyAgentDefaultModelPrimary({
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
@@ -26,12 +26,5 @@ export function applyDeepInfraProviderConfig(
|
||||
models,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyDeepInfraConfig(
|
||||
cfg: OpenClawConfig,
|
||||
modelRef: string = DEEPINFRA_DEFAULT_MODEL_REF,
|
||||
): OpenClawConfig {
|
||||
return applyAgentDefaultModelPrimary(applyDeepInfraProviderConfig(cfg, modelRef), modelRef);
|
||||
}, modelRef);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Official OpenTelemetry diagnostics exporter for OpenClaw.
|
||||
|
||||
This plugin exports OpenClaw Gateway traces, metrics, and logs to an OTLP collector for observability stacks such as Grafana, Datadog, Honeycomb, New Relic, Tempo, and compatible collectors.
|
||||
This plugin exports OpenClaw Gateway traces, metrics, and logs to an OTLP collector for observability stacks such as Grafana, Datadog, Honeycomb, New Relic, Tempo, and compatible collectors. It can also write diagnostic log records as stdout JSONL for container log pipelines.
|
||||
|
||||
## Install
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user