mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-18 03:52:42 +08:00
Compare commits
1 Commits
script-to-
...
fix/pr-mac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94672cf1f5 |
5
.github/CODEOWNERS
vendored
5
.github/CODEOWNERS
vendored
@@ -12,14 +12,9 @@
|
||||
/.github/workflows/codeql-android-critical-security.yml @openclaw/openclaw-secops
|
||||
/.github/workflows/codeql-critical-quality.yml @openclaw/openclaw-secops
|
||||
/.github/workflows/dependency-guard.yml @openclaw/openclaw-secops
|
||||
/.github/workflows/security-sensitive-guard.yml @openclaw/openclaw-secops
|
||||
/test/scripts/dependency-guard-workflow.test.ts @openclaw/openclaw-secops
|
||||
/test/scripts/dependency-guard-script.test.ts @openclaw/openclaw-secops
|
||||
/test/scripts/security-sensitive-guard-workflow.test.ts @openclaw/openclaw-secops
|
||||
/test/scripts/security-sensitive-guard-script.test.ts @openclaw/openclaw-secops
|
||||
/scripts/github/dependency-guard.mjs @openclaw/openclaw-secops
|
||||
/scripts/github/security-sensitive-guard.mjs @openclaw/openclaw-secops
|
||||
/.gitignore @openclaw/openclaw-secops
|
||||
/package-lock.json @openclaw/openclaw-secops
|
||||
/npm-shrinkwrap.json @openclaw/openclaw-secops
|
||||
/extensions/*/package-lock.json @openclaw/openclaw-secops
|
||||
|
||||
114
.github/workflows/security-sensitive-guard.yml
vendored
114
.github/workflows/security-sensitive-guard.yml
vendored
@@ -1,114 +0,0 @@
|
||||
name: Security Sensitive Guard
|
||||
|
||||
on:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers] checks trusted base script only; never checks out PR head
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
env:
|
||||
# Temporary rollout bridge for PRs opened before this workflow's script landed.
|
||||
# Remove once the pre-rollout PR set has drained.
|
||||
OPENCLAW_SECURITY_SENSITIVE_GUARD_ROLLOUT_SHA: 5d9c010628ea4de3492a12e32f9be5b8c5dfa9ed
|
||||
|
||||
concurrency:
|
||||
group: security-sensitive-guard-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
security-sensitive-guard-detect:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check security-sensitive guard rollout eligibility
|
||||
id: rollout
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
status="$(
|
||||
gh api \
|
||||
"repos/${GITHUB_REPOSITORY}/compare/${OPENCLAW_SECURITY_SENSITIVE_GUARD_ROLLOUT_SHA}...${PR_BASE_SHA}" \
|
||||
--jq '.status'
|
||||
)"
|
||||
case "$status" in
|
||||
ahead|identical)
|
||||
echo "ready=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
behind|diverged)
|
||||
echo "ready=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Skipping security-sensitive guard for a PR base that predates rollout commit ${OPENCLAW_SECURITY_SENSITIVE_GUARD_ROLLOUT_SHA}."
|
||||
;;
|
||||
*)
|
||||
echo "Unexpected compare status for security-sensitive guard rollout: $status" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check out trusted base workflow scripts
|
||||
if: steps.rollout.outputs.ready == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.workflow_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Detect security-sensitive changes
|
||||
if: steps.rollout.outputs.ready == 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
OPENCLAW_SECURITY_APPROVERS: vincentkoc,steipete,joshavant
|
||||
OPENCLAW_SECURITY_SENSITIVE_GUARD_MODE: detect
|
||||
OPENCLAW_SECURITY_TEAM_SLUG: openclaw-secops
|
||||
run: node scripts/github/security-sensitive-guard.mjs
|
||||
|
||||
security-sensitive-guard:
|
||||
if: ${{ !github.event.pull_request.draft && always() }}
|
||||
needs:
|
||||
- security-sensitive-guard-detect
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check security-sensitive guard rollout eligibility
|
||||
id: rollout
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
status="$(
|
||||
gh api \
|
||||
"repos/${GITHUB_REPOSITORY}/compare/${OPENCLAW_SECURITY_SENSITIVE_GUARD_ROLLOUT_SHA}...${PR_BASE_SHA}" \
|
||||
--jq '.status'
|
||||
)"
|
||||
case "$status" in
|
||||
ahead|identical)
|
||||
echo "ready=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
behind|diverged)
|
||||
echo "ready=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Skipping security-sensitive guard for a PR base that predates rollout commit ${OPENCLAW_SECURITY_SENSITIVE_GUARD_ROLLOUT_SHA}."
|
||||
;;
|
||||
*)
|
||||
echo "Unexpected compare status for security-sensitive guard rollout: $status" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check out trusted base workflow scripts
|
||||
if: steps.rollout.outputs.ready == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.workflow_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Enforce security-sensitive guard
|
||||
if: steps.rollout.outputs.ready == 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
OPENCLAW_SECURITY_APPROVERS: vincentkoc,steipete,joshavant
|
||||
OPENCLAW_SECURITY_SENSITIVE_GUARD_MODE: enforce
|
||||
OPENCLAW_SECURITY_TEAM_SLUG: openclaw-secops
|
||||
run: node scripts/github/security-sensitive-guard.mjs
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -77,7 +77,6 @@ extensions/canvas/src/host/a2ui/*.map
|
||||
|
||||
# fastlane (iOS)
|
||||
apps/ios/fastlane/README.md
|
||||
apps/android/fastlane/README.md
|
||||
apps/ios/fastlane/report.xml
|
||||
apps/ios/fastlane/Preview.html
|
||||
apps/ios/fastlane/screenshots/
|
||||
|
||||
@@ -110,7 +110,7 @@ For coordinated change sets that genuinely need more than 20 PRs, join the **#cl
|
||||
- Keep PRs takeover-ready: open them from a branch maintainers can push to. For fork PRs, leave GitHub's **Allow edits by maintainers** option enabled so maintainers can finish urgent fixes, changelog entries, or merge prep when needed. If GitHub shows **Allow edits and access to secrets by maintainers**, enable it only when that workflow/secrets access is acceptable and say so in the PR.
|
||||
- Do not edit `CHANGELOG.md` in contributor PRs. Maintainers or ClawSweeper add the changelog entry when landing user-facing changes.
|
||||
- Run tests: `pnpm build && pnpm check && pnpm test`
|
||||
- For iterative local commits, `scripts/committer --fast "message" <files...>` skips commit hooks. Only use it when you've already run equivalent targeted validation for the touched surface.
|
||||
- For iterative local commits, `scripts/committer --fast "message" <files...>` passes `FAST_COMMIT=1` through to the pre-commit hook so it skips the repo-wide `pnpm check`. Only use it when you've already run equivalent targeted validation for the touched surface.
|
||||
- For extension/plugin changes, run the fast local lane first:
|
||||
- `pnpm test:extension <extension-name>`
|
||||
- `pnpm test:extension --list` to see valid extension ids
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# OpenClaw Android Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
Maintenance update for the current OpenClaw Android release.
|
||||
|
||||
## 2026.6.2 - 2026-06-02
|
||||
|
||||
OpenClaw is now available on Android.
|
||||
|
||||
Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, and bring Android device capabilities like camera, location, screen, and notifications into your private automation workflows.
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"signingRepo": "git@github.com:openclaw/apps-signing.git",
|
||||
"signingBranch": "main",
|
||||
"assetPath": "android/openclaw",
|
||||
"uploadKeystoreEncryptedFile": "upload-keystore.jks.enc",
|
||||
"gradlePropertiesEncryptedFile": "gradle.properties.enc",
|
||||
"materializedRoot": "apps/android/build/release-signing",
|
||||
"gradlePropertyNames": [
|
||||
"OPENCLAW_ANDROID_STORE_FILE",
|
||||
"OPENCLAW_ANDROID_STORE_PASSWORD",
|
||||
"OPENCLAW_ANDROID_KEY_ALIAS",
|
||||
"OPENCLAW_ANDROID_KEY_PASSWORD"
|
||||
]
|
||||
}
|
||||
@@ -53,16 +53,6 @@ pnpm android:version:pin -- --from-gateway
|
||||
pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060501
|
||||
```
|
||||
|
||||
Release-owner signing sync:
|
||||
|
||||
```bash
|
||||
pnpm android:release:signing:plan
|
||||
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:sync:pull
|
||||
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:check
|
||||
```
|
||||
|
||||
The signing sync pulls encrypted Android upload-key assets from the shared `apps-signing` repo and materializes decrypted files under `apps/android/build/release-signing/`.
|
||||
|
||||
Generate raw Google Play screenshots:
|
||||
|
||||
```bash
|
||||
@@ -74,7 +64,7 @@ pnpm android:screenshots
|
||||
- Play build: `openclaw-<version>-play-release.aab`
|
||||
- Third-party build: `openclaw-<version>-third-party-release.apk`
|
||||
|
||||
`pnpm android:bundle:release` is an alias for the same Fastlane archive lane.
|
||||
`pnpm android:bundle:release` is an alias for the same archive helper.
|
||||
|
||||
See `apps/android/VERSIONING.md` and `apps/android/fastlane/SETUP.md` for the release workflow.
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@ Android release builds use pinned app metadata instead of auto-bumping `build.gr
|
||||
- `version` is the Play `versionName` and uses CalVer: `YYYY.M.D`.
|
||||
- `versionCode` uses `YYYYMMDDNN`, where `NN` is a two-digit build number for that pinned app version.
|
||||
- `apps/android/Config/Version.properties` is generated from `version.json` and read by Gradle.
|
||||
- `apps/android/CHANGELOG.md` is the Android-only changelog and release-note source.
|
||||
- `apps/android/fastlane/metadata/android/en-US/release_notes.txt` is generated from the changelog.
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -25,41 +23,16 @@ pnpm android:version:check
|
||||
pnpm android:version:sync
|
||||
pnpm android:version:pin -- --from-gateway
|
||||
pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060501
|
||||
pnpm android:release:signing:plan
|
||||
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:sync:pull
|
||||
pnpm android:release:preflight
|
||||
```
|
||||
|
||||
## Release-note resolution order
|
||||
|
||||
When generating `apps/android/fastlane/metadata/android/en-US/release_notes.txt`, the tooling reads the first available changelog section in this order:
|
||||
|
||||
1. exact pinned version, for example `## 2026.6.2`
|
||||
2. `## Unreleased`
|
||||
|
||||
Recommended workflow:
|
||||
|
||||
- while iterating on a Play internal testing train, keep pending notes under `## Unreleased`
|
||||
- before the production release, move or copy the final notes under `## <pinned version>` and run sync again
|
||||
|
||||
## Release Workflow
|
||||
|
||||
1. Pin Android to the intended release version.
|
||||
2. Run `pnpm android:version:sync`.
|
||||
3. Update `apps/android/CHANGELOG.md`, then run `pnpm android:version:sync` again if needed.
|
||||
4. Run `MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:sync:pull` to materialize encrypted Android signing assets from `apps-signing`.
|
||||
5. Run `pnpm android:release:preflight` to validate Play auth, signing, synced versioning, and release notes.
|
||||
6. Run `pnpm android:screenshots` to refresh raw Google Play screenshots.
|
||||
7. Run `pnpm android:release:archive` to produce the signed Play AAB and third-party APK.
|
||||
8. Run `pnpm android:release:upload` to upload metadata, screenshots, and the Play AAB to Google Play internal testing.
|
||||
9. Promote to production manually in Google Play Console.
|
||||
3. Update `apps/android/fastlane/metadata/android/en-US/release_notes.txt`.
|
||||
4. Run `pnpm android:screenshots` to refresh raw Google Play screenshots.
|
||||
5. Run `pnpm android:release:archive` to produce the signed Play AAB and third-party APK.
|
||||
6. Run `pnpm android:release:upload` to upload metadata, screenshots, and the Play AAB to Google Play internal testing.
|
||||
7. Promote to production manually in Google Play Console.
|
||||
|
||||
The third-party flavor is archived as a signed APK for non-Play distribution. It is not uploaded by the Play release lane.
|
||||
|
||||
## Signing model
|
||||
|
||||
`apps/android/Config/ReleaseSigning.json` pins the Android signing assets in the shared private `apps-signing` repo. The Android pipeline uses the same `MATCH_PASSWORD` release-owner secret as iOS, but the Android files are managed by `scripts/android-release-signing.mjs` instead of Fastlane `match`.
|
||||
|
||||
`sync:pull` decrypts the Play upload keystore and Gradle signing properties into `apps/android/build/release-signing/`. That directory is gitignored, and Fastlane exports the materialized values as Gradle project properties for the current release command.
|
||||
|
||||
If `MATCH_PASSWORD` is not set, the existing manual Gradle-property signing path still works: provide `OPENCLAW_ANDROID_STORE_FILE`, `OPENCLAW_ANDROID_STORE_PASSWORD`, `OPENCLAW_ANDROID_KEY_ALIAS`, and `OPENCLAW_ANDROID_KEY_PASSWORD` through your local Gradle user properties before running release tasks.
|
||||
|
||||
@@ -9,12 +9,6 @@ default_platform(:android)
|
||||
DEFAULT_PLAY_PACKAGE_NAME = "ai.openclaw.app"
|
||||
DEFAULT_PLAY_TRACK = "internal"
|
||||
DEFAULT_PLAY_RELEASE_STATUS = "completed"
|
||||
ANDROID_RELEASE_SIGNING_GRADLE_PROPERTIES = [
|
||||
"OPENCLAW_ANDROID_STORE_FILE",
|
||||
"OPENCLAW_ANDROID_STORE_PASSWORD",
|
||||
"OPENCLAW_ANDROID_KEY_ALIAS",
|
||||
"OPENCLAW_ANDROID_KEY_PASSWORD"
|
||||
].freeze
|
||||
|
||||
def load_env_file(path)
|
||||
return unless File.exist?(path)
|
||||
@@ -42,14 +36,6 @@ def repo_root
|
||||
File.expand_path("../..", android_root)
|
||||
end
|
||||
|
||||
def android_release_signing_script
|
||||
File.join(repo_root, "scripts", "android-release-signing.mjs")
|
||||
end
|
||||
|
||||
def android_release_signing_materialized_properties_path
|
||||
File.join(android_root, "build", "release-signing", "gradle.properties")
|
||||
end
|
||||
|
||||
def shell_join(args)
|
||||
args.shelljoin
|
||||
end
|
||||
@@ -150,22 +136,17 @@ def android_release_notes_path
|
||||
File.join(__dir__, "metadata", "android", "en-US", "release_notes.txt")
|
||||
end
|
||||
|
||||
def validate_android_release_notes!
|
||||
release_notes_path = android_release_notes_path
|
||||
UI.user_error!("Missing Android release notes at #{release_notes_path}. Run `pnpm android:version:sync`.") unless File.exist?(release_notes_path)
|
||||
UI.user_error!("Android release notes at #{release_notes_path} are empty.") unless env_present?(File.read(release_notes_path))
|
||||
end
|
||||
|
||||
def android_changelog_path(version_code)
|
||||
File.join(__dir__, "metadata", "android", "en-US", "changelogs", "#{version_code}.txt")
|
||||
end
|
||||
|
||||
def sync_android_changelog!(version_code)
|
||||
validate_android_release_notes!
|
||||
release_notes_path = android_release_notes_path
|
||||
UI.user_error!("Missing Android release notes at #{release_notes_path}.") unless File.exist?(release_notes_path)
|
||||
|
||||
changelog_path = android_changelog_path(version_code)
|
||||
FileUtils.mkdir_p(File.dirname(changelog_path))
|
||||
File.write(changelog_path, File.read(android_release_notes_path))
|
||||
File.write(changelog_path, File.read(release_notes_path))
|
||||
changelog_path
|
||||
end
|
||||
|
||||
@@ -197,69 +178,6 @@ def capture_android_screenshots!
|
||||
sh(shell_join(["bash", File.join(repo_root, "scripts", "android-screenshots.sh")]))
|
||||
end
|
||||
|
||||
def read_android_release_signing_properties!(path)
|
||||
UI.user_error!("Missing materialized Android release signing properties at #{path}.") unless File.exist?(path)
|
||||
|
||||
properties = {}
|
||||
File.foreach(path) do |line|
|
||||
stripped = line.strip
|
||||
next if stripped.empty? || stripped.start_with?("#")
|
||||
|
||||
key, value = stripped.split("=", 2)
|
||||
next if key.nil? || key.empty? || value.nil?
|
||||
|
||||
properties[key] = value.strip
|
||||
end
|
||||
|
||||
missing = ANDROID_RELEASE_SIGNING_GRADLE_PROPERTIES.reject { |key| env_present?(properties[key]) }
|
||||
UI.user_error!("Materialized Android release signing properties are missing: #{missing.join(', ')}.") unless missing.empty?
|
||||
|
||||
properties
|
||||
end
|
||||
|
||||
def export_android_release_signing_properties!(path)
|
||||
read_android_release_signing_properties!(path).each do |key, value|
|
||||
ENV["ORG_GRADLE_PROJECT_#{key}"] = value
|
||||
end
|
||||
end
|
||||
|
||||
def sync_android_release_signing!
|
||||
sh(shell_join(["node", android_release_signing_script, "--mode", "sync-pull"]))
|
||||
export_android_release_signing_properties!(android_release_signing_materialized_properties_path)
|
||||
end
|
||||
|
||||
def prepare_android_release_signing!
|
||||
if env_present?(ENV["MATCH_PASSWORD"])
|
||||
sync_android_release_signing!
|
||||
elsif File.exist?(android_release_signing_materialized_properties_path)
|
||||
export_android_release_signing_properties!(android_release_signing_materialized_properties_path)
|
||||
end
|
||||
end
|
||||
|
||||
def validate_android_release_signing!
|
||||
Dir.chdir(android_root) do
|
||||
sh(shell_join(["./gradlew", ":app:bundlePlayRelease", "--dry-run"]))
|
||||
end
|
||||
end
|
||||
|
||||
def print_android_release_plan!(version_metadata)
|
||||
UI.message("Android Play release plan:")
|
||||
UI.message(" package: #{play_package_name}")
|
||||
UI.message(" track: #{play_track}")
|
||||
UI.message(" release_status: #{play_release_status}")
|
||||
UI.message(" validate_only: #{play_validate_only?}")
|
||||
UI.message(" versionName: #{version_metadata.fetch(:version)}")
|
||||
UI.message(" versionCode: #{version_metadata.fetch(:version_code)}")
|
||||
end
|
||||
|
||||
def validate_android_release_preflight!(version_metadata)
|
||||
validate_play_auth!
|
||||
prepare_android_release_signing!
|
||||
validate_android_release_signing!
|
||||
validate_android_release_notes!
|
||||
print_android_release_plan!(version_metadata)
|
||||
end
|
||||
|
||||
def upload_play_store_metadata!(version_metadata)
|
||||
validate_android_screenshots!
|
||||
sync_android_changelog!(version_metadata.fetch(:version_code))
|
||||
@@ -312,38 +230,6 @@ platform :android do
|
||||
UI.success("Google Play API credentials are valid.")
|
||||
end
|
||||
|
||||
desc "Print the Android release signing plan"
|
||||
lane :signing_plan do
|
||||
sh(shell_join(["node", android_release_signing_script, "--mode", "plan"]))
|
||||
end
|
||||
|
||||
desc "Pull encrypted Android release signing assets and validate Gradle release signing"
|
||||
lane :signing_check do
|
||||
sync_android_release_signing!
|
||||
validate_android_release_signing!
|
||||
UI.success("Android release signing assets are available locally.")
|
||||
end
|
||||
|
||||
desc "Pull encrypted Android release signing assets from the shared signing repo"
|
||||
lane :signing_sync_pull do
|
||||
sync_android_release_signing!
|
||||
UI.success("Pulled Android release signing assets.")
|
||||
end
|
||||
|
||||
desc "Create or refresh encrypted Android release signing assets in the shared signing repo"
|
||||
lane :signing_sync_push do
|
||||
sh(shell_join(["node", android_release_signing_script, "--mode", "sync-push"]))
|
||||
UI.success("Pushed Android release signing assets.")
|
||||
end
|
||||
|
||||
desc "Validate Android Play release auth, signing, versioning, and release notes"
|
||||
lane :release_preflight do
|
||||
sync_android_versioning!
|
||||
version_metadata = read_android_version_metadata
|
||||
validate_android_release_preflight!(version_metadata)
|
||||
UI.success("Android Play release preflight passed for #{version_metadata[:version]} (#{version_metadata[:version_code]}).")
|
||||
end
|
||||
|
||||
desc "Upload Google Play metadata, changelog, and optional screenshots"
|
||||
lane :metadata do
|
||||
sync_android_versioning!
|
||||
@@ -356,7 +242,6 @@ platform :android do
|
||||
desc "Build signed Android release artifacts locally without uploading"
|
||||
lane :play_store_archive do
|
||||
sync_android_versioning!
|
||||
prepare_android_release_signing!
|
||||
build_release_artifacts!
|
||||
end
|
||||
|
||||
@@ -375,9 +260,9 @@ platform :android do
|
||||
|
||||
desc "Upload Android metadata, archive release artifacts, then upload the Play AAB"
|
||||
lane :release_upload do
|
||||
auth_check
|
||||
sync_android_versioning!
|
||||
version_metadata = read_android_version_metadata
|
||||
validate_android_release_preflight!(version_metadata)
|
||||
screenshots
|
||||
ENV["SUPPLY_UPLOAD_METADATA"] = "1"
|
||||
ENV["SUPPLY_UPLOAD_SCREENSHOTS"] = "1"
|
||||
|
||||
@@ -20,35 +20,6 @@ Optional app targeting:
|
||||
GOOGLE_PLAY_PACKAGE_NAME=ai.openclaw.app
|
||||
```
|
||||
|
||||
Android release signing uses the same private `apps-signing` repository and `MATCH_PASSWORD` secret as iOS, but with Android-specific encrypted assets. Pull the shared upload key before release validation:
|
||||
|
||||
```bash
|
||||
pnpm android:release:signing:plan
|
||||
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:sync:pull
|
||||
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:check
|
||||
```
|
||||
|
||||
The pull command materializes decrypted signing files under `apps/android/build/release-signing/`, which is gitignored. Later Fastlane release commands reload those materialized values and export them to Gradle for the current process.
|
||||
|
||||
For the first setup or rotation, provide the Play upload keystore and a local signing properties file, then push encrypted assets to `apps-signing`:
|
||||
|
||||
```bash
|
||||
MATCH_PASSWORD=<signing repo password> \
|
||||
OPENCLAW_ANDROID_UPLOAD_KEYSTORE=<path-to-upload-keystore.jks> \
|
||||
OPENCLAW_ANDROID_SIGNING_PROPERTIES=<path-to-android-signing.properties> \
|
||||
pnpm android:release:signing:sync:push
|
||||
```
|
||||
|
||||
The source signing properties file must contain:
|
||||
|
||||
```properties
|
||||
OPENCLAW_ANDROID_STORE_PASSWORD=<store-password>
|
||||
OPENCLAW_ANDROID_KEY_ALIAS=<upload-key-alias>
|
||||
OPENCLAW_ANDROID_KEY_PASSWORD=<key-password>
|
||||
```
|
||||
|
||||
Store the Google Play upload key, not the irreplaceable app signing key, when Play App Signing is enabled.
|
||||
|
||||
Validate auth:
|
||||
|
||||
```bash
|
||||
@@ -85,19 +56,12 @@ Release rules:
|
||||
|
||||
- `apps/android/version.json` is the pinned Android release version source.
|
||||
- `apps/android/Config/Version.properties` is generated from that source and read by Gradle.
|
||||
- `apps/android/CHANGELOG.md` is the Android-only changelog and release-note source.
|
||||
- `apps/android/fastlane/metadata/android/en-US/release_notes.txt` is generated from that changelog by `pnpm android:version:sync`.
|
||||
- `apps/android/Config/ReleaseSigning.json` pins the encrypted Android signing assets in the shared signing repo.
|
||||
- `MATCH_PASSWORD` enables Fastlane to pull encrypted Android signing assets into `apps/android/build/release-signing/` before release validation or archive builds.
|
||||
- Supported pinned Android versions use CalVer: `YYYY.M.D`.
|
||||
- `versionCode` uses `YYYYMMDDNN`, where `NN` is a two-digit build number for the pinned version.
|
||||
- `pnpm android:version:pin -- --from-gateway` promotes the current root gateway version into the pinned Android release version.
|
||||
- `pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060502` increments another build on the same Android release train.
|
||||
- `pnpm android:version:sync` updates generated version artifacts.
|
||||
- `pnpm android:version:check` validates checked-in Android version artifacts.
|
||||
- `pnpm android:release:preflight` validates Google Play auth, Android release signing, synced versioning, release notes, and prints the package/track/version/versionCode that will be uploaded.
|
||||
- `pnpm android:release:signing:sync:pull` pulls encrypted Android signing assets from `apps-signing`.
|
||||
- `pnpm android:release:signing:sync:push` creates or refreshes encrypted Android signing assets in `apps-signing`.
|
||||
- `pnpm android:screenshots` builds and installs the Play debug app, launches deterministic screenshot scenes, and captures raw PNGs.
|
||||
- `pnpm android:release:archive` builds the signed Play AAB and third-party APK into `apps/android/build/release-artifacts/`.
|
||||
- `pnpm android:release:upload` uploads the Play AAB to the configured Google Play track. The default track is `internal`.
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
c84eab270f19d11a807ce71e783d35ee95a7620295dbffcca7fff31dacfcc882 plugin-sdk-api-baseline.json
|
||||
55656396a5f1941af61603402c43e23e0ffc90003e7efa7c1857c4541a0f1bb4 plugin-sdk-api-baseline.jsonl
|
||||
e2a646aa93124c089fcfed3c3ef982c88d1fdd2170fcdec274446f3d02f20d2b plugin-sdk-api-baseline.json
|
||||
f1762c7b4bbaea4a3ce47ab943daaa6ca3dbc58322cc5d39688da66b3d483a2d plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -1175,24 +1175,8 @@
|
||||
"source": "Control UI",
|
||||
"target": "Control UI"
|
||||
},
|
||||
{
|
||||
"source": "Models CLI",
|
||||
"target": "模型 CLI"
|
||||
},
|
||||
{
|
||||
"source": "Z.AI (GLM)",
|
||||
"target": "Z.AI (GLM)"
|
||||
},
|
||||
{
|
||||
"source": "Cohere",
|
||||
"target": "Cohere"
|
||||
},
|
||||
{
|
||||
"source": "Cohere plugin",
|
||||
"target": "Cohere 插件"
|
||||
},
|
||||
{
|
||||
"source": "cohere",
|
||||
"target": "cohere"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -231,7 +231,7 @@ Retention and pruning are controlled in config:
|
||||
## Migrating older jobs
|
||||
|
||||
<Note>
|
||||
If you have cron jobs from before the current delivery and store format, run `openclaw doctor --fix`. Doctor normalizes legacy cron fields (`jobId`, `schedule.cron`, top-level delivery fields including legacy `threadId`, payload `provider` delivery aliases) and migrates `notify: true` webhook fallback jobs from `cron.webhook` to explicit webhook delivery. Jobs that already announce to a chat keep that delivery and get a completion webhook destination. When `cron.webhook` is unset, the inert top-level `notify` marker is removed for jobs with no migration target (the existing delivery is preserved unchanged), so `doctor --fix` no longer keeps re-warning about them.
|
||||
If you have cron jobs from before the current delivery and store format, run `openclaw doctor --fix`. Doctor normalizes legacy cron fields (`jobId`, `schedule.cron`, top-level delivery fields including legacy `threadId`, payload `provider` delivery aliases) and migrates `notify: true` webhook fallback jobs from `cron.webhook` to explicit webhook delivery. Jobs that already announce to a chat keep that delivery and get a completion webhook destination.
|
||||
</Note>
|
||||
|
||||
## Common edits
|
||||
|
||||
@@ -11,17 +11,13 @@ sidebarTitle: "MCP"
|
||||
`openclaw mcp` has two jobs:
|
||||
|
||||
- run OpenClaw as an MCP server with `openclaw mcp serve`
|
||||
- manage OpenClaw-managed outbound MCP server definitions with `list`, `show`, `status`, `doctor`, `probe`, `add`, `set`, `configure`, `tools`, `login`, `logout`, `reload`, and `unset`
|
||||
- manage OpenClaw-owned outbound MCP server definitions with `list`, `show`, `status`, `doctor`, `probe`, `add`, `set`, `configure`, `tools`, `login`, `logout`, `reload`, and `unset`
|
||||
|
||||
In other words:
|
||||
|
||||
- `serve` is OpenClaw acting as an MCP server
|
||||
- the other subcommands are OpenClaw acting as an MCP client-side registry for MCP servers its runtimes may consume later
|
||||
|
||||
<Note>
|
||||
`list`, `show`, `set`, and `unset` only read and write OpenClaw-managed `mcp.servers` entries in OpenClaw config. They do not include mcporter servers from `config/mcporter.json`; use `mcporter list` for that registry.
|
||||
</Note>
|
||||
|
||||
Use [`openclaw acp`](/cli/acp) when OpenClaw should host a coding harness session itself and route that runtime through ACP.
|
||||
|
||||
## Choose the right MCP path
|
||||
@@ -372,7 +368,7 @@ For broader testing context, see [Testing](/help/testing).
|
||||
This is the `openclaw mcp list`, `show`, `status`, `doctor`, `probe`, `add`, `set`,
|
||||
`configure`, `tools`, `login`, `logout`, `reload`, and `unset` path.
|
||||
|
||||
These commands do not expose OpenClaw over MCP. They manage OpenClaw-managed MCP server definitions under `mcp.servers` in OpenClaw config. They do not read mcporter servers from `config/mcporter.json`.
|
||||
These commands do not expose OpenClaw over MCP. They manage OpenClaw-owned MCP server definitions under `mcp.servers` in OpenClaw config.
|
||||
|
||||
Those saved definitions are for runtimes that OpenClaw launches or configures later, such as embedded OpenClaw and other runtime adapters. OpenClaw stores the definitions centrally so those runtimes do not need to keep their own duplicate MCP server lists.
|
||||
|
||||
|
||||
@@ -107,10 +107,6 @@ Notes:
|
||||
in the shared managed skills directory when combined with `--global`.
|
||||
- `verify <slug>` prints ClawHub's `clawhub.skill.verify.v1` JSON envelope by
|
||||
default. There is no `--json` flag because JSON is already the default.
|
||||
- When ClawHub returns server-resolved source provenance, verify JSON also
|
||||
includes a commit-pinned `openclaw.verifiedSourceUrl`. Unavailable or
|
||||
self-declared source URLs stay only in the raw provenance envelope and are not
|
||||
promoted.
|
||||
- `verify` uses `.clawhub/origin.json` for installed ClawHub skills, so it
|
||||
verifies the installed version against the registry it came from. `--version`
|
||||
and `--tag` override the version selector but keep that installed registry
|
||||
|
||||
@@ -296,7 +296,6 @@ See [/providers/kilocode](/providers/kilocode) for setup details.
|
||||
| --------------------------------------- | -------------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------- |
|
||||
| BytePlus | `byteplus` / `byteplus-plan` | `BYTEPLUS_API_KEY` | `byteplus-plan/ark-code-latest` |
|
||||
| Cerebras | `cerebras` | `CEREBRAS_API_KEY` | `cerebras/zai-glm-4.7` |
|
||||
| Cohere | `cohere` | `COHERE_API_KEY` | `cohere/command-a-03-2025` |
|
||||
| Cloudflare AI Gateway | `cloudflare-ai-gateway` | `CLOUDFLARE_AI_GATEWAY_API_KEY` | - |
|
||||
| DeepInfra | `deepinfra` | `DEEPINFRA_API_KEY` | `deepinfra/deepseek-ai/DeepSeek-V4-Flash` |
|
||||
| DeepSeek | `deepseek` | `DEEPSEEK_API_KEY` | `deepseek/deepseek-v4-flash` |
|
||||
|
||||
@@ -1417,7 +1417,6 @@
|
||||
"providers/azure-speech",
|
||||
"providers/cerebras",
|
||||
"providers/chutes",
|
||||
"providers/cohere",
|
||||
"providers/claude-max-api-proxy",
|
||||
"providers/cloudflare-ai-gateway",
|
||||
"providers/comfy",
|
||||
|
||||
@@ -373,11 +373,11 @@ That stages grounded durable candidates into the short-term dreaming store while
|
||||
- top-level payload fields (`message`, `model`, `thinking`, ...) → `payload`
|
||||
- top-level delivery fields (`deliver`, `channel`, `to`, `provider`, ...) → `delivery`
|
||||
- payload `provider` delivery aliases → explicit `delivery.channel`
|
||||
- legacy `notify: true` webhook fallback jobs → explicit webhook delivery from `cron.webhook` when set; announce jobs keep their chat delivery and get `delivery.completionDestination`. When `cron.webhook` is unset, the inert top-level `notify` marker is removed for no-target jobs (existing delivery, including announce, is preserved) since runtime delivery never reads it
|
||||
- legacy `notify: true` webhook fallback jobs → explicit webhook delivery from `cron.webhook`; announce jobs keep their chat delivery and get `delivery.completionDestination`
|
||||
|
||||
The Gateway also sanitizes malformed cron rows at load time so valid jobs keep running. Raw malformed rows are copied to `jobs-quarantine.json` next to the active store before they are removed from `jobs.json`; doctor reports quarantined rows so you can review or repair them manually.
|
||||
|
||||
Gateway startup normalizes the runtime projection and ignores the top-level `notify` marker, but leaves the persisted cron config for doctor repair. When `cron.webhook` is unset, doctor removes the inert marker for jobs with no migration target (`delivery.mode` none/absent, an unusable webhook target, or existing announce/chat delivery), leaving the existing delivery untouched, so repeated `doctor --fix` runs no longer re-warn about the same job. If `cron.webhook` is set but not a valid HTTP(S) URL, doctor still warns and leaves the marker so you can fix the URL.
|
||||
Doctor and Gateway startup use the same `notify: true` migration before the scheduler runs. If `cron.webhook` is missing, doctor warns and leaves the legacy notify marker for manual repair.
|
||||
|
||||
On Linux, doctor also warns when the user's crontab still invokes legacy `~/.openclaw/bin/ensure-whatsapp.sh`. That host-local script is not maintained by current OpenClaw and can write false `Gateway inactive` messages to `~/.openclaw/logs/whatsapp-health.log` when cron cannot reach the systemd user bus. Remove the stale crontab entry with `crontab -e`; use `openclaw channels status --probe`, `openclaw doctor`, and `openclaw gateway status` for current health checks.
|
||||
|
||||
|
||||
@@ -335,8 +335,6 @@ the config fields that accept SecretRefs.
|
||||
- `BWS_ACCESS_TOKEN` available to the Gateway service.
|
||||
- `PATH` passed to the resolver, or `BWS_BIN` set to the absolute `bws`
|
||||
binary path.
|
||||
- `BWS_SERVER_URL` must be set in the environment when using a self-hosted
|
||||
Bitwarden instance.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -345,7 +343,7 @@ the config fields that accept SecretRefs.
|
||||
bws: {
|
||||
source: "exec",
|
||||
command: "/usr/local/bin/openclaw-bws-resolver.mjs",
|
||||
passEnv: ["BWS_ACCESS_TOKEN", "BWS_SERVER_URL", "PATH", "BWS_BIN"],
|
||||
passEnv: ["BWS_ACCESS_TOKEN", "PATH", "BWS_BIN"],
|
||||
jsonOnly: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -46,29 +46,6 @@ Docker is **optional**. Use it only if you want a containerized gateway or to va
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Airgapped rerun">
|
||||
On offline hosts, transfer and load the image first:
|
||||
|
||||
```bash
|
||||
docker load -i openclaw-image.tar
|
||||
export OPENCLAW_IMAGE="ghcr.io/openclaw/openclaw:latest"
|
||||
./scripts/docker/setup.sh --offline
|
||||
```
|
||||
|
||||
`--offline` verifies that `OPENCLAW_IMAGE` already exists locally, disables
|
||||
implicit Compose pulls and builds, then runs the normal setup flow such as
|
||||
`.env` synchronization, permission fixes, onboarding, gateway config sync,
|
||||
and Compose startup.
|
||||
|
||||
If `OPENCLAW_SANDBOX=1`, offline setup also checks the configured default
|
||||
and active per-agent sandbox images on the daemon behind
|
||||
`OPENCLAW_DOCKER_SOCKET`. Docker-backed browser images must also carry the
|
||||
current OpenClaw browser contract label. When a required image is missing or
|
||||
incompatible, setup exits without changing sandbox configuration instead of
|
||||
reporting success with an unusable sandbox.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Complete onboarding">
|
||||
The setup script runs onboarding automatically. It will:
|
||||
|
||||
|
||||
@@ -103,46 +103,8 @@ Supported `appServer` fields:
|
||||
| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed. |
|
||||
| `defaultWorkspaceDir` | current process directory | Workspace used by `/codex bind` when `--cwd` is omitted. |
|
||||
| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, and `null` clears the override. Legacy `"fast"` is accepted as `"priority"`. |
|
||||
| `networkProxy` | disabled | Opt into Codex permissions-profile networking for app-server commands. OpenClaw defines the selected `permissions.<profile>.network` config and selects it with `default_permissions` instead of sending `sandbox`. |
|
||||
| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with Codex app-server 0.132.0 or newer so native Codex execution can run inside the active OpenClaw sandbox. |
|
||||
|
||||
`appServer.networkProxy` is explicit because it changes the Codex sandbox
|
||||
contract. When enabled, OpenClaw also sets `features.network_proxy.enabled` and
|
||||
`default_permissions` in the Codex thread config so the generated permission
|
||||
profile can start Codex managed networking. By default, OpenClaw generates a
|
||||
collision-resistant `openclaw-network-<fingerprint>` profile name from the
|
||||
profile body; use `profileName` only when a stable local name is required.
|
||||
|
||||
```js
|
||||
export default {
|
||||
plugins: {
|
||||
entries: {
|
||||
codex: {
|
||||
config: {
|
||||
appServer: {
|
||||
sandbox: "workspace-write",
|
||||
networkProxy: {
|
||||
enabled: true,
|
||||
domains: {
|
||||
"api.openai.com": "allow",
|
||||
"blocked.example.com": "deny",
|
||||
},
|
||||
allowUpstreamProxy: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
If the normal app-server runtime would be `danger-full-access`, enabling
|
||||
`networkProxy` uses workspace-style filesystem access for the generated
|
||||
permission profile. Codex managed network enforcement is sandboxed networking,
|
||||
so a full-access profile would not protect outbound traffic.
|
||||
|
||||
The plugin blocks older or unversioned app-server handshakes. Codex app-server
|
||||
must report stable version `0.125.0` or newer.
|
||||
|
||||
|
||||
@@ -561,52 +561,8 @@ Supported `appServer` fields:
|
||||
| `sandbox` | `"danger-full-access"` or an allowed guardian sandbox | Native Codex sandbox mode sent to thread start/resume. Guardian defaults prefer `"workspace-write"` when allowed, otherwise `"read-only"`. When an OpenClaw sandbox is active, `danger-full-access` turns use Codex `workspace-write` with network access derived from the OpenClaw sandbox egress setting. |
|
||||
| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed, otherwise `guardian_subagent` or `user`. `guardian_subagent` remains a legacy alias. |
|
||||
| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, `null` clears the override, and legacy `"fast"` is accepted as `"priority"`. |
|
||||
| `networkProxy` | disabled | Opt into Codex permissions-profile networking for app-server commands. OpenClaw defines the selected `permissions.<profile>.network` config and selects it with `default_permissions` instead of sending `sandbox`. |
|
||||
| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with Codex app-server 0.132.0 or newer so native Codex execution can run inside the active OpenClaw sandbox. |
|
||||
|
||||
`appServer.networkProxy` is explicit because it changes the Codex sandbox
|
||||
contract. When enabled, OpenClaw also sets `features.network_proxy.enabled` and
|
||||
`default_permissions` in the Codex thread config so the generated permission
|
||||
profile can start Codex managed networking. By default, OpenClaw generates a
|
||||
collision-resistant `openclaw-network-<fingerprint>` profile name from the
|
||||
profile body; use `profileName` only when a stable local name is required.
|
||||
|
||||
```js
|
||||
export default {
|
||||
plugins: {
|
||||
entries: {
|
||||
codex: {
|
||||
config: {
|
||||
appServer: {
|
||||
sandbox: "workspace-write",
|
||||
networkProxy: {
|
||||
enabled: true,
|
||||
domains: {
|
||||
"api.openai.com": "allow",
|
||||
"blocked.example.com": "deny",
|
||||
},
|
||||
unixSockets: {
|
||||
"/tmp/proxy.sock": "allow",
|
||||
"/tmp/blocked.sock": "none",
|
||||
},
|
||||
allowUpstreamProxy: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
If the normal app-server runtime would be `danger-full-access`, enabling
|
||||
`networkProxy` uses workspace-style filesystem access for the generated
|
||||
permission profile. Codex managed network enforcement is sandboxed networking,
|
||||
so a full-access profile would not protect outbound traffic.
|
||||
Domain entries use `allow` or `deny`; Unix socket entries use Codex's
|
||||
`allow` or `none` values.
|
||||
|
||||
OpenClaw-owned dynamic tool calls are bounded independently from
|
||||
`appServer.requestTimeoutMs`: Codex `item/tool/call` requests use a 90 second
|
||||
OpenClaw watchdog by default. A positive per-call `timeoutMs` argument extends
|
||||
|
||||
@@ -51,7 +51,7 @@ Each entry lists the package, distribution route, and description.
|
||||
|
||||
## Core npm package
|
||||
|
||||
91 plugins
|
||||
90 plugins
|
||||
|
||||
- **[admin-http-rpc](/plugins/reference/admin-http-rpc)** (`@openclaw/admin-http-rpc`) - included in OpenClaw. OpenClaw admin HTTP RPC endpoint.
|
||||
|
||||
@@ -81,8 +81,6 @@ Each entry lists the package, distribution route, and description.
|
||||
|
||||
- **[codex-supervisor](/plugins/reference/codex-supervisor)** (`@openclaw/codex-supervisor`) - included in OpenClaw. Supervise Codex app-server sessions from OpenClaw.
|
||||
|
||||
- **[cohere](/plugins/reference/cohere)** (`@openclaw/cohere-provider`) - included in OpenClaw. Adds Cohere model provider support to OpenClaw.
|
||||
|
||||
- **[comfy](/plugins/reference/comfy)** (`@openclaw/comfy-provider`) - included in OpenClaw. Adds ComfyUI model provider support to OpenClaw.
|
||||
|
||||
- **[copilot-proxy](/plugins/reference/copilot-proxy)** (`@openclaw/copilot-proxy`) - included in OpenClaw. Adds Copilot Proxy model provider support to OpenClaw.
|
||||
|
||||
@@ -15,5 +15,5 @@ This page is generated from `extensions/*/package.json` and
|
||||
pnpm plugins:inventory:gen
|
||||
```
|
||||
|
||||
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 128
|
||||
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 127
|
||||
generated plugin reference pages by distribution, package, and description.
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
summary: "Adds Cohere model provider support to OpenClaw."
|
||||
read_when:
|
||||
- You are installing, configuring, or auditing the cohere plugin
|
||||
title: "Cohere plugin"
|
||||
---
|
||||
|
||||
# Cohere plugin
|
||||
|
||||
Adds Cohere model provider support to OpenClaw.
|
||||
|
||||
## Distribution
|
||||
|
||||
- Package: `@openclaw/cohere-provider`
|
||||
- Install route: included in OpenClaw
|
||||
|
||||
## Surface
|
||||
|
||||
providers: cohere
|
||||
|
||||
## Related docs
|
||||
|
||||
- [cohere](/providers/cohere)
|
||||
@@ -1,63 +0,0 @@
|
||||
---
|
||||
summary: "Cohere setup (auth + model selection)"
|
||||
title: "Cohere"
|
||||
read_when:
|
||||
- You want to use Cohere with OpenClaw
|
||||
- You need the Cohere API key env var or CLI auth choice
|
||||
---
|
||||
|
||||
[Cohere](https://cohere.com) provides OpenAI-compatible inference through its Compatibility API. OpenClaw includes a bundled Cohere provider plugin with the Command A model catalog.
|
||||
|
||||
| Property | Value |
|
||||
| --------------- | ---------------------------------------- |
|
||||
| Provider id | `cohere` |
|
||||
| Plugin | bundled, `enabledByDefault: true` |
|
||||
| Auth env var | `COHERE_API_KEY` |
|
||||
| Onboarding flag | `--auth-choice cohere-api-key` |
|
||||
| Direct CLI flag | `--cohere-api-key <key>` |
|
||||
| API | OpenAI-compatible (`openai-completions`) |
|
||||
| Base URL | `https://api.cohere.ai/compatibility/v1` |
|
||||
| Default model | `cohere/command-a-03-2025` |
|
||||
|
||||
## Get started
|
||||
|
||||
1. Create a Cohere API key.
|
||||
2. Run onboarding:
|
||||
|
||||
```bash
|
||||
openclaw onboard --non-interactive \
|
||||
--auth-choice cohere-api-key \
|
||||
--cohere-api-key "$COHERE_API_KEY"
|
||||
```
|
||||
|
||||
3. Confirm the catalog is available:
|
||||
|
||||
```bash
|
||||
openclaw models list --provider cohere
|
||||
```
|
||||
|
||||
The default model is set only when no primary model is already configured.
|
||||
|
||||
## Environment-only setup
|
||||
|
||||
Make `COHERE_API_KEY` available to the Gateway process, then select the bundled model:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "cohere/command-a-03-2025" },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
<Note>
|
||||
If the Gateway runs as a daemon or in Docker, configure `COHERE_API_KEY` for that service. Exporting it only in an interactive shell does not make it available to an already-running Gateway.
|
||||
</Note>
|
||||
|
||||
## Related
|
||||
|
||||
- [Model providers](/concepts/model-providers)
|
||||
- [Models CLI](/cli/models)
|
||||
- [Provider directory](/providers)
|
||||
@@ -33,7 +33,6 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
|
||||
- [BytePlus (International)](/concepts/model-providers#byteplus-international)
|
||||
- [Cerebras](/providers/cerebras)
|
||||
- [Chutes](/providers/chutes)
|
||||
- [Cohere](/providers/cohere)
|
||||
- [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway)
|
||||
- [ComfyUI](/providers/comfy)
|
||||
- [DeepSeek](/providers/deepseek)
|
||||
|
||||
@@ -27,7 +27,6 @@ model as `provider/model`.
|
||||
- [Anthropic (API + Claude CLI)](/providers/anthropic)
|
||||
- [BytePlus (International)](/concepts/model-providers#byteplus-international)
|
||||
- [Chutes](/providers/chutes)
|
||||
- [Cohere](/providers/cohere)
|
||||
- [ComfyUI](/providers/comfy)
|
||||
- [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway)
|
||||
- [DeepInfra](/providers/deepinfra)
|
||||
|
||||
@@ -1097,10 +1097,11 @@ sessionId})`; create, branch, continue, list, and fork flows live in their
|
||||
legacy `jobs.json`, `jobs-state.json`, and `runs/*.jsonl` files and removes
|
||||
the imported sources. Plugin target writebacks update matching `cron_jobs`
|
||||
rows instead of loading and replacing the whole cron store.
|
||||
- Gateway startup ignores legacy `notify: true` markers in the runtime
|
||||
projection. Doctor translates them into explicit SQLite delivery when
|
||||
`cron.webhook` is valid, removes inert markers when it is unset, and preserves
|
||||
them with a warning when the configured webhook is invalid.
|
||||
- Doctor and Gateway startup translate legacy `notify: true` webhook fallback
|
||||
into explicit SQLite delivery before the scheduler runs. Jobs that already
|
||||
announce to a chat keep that delivery and receive a webhook
|
||||
`completionDestination`; jobs without `cron.webhook` are reported for manual
|
||||
repair.
|
||||
- Outbound and session delivery queues now store queue status, entry kind,
|
||||
session key, channel, target, account id, retry count, last attempt/error,
|
||||
recovery state, and platform-send markers as typed columns in the shared
|
||||
|
||||
@@ -675,10 +675,9 @@ is disabled, uninstalled, or rolled back:
|
||||
clearCodeModeNamespacesForPlugin(pluginId);
|
||||
```
|
||||
|
||||
Code-mode cleanup is plugin-owned; clear the plugin's namespace registrations
|
||||
when its lifecycle ends instead of keeping per-namespace teardown handles. Tests
|
||||
can call `clearCodeModeNamespacesForTest()` to avoid leaking registrations
|
||||
across cases.
|
||||
Use `unregisterCodeModeNamespace(namespaceId)` only when removing one known
|
||||
namespace. Tests can call `clearCodeModeNamespacesForTest()` to avoid leaking
|
||||
registrations across cases.
|
||||
|
||||
### Test checklist
|
||||
|
||||
|
||||
@@ -190,24 +190,4 @@ describe("ClickClack gateway", () => {
|
||||
abort.abort();
|
||||
await run;
|
||||
});
|
||||
|
||||
it("clears running status when backlog polling fails", async () => {
|
||||
mocks.client.events.mockRejectedValue(new Error("clickclack unavailable"));
|
||||
const abort = new AbortController();
|
||||
const ctx = createGatewayContext(abort.signal);
|
||||
|
||||
await expect(startClickClackGatewayAccount(ctx)).rejects.toThrow("clickclack unavailable");
|
||||
|
||||
expect(ctx.setStatus).toHaveBeenCalledWith({
|
||||
accountId: "default",
|
||||
running: true,
|
||||
configured: true,
|
||||
enabled: true,
|
||||
baseUrl: "https://clickclack.example",
|
||||
});
|
||||
expect(ctx.setStatus).toHaveBeenLastCalledWith({
|
||||
accountId: "default",
|
||||
running: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -146,67 +146,62 @@ export async function startClickClackGatewayAccount(
|
||||
});
|
||||
let afterCursor = "";
|
||||
let initialized = false;
|
||||
try {
|
||||
while (!ctx.abortSignal.aborted) {
|
||||
const backlog = await client.events(workspaceId, afterCursor);
|
||||
if (!initialized) {
|
||||
// First pass establishes the cursor without replaying historical backlog
|
||||
// into fresh gateway sessions.
|
||||
for (const event of backlog) {
|
||||
afterCursor = event.cursor || afterCursor;
|
||||
}
|
||||
initialized = true;
|
||||
} else {
|
||||
for (const event of backlog) {
|
||||
while (!ctx.abortSignal.aborted) {
|
||||
const backlog = await client.events(workspaceId, afterCursor);
|
||||
if (!initialized) {
|
||||
// First pass establishes the cursor without replaying historical backlog
|
||||
// into fresh gateway sessions.
|
||||
for (const event of backlog) {
|
||||
afterCursor = event.cursor || afterCursor;
|
||||
}
|
||||
initialized = true;
|
||||
} else {
|
||||
for (const event of backlog) {
|
||||
afterCursor = event.cursor || afterCursor;
|
||||
await processEvent({
|
||||
account,
|
||||
config: ctx.cfg,
|
||||
client,
|
||||
event,
|
||||
botUserId: account.botUserId,
|
||||
});
|
||||
}
|
||||
}
|
||||
const socket = client.websocket(workspaceId, afterCursor);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const abort = () => {
|
||||
socket.close();
|
||||
resolve();
|
||||
};
|
||||
ctx.abortSignal.addEventListener("abort", abort, { once: true });
|
||||
socket.on("message", (data) => {
|
||||
void (async () => {
|
||||
const event = parseSocketEvent(data);
|
||||
if (!event) {
|
||||
ctx.log?.warn?.(`[${account.accountId}] skipped malformed ClickClack websocket event`);
|
||||
return;
|
||||
}
|
||||
afterCursor = event.cursor || afterCursor;
|
||||
await processEvent({
|
||||
account,
|
||||
config: ctx.cfg,
|
||||
client,
|
||||
event,
|
||||
botUserId: account.botUserId,
|
||||
botUserId: account.botUserId ?? "",
|
||||
});
|
||||
}
|
||||
}
|
||||
const socket = client.websocket(workspaceId, afterCursor);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const abort = () => {
|
||||
socket.close();
|
||||
resolve();
|
||||
};
|
||||
ctx.abortSignal.addEventListener("abort", abort, { once: true });
|
||||
socket.on("message", (data) => {
|
||||
void (async () => {
|
||||
const event = parseSocketEvent(data);
|
||||
if (!event) {
|
||||
ctx.log?.warn?.(
|
||||
`[${account.accountId}] skipped malformed ClickClack websocket event`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
afterCursor = event.cursor || afterCursor;
|
||||
await processEvent({
|
||||
account,
|
||||
config: ctx.cfg,
|
||||
client,
|
||||
event,
|
||||
botUserId: account.botUserId ?? "",
|
||||
});
|
||||
})().catch(reject);
|
||||
});
|
||||
socket.on("close", () => {
|
||||
ctx.abortSignal.removeEventListener("abort", abort);
|
||||
resolve();
|
||||
});
|
||||
socket.on("error", reject);
|
||||
})().catch(reject);
|
||||
});
|
||||
socket.on("close", () => {
|
||||
ctx.abortSignal.removeEventListener("abort", abort);
|
||||
resolve();
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
if (!ctx.abortSignal.aborted) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, account.reconnectMs);
|
||||
});
|
||||
if (!ctx.abortSignal.aborted) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, account.reconnectMs);
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
ctx.setStatus({ accountId: account.accountId, running: false });
|
||||
}
|
||||
ctx.setStatus({ accountId: account.accountId, running: false });
|
||||
}
|
||||
|
||||
@@ -193,47 +193,6 @@
|
||||
"enum": ["user", "auto_review", "guardian_subagent"]
|
||||
},
|
||||
"serviceTier": { "type": ["string", "null"] },
|
||||
"networkProxy": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"profileName": { "type": "string" },
|
||||
"baseProfile": {
|
||||
"type": "string",
|
||||
"enum": ["read-only", "workspace"]
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["limited", "full"]
|
||||
},
|
||||
"domains": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": ["allow", "deny"]
|
||||
}
|
||||
},
|
||||
"unixSockets": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": ["allow", "none"]
|
||||
}
|
||||
},
|
||||
"proxyUrl": { "type": "string" },
|
||||
"socksUrl": { "type": "string" },
|
||||
"enableSocks5": { "type": "boolean" },
|
||||
"enableSocks5Udp": { "type": "boolean" },
|
||||
"allowUpstreamProxy": { "type": "boolean" },
|
||||
"allowLocalBinding": { "type": "boolean" },
|
||||
"dangerouslyAllowNonLoopbackProxy": { "type": "boolean" },
|
||||
"dangerouslyAllowAllUnixSockets": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"defaultWorkspaceDir": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -426,81 +385,6 @@
|
||||
"help": "Optional Codex app-server service tier. Use priority, flex, or null. Legacy fast is accepted as priority.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy": {
|
||||
"label": "Network Proxy",
|
||||
"help": "Enable Codex permissions-profile networking for app-server commands.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.enabled": {
|
||||
"label": "Network Proxy Enabled",
|
||||
"help": "When enabled, OpenClaw defines a Codex permissions profile and selects it with default_permissions instead of sandbox fields.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.profileName": {
|
||||
"label": "Network Proxy Profile",
|
||||
"help": "Optional stable Codex permissions profile name. Leave unset to use a generated openclaw-network fingerprint name.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.baseProfile": {
|
||||
"label": "Network Proxy Base",
|
||||
"help": "Filesystem access used by the generated profile. Defaults to read-only for read-only sandboxes and workspace otherwise.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.domains": {
|
||||
"label": "Network Domains",
|
||||
"help": "Domain allow and deny rules for Codex sandboxed networking.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.unixSockets": {
|
||||
"label": "Unix Sockets",
|
||||
"help": "Unix socket allow and none rules for Codex sandboxed networking.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.proxyUrl": {
|
||||
"label": "HTTP Proxy URL",
|
||||
"help": "HTTP listener URL used by Codex sandboxed networking.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.socksUrl": {
|
||||
"label": "SOCKS Proxy URL",
|
||||
"help": "SOCKS listener URL used by Codex sandboxed networking.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.enableSocks5": {
|
||||
"label": "Enable SOCKS5",
|
||||
"help": "Expose SOCKS5 support for the generated Codex permissions profile.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.enableSocks5Udp": {
|
||||
"label": "Enable SOCKS5 UDP",
|
||||
"help": "Allow UDP over the SOCKS5 listener when SOCKS5 is enabled.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.allowUpstreamProxy": {
|
||||
"label": "Allow Upstream Proxy",
|
||||
"help": "Allow Codex sandboxed networking to chain through inherited HTTP(S)_PROXY or ALL_PROXY settings.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.allowLocalBinding": {
|
||||
"label": "Allow Local Binding",
|
||||
"help": "Permit broader local and private-network access through Codex sandboxed networking.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.mode": {
|
||||
"label": "Network Mode",
|
||||
"help": "Codex sandboxed networking mode for subprocess traffic.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.dangerouslyAllowNonLoopbackProxy": {
|
||||
"label": "Allow Non-Loopback Proxy",
|
||||
"help": "Permit non-loopback bind addresses for Codex sandboxed networking listeners.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.dangerouslyAllowAllUnixSockets": {
|
||||
"label": "Allow All Unix Sockets",
|
||||
"help": "Bypass Codex's Unix socket allowlist for tightly controlled environments.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.defaultWorkspaceDir": {
|
||||
"label": "Default Workspace",
|
||||
"help": "Workspace used by /codex bind when --cwd is omitted.",
|
||||
|
||||
@@ -10,10 +10,7 @@ import { AUTH_PROFILE_RUNTIME_CONTRACT } from "openclaw/plugin-sdk/agent-runtime
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CodexAppServerClientFactory } from "./client-factory.js";
|
||||
import { runCodexAppServerAttempt as runCodexAppServerAttemptImpl } from "./run-attempt.js";
|
||||
import {
|
||||
readCodexAppServerBinding,
|
||||
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
|
||||
} from "./session-binding.js";
|
||||
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
|
||||
import { createCodexTestModel } from "./test-support.js";
|
||||
|
||||
let codexAppServerClientFactoryForTest: CodexAppServerClientFactory | undefined;
|
||||
@@ -61,25 +58,6 @@ function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAtt
|
||||
} as EmbeddedRunAttemptParams;
|
||||
}
|
||||
|
||||
const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
});
|
||||
|
||||
function writeCodexAppServerBinding(
|
||||
...args: Parameters<typeof writeRawCodexAppServerBinding>
|
||||
) {
|
||||
const [sessionFile, binding, lookup] = args;
|
||||
return writeRawCodexAppServerBinding(
|
||||
sessionFile,
|
||||
{
|
||||
webSearchThreadConfigFingerprint: DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT,
|
||||
...binding,
|
||||
},
|
||||
lookup,
|
||||
);
|
||||
}
|
||||
|
||||
function threadStartResult(threadId = "thread-auth-contract") {
|
||||
return {
|
||||
thread: {
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
CODEX_PLUGINS_CONFIG_KEYS,
|
||||
canUseCodexModelBackedApprovalsReviewerForModel,
|
||||
codexAppServerStartOptionsKey,
|
||||
fingerprintCodexAppServerNetworkProxyConfigPatch,
|
||||
readCodexPluginConfig,
|
||||
resolveCodexAppServerRuntimeOptions,
|
||||
resolveCodexComputerUseConfig,
|
||||
@@ -84,21 +83,6 @@ describe("Codex app-server config", () => {
|
||||
sandbox: "danger-full-access",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldAutoApproveCodexAppServerApprovals({
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
networkProxy: {
|
||||
profileName: "openclaw-network",
|
||||
configFingerprint: "network-proxy-v1",
|
||||
configPatch: {
|
||||
"features.network_proxy.enabled": true,
|
||||
default_permissions: "openclaw-network",
|
||||
permissions: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("parses typed plugin config before falling back to environment knobs", () => {
|
||||
@@ -141,102 +125,6 @@ describe("Codex app-server config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("builds Codex permissions-profile config for app-server network proxy", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
sandbox: "workspace-write",
|
||||
networkProxy: {
|
||||
enabled: true,
|
||||
profileName: "mock-proxy",
|
||||
mode: "limited",
|
||||
domains: {
|
||||
" api.openai.com ": "allow",
|
||||
"blocked.example.com": "deny",
|
||||
},
|
||||
unixSockets: {
|
||||
" /tmp/mock-proxy.sock ": "allow",
|
||||
"/tmp/blocked.sock": "none",
|
||||
},
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
socksUrl: "socks5h://127.0.0.1:8081",
|
||||
enableSocks5: true,
|
||||
enableSocks5Udp: false,
|
||||
allowUpstreamProxy: true,
|
||||
allowLocalBinding: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const networkProxy = runtime.networkProxy;
|
||||
if (!networkProxy) {
|
||||
throw new Error("Expected network proxy runtime config");
|
||||
}
|
||||
expect(networkProxy).toEqual({
|
||||
profileName: "mock-proxy",
|
||||
configFingerprint: expect.any(String),
|
||||
configPatch: {
|
||||
"features.network_proxy.enabled": true,
|
||||
default_permissions: "mock-proxy",
|
||||
permissions: {
|
||||
"mock-proxy": {
|
||||
filesystem: {
|
||||
":minimal": "read",
|
||||
":project_roots": {
|
||||
".": "write",
|
||||
},
|
||||
},
|
||||
network: {
|
||||
enabled: true,
|
||||
mode: "limited",
|
||||
domains: {
|
||||
"api.openai.com": "allow",
|
||||
"blocked.example.com": "deny",
|
||||
},
|
||||
unix_sockets: {
|
||||
"/tmp/mock-proxy.sock": "allow",
|
||||
"/tmp/blocked.sock": "none",
|
||||
},
|
||||
proxy_url: "http://127.0.0.1:3128",
|
||||
socks_url: "socks5h://127.0.0.1:8081",
|
||||
enable_socks5: true,
|
||||
enable_socks5_udp: false,
|
||||
allow_upstream_proxy: true,
|
||||
allow_local_binding: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(networkProxy.configFingerprint).toBe(
|
||||
fingerprintCodexAppServerNetworkProxyConfigPatch(networkProxy.configPatch),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses read-only filesystem rules for read-only network proxy profiles", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
sandbox: "read-only",
|
||||
networkProxy: {
|
||||
enabled: true,
|
||||
domains: { "example.com": "allow" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const profileName = runtime.networkProxy?.profileName;
|
||||
const permissions = runtime.networkProxy?.configPatch.permissions as Record<
|
||||
string,
|
||||
{ filesystem: { ":project_roots": { ".": string } } }
|
||||
>;
|
||||
|
||||
expect(profileName).toMatch(/^openclaw-network-[a-f0-9]{16}$/u);
|
||||
expect(runtime.networkProxy?.configPatch.default_permissions).toBe(profileName);
|
||||
expect(permissions[profileName ?? ""]?.filesystem[":project_roots"]["."]).toBe("read");
|
||||
});
|
||||
|
||||
it("clamps oversized app-server timer config", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Codex helper module supports config behavior.
|
||||
import { createHash, createHmac, randomBytes } from "node:crypto";
|
||||
import { createHmac, randomBytes } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { hostname as readHostName } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -16,7 +16,7 @@ import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
||||
import { normalizeTrimmedStringList } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { detectWindowsSpawnCommandInlineArgs } from "openclaw/plugin-sdk/windows-spawn";
|
||||
import { z } from "zod";
|
||||
import type { CodexSandboxPolicy, CodexServiceTier, JsonObject, JsonValue } from "./protocol.js";
|
||||
import type { CodexSandboxPolicy, CodexServiceTier } from "./protocol.js";
|
||||
|
||||
const START_OPTIONS_KEY_SECRET_SYMBOL = Symbol.for("openclaw.codexAppServerStartOptionsKeySecret");
|
||||
const START_OPTIONS_KEY_SECRET = getStartOptionsKeySecret();
|
||||
@@ -111,34 +111,6 @@ export type CodexAppServerExperimentalConfig = {
|
||||
sandboxExecServer?: boolean;
|
||||
};
|
||||
|
||||
export type CodexAppServerNetworkProxyDomainPermission = "allow" | "deny";
|
||||
export type CodexAppServerNetworkProxyUnixSocketPermission = "allow" | "none";
|
||||
export type CodexAppServerNetworkProxyBaseProfile = "read-only" | "workspace";
|
||||
export type CodexAppServerNetworkProxyMode = "limited" | "full";
|
||||
|
||||
export type CodexAppServerNetworkProxyConfig = {
|
||||
enabled?: boolean;
|
||||
profileName?: string;
|
||||
baseProfile?: CodexAppServerNetworkProxyBaseProfile;
|
||||
mode?: CodexAppServerNetworkProxyMode;
|
||||
domains?: Record<string, CodexAppServerNetworkProxyDomainPermission>;
|
||||
unixSockets?: Record<string, CodexAppServerNetworkProxyUnixSocketPermission>;
|
||||
proxyUrl?: string;
|
||||
socksUrl?: string;
|
||||
enableSocks5?: boolean;
|
||||
enableSocks5Udp?: boolean;
|
||||
allowUpstreamProxy?: boolean;
|
||||
allowLocalBinding?: boolean;
|
||||
dangerouslyAllowNonLoopbackProxy?: boolean;
|
||||
dangerouslyAllowAllUnixSockets?: boolean;
|
||||
};
|
||||
|
||||
export type ResolvedCodexAppServerNetworkProxyConfig = {
|
||||
profileName: string;
|
||||
configFingerprint: string;
|
||||
configPatch: JsonObject;
|
||||
};
|
||||
|
||||
export type ResolvedCodexPluginPolicy = {
|
||||
configKey: string;
|
||||
marketplaceName: typeof CODEX_PLUGINS_MARKETPLACE_NAME;
|
||||
@@ -179,7 +151,6 @@ export type CodexAppServerRuntimeOptions = {
|
||||
sandbox: CodexAppServerSandboxMode;
|
||||
approvalsReviewer: CodexAppServerApprovalsReviewer;
|
||||
serviceTier?: CodexServiceTier;
|
||||
networkProxy?: ResolvedCodexAppServerNetworkProxyConfig;
|
||||
};
|
||||
|
||||
export type CodexModelBackedReviewerContext = {
|
||||
@@ -217,20 +188,15 @@ export type CodexPluginConfig = {
|
||||
sandbox?: CodexAppServerSandboxMode;
|
||||
approvalsReviewer?: CodexAppServerApprovalsReviewer;
|
||||
serviceTier?: CodexServiceTier | null;
|
||||
networkProxy?: CodexAppServerNetworkProxyConfig;
|
||||
defaultWorkspaceDir?: string;
|
||||
experimental?: CodexAppServerExperimentalConfig;
|
||||
};
|
||||
};
|
||||
|
||||
export function shouldAutoApproveCodexAppServerApprovals(
|
||||
appServer: Pick<CodexAppServerRuntimeOptions, "approvalPolicy" | "networkProxy" | "sandbox">,
|
||||
appServer: Pick<CodexAppServerRuntimeOptions, "approvalPolicy" | "sandbox">,
|
||||
): boolean {
|
||||
return (
|
||||
appServer.networkProxy === undefined &&
|
||||
appServer.approvalPolicy === "never" &&
|
||||
appServer.sandbox === "danger-full-access"
|
||||
);
|
||||
return appServer.approvalPolicy === "never" && appServer.sandbox === "danger-full-access";
|
||||
}
|
||||
|
||||
export const CODEX_APP_SERVER_CONFIG_KEYS = [
|
||||
@@ -250,7 +216,6 @@ export const CODEX_APP_SERVER_CONFIG_KEYS = [
|
||||
"sandbox",
|
||||
"approvalsReviewer",
|
||||
"serviceTier",
|
||||
"networkProxy",
|
||||
"defaultWorkspaceDir",
|
||||
"experimental",
|
||||
] as const;
|
||||
@@ -284,7 +249,6 @@ export const CODEX_PLUGIN_ENTRY_CONFIG_KEYS = [
|
||||
const DEFAULT_CODEX_COMPUTER_USE_PLUGIN_NAME = "computer-use";
|
||||
const DEFAULT_CODEX_COMPUTER_USE_MCP_SERVER_NAME = "computer-use";
|
||||
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 codexAppServerPolicyModeSchema = z.enum(["yolo", "guardian"]);
|
||||
@@ -309,26 +273,6 @@ const codexAppServerExperimentalSchema = z
|
||||
sandboxExecServer: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
const codexAppServerNetworkProxyDomainPermissionSchema = z.enum(["allow", "deny"]);
|
||||
const codexAppServerNetworkProxyUnixSocketPermissionSchema = z.enum(["allow", "none"]);
|
||||
const codexAppServerNetworkProxySchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
profileName: z.string().trim().min(1).optional(),
|
||||
baseProfile: z.enum(["read-only", "workspace"]).optional(),
|
||||
mode: z.enum(["limited", "full"]).optional(),
|
||||
domains: z.record(z.string(), codexAppServerNetworkProxyDomainPermissionSchema).optional(),
|
||||
unixSockets: z.record(z.string(), codexAppServerNetworkProxyUnixSocketPermissionSchema).optional(),
|
||||
proxyUrl: z.string().trim().min(1).optional(),
|
||||
socksUrl: z.string().trim().min(1).optional(),
|
||||
enableSocks5: z.boolean().optional(),
|
||||
enableSocks5Udp: z.boolean().optional(),
|
||||
allowUpstreamProxy: z.boolean().optional(),
|
||||
allowLocalBinding: z.boolean().optional(),
|
||||
dangerouslyAllowNonLoopbackProxy: z.boolean().optional(),
|
||||
dangerouslyAllowAllUnixSockets: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const codexPluginEntryConfigSchema = z
|
||||
.object({
|
||||
@@ -390,7 +334,6 @@ const codexPluginConfigSchema = z
|
||||
sandbox: codexAppServerSandboxSchema.optional(),
|
||||
approvalsReviewer: codexAppServerApprovalsReviewerSchema.optional(),
|
||||
serviceTier: codexAppServerServiceTierSchema,
|
||||
networkProxy: codexAppServerNetworkProxySchema.optional(),
|
||||
defaultWorkspaceDir: z.string().optional(),
|
||||
experimental: codexAppServerExperimentalSchema.optional(),
|
||||
})
|
||||
@@ -606,11 +549,6 @@ export function resolveCodexAppServerRuntimeOptions(
|
||||
? normalizedPolicyMode
|
||||
: (explicitPolicyMode ?? normalizedPolicyMode ?? defaultPolicy?.mode ?? "yolo");
|
||||
const serviceTier = normalizeCodexServiceTier(config.serviceTier);
|
||||
const resolvedSandbox =
|
||||
forcedPolicy?.sandbox ??
|
||||
configuredSandbox ??
|
||||
defaultPolicy?.sandbox ??
|
||||
(policyMode === "guardian" ? "workspace-write" : "danger-full-access");
|
||||
if (transport === "websocket" && !url) {
|
||||
throw new Error(
|
||||
"plugins.entries.codex.config.appServer.url is required when appServer.transport is websocket",
|
||||
@@ -659,14 +597,17 @@ export function resolveCodexAppServerRuntimeOptions(
|
||||
: {}),
|
||||
approvalPolicy: forcedPolicy?.approvalPolicy ?? approvalPolicy,
|
||||
approvalPolicySource,
|
||||
sandbox: resolvedSandbox,
|
||||
sandbox:
|
||||
forcedPolicy?.sandbox ??
|
||||
configuredSandbox ??
|
||||
defaultPolicy?.sandbox ??
|
||||
(policyMode === "guardian" ? "workspace-write" : "danger-full-access"),
|
||||
approvalsReviewer:
|
||||
forcedPolicy?.approvalsReviewer ??
|
||||
explicitApprovalsReviewer ??
|
||||
defaultPolicy?.approvalsReviewer ??
|
||||
(policyMode === "guardian" ? "auto_review" : "user"),
|
||||
...(serviceTier ? { serviceTier } : {}),
|
||||
...resolveCodexAppServerNetworkProxy(config.networkProxy, resolvedSandbox),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -880,104 +821,6 @@ export function codexSandboxPolicyForTurn(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCodexAppServerNetworkProxy(
|
||||
config: CodexAppServerNetworkProxyConfig | undefined,
|
||||
sandbox: CodexAppServerSandboxMode,
|
||||
): { networkProxy?: ResolvedCodexAppServerNetworkProxyConfig } {
|
||||
if (config?.enabled !== true) {
|
||||
return {};
|
||||
}
|
||||
const fileSystemMode =
|
||||
config.baseProfile === "read-only" || (!config.baseProfile && sandbox === "read-only")
|
||||
? "read"
|
||||
: "write";
|
||||
const networkConfig = removeUndefinedJsonFields({
|
||||
enabled: true,
|
||||
mode: config.mode,
|
||||
domains: normalizeNetworkProxyPermissionMap(config.domains),
|
||||
unix_sockets: normalizeNetworkProxyPermissionMap(config.unixSockets),
|
||||
proxy_url: readNonEmptyString(config.proxyUrl),
|
||||
socks_url: readNonEmptyString(config.socksUrl),
|
||||
enable_socks5: config.enableSocks5,
|
||||
enable_socks5_udp: config.enableSocks5Udp,
|
||||
allow_upstream_proxy: config.allowUpstreamProxy,
|
||||
allow_local_binding: config.allowLocalBinding,
|
||||
dangerously_allow_non_loopback_proxy: config.dangerouslyAllowNonLoopbackProxy,
|
||||
dangerously_allow_all_unix_sockets: config.dangerouslyAllowAllUnixSockets,
|
||||
});
|
||||
const profile = {
|
||||
filesystem: {
|
||||
":minimal": "read",
|
||||
":project_roots": {
|
||||
".": fileSystemMode,
|
||||
},
|
||||
},
|
||||
network: networkConfig,
|
||||
};
|
||||
const profileName = resolveNetworkProxyPermissionProfileName(config, profile);
|
||||
const configPatch: JsonObject = {
|
||||
"features.network_proxy.enabled": true,
|
||||
default_permissions: profileName,
|
||||
permissions: {
|
||||
[profileName]: profile,
|
||||
},
|
||||
};
|
||||
return {
|
||||
networkProxy: {
|
||||
profileName,
|
||||
configFingerprint: fingerprintCodexAppServerNetworkProxyConfigPatch(configPatch),
|
||||
configPatch,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveNetworkProxyPermissionProfileName(
|
||||
config: CodexAppServerNetworkProxyConfig,
|
||||
profile: JsonObject,
|
||||
): string {
|
||||
const explicitProfileName = readNonEmptyString(config.profileName);
|
||||
if (explicitProfileName) {
|
||||
return explicitProfileName;
|
||||
}
|
||||
const suffix = createHash("sha256")
|
||||
.update(stableStringifyJson({ version: 1, profile }))
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
return `${DEFAULT_CODEX_APP_SERVER_NETWORK_PROXY_PROFILE_PREFIX}-${suffix}`;
|
||||
}
|
||||
|
||||
export function fingerprintCodexAppServerNetworkProxyConfigPatch(configPatch: JsonObject): string {
|
||||
return createHash("sha256").update(stableStringifyJson(configPatch)).digest("hex");
|
||||
}
|
||||
|
||||
function normalizeNetworkProxyPermissionMap<TPermission extends string>(
|
||||
value: Record<string, TPermission> | undefined,
|
||||
): Record<string, TPermission> | undefined {
|
||||
const entries = Object.entries(value ?? {})
|
||||
.map(([key, permission]) => [key.trim(), permission] as const)
|
||||
.filter(([key]) => key.length > 0);
|
||||
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
||||
}
|
||||
|
||||
function removeUndefinedJsonFields(value: Record<string, JsonValue | undefined>): JsonObject {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter((entry): entry is [string, JsonValue] => entry[1] !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
function stableStringifyJson(value: JsonValue): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => stableStringifyJson(item)).join(",")}]`;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return `{${Object.entries(value)
|
||||
.toSorted(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringifyJson(item)}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function withMcpElicitationsApprovalPolicy(
|
||||
policy: CodexAppServerEffectiveApprovalPolicy,
|
||||
): CodexAppServerEffectiveApprovalPolicy {
|
||||
|
||||
@@ -1313,28 +1313,6 @@ describe("Codex app-server dynamic tool build", () => {
|
||||
expect(shouldForceMessageTool(params)).toBe(false);
|
||||
});
|
||||
|
||||
it("can retain message in the registered schema when disabled for the current turn", async () => {
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.disableMessageTool = true;
|
||||
params.sourceReplyDeliveryMode = "message_tool_only";
|
||||
params.toolsAllow = [];
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
setOpenClawCodingToolsFactoryForTests((options) =>
|
||||
options?.disableMessageTool ? [] : [createRuntimeDynamicTool("message")],
|
||||
);
|
||||
|
||||
const availableTools = await buildDynamicToolsForTest(params, workspaceDir);
|
||||
const registeredTools = await buildDynamicToolsForTest(params, workspaceDir, {
|
||||
ignoreDisableMessageTool: true,
|
||||
ignoreRuntimePlan: true,
|
||||
});
|
||||
|
||||
expect(availableTools.map((tool) => tool.name)).not.toContain("message");
|
||||
expect(registeredTools.map((tool) => tool.name)).toContain("message");
|
||||
});
|
||||
|
||||
it("passes the live run session key to Codex dynamic tools when sandbox policy uses another key", () => {
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
|
||||
|
||||
@@ -76,7 +76,6 @@ export type DynamicToolBuildParams = {
|
||||
pluginConfig: CodexPluginConfig;
|
||||
profilerEnabled?: boolean;
|
||||
forceHeartbeatTool?: boolean;
|
||||
ignoreDisableMessageTool?: boolean;
|
||||
ignoreRuntimePlan?: boolean;
|
||||
onYieldDetected: () => void;
|
||||
onCodexAppServerEvent?: (event: CodexDynamicToolBuildEvent) => void;
|
||||
@@ -204,9 +203,6 @@ export function formatCodexDynamicToolBuildStageSummary(
|
||||
/** Builds, filters, and normalizes Codex-compatible runtime tools for a single turn. */
|
||||
export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
const { params } = input;
|
||||
const messagePolicyParams = input.ignoreDisableMessageTool
|
||||
? { ...params, disableMessageTool: false }
|
||||
: params;
|
||||
if (params.disableTools) {
|
||||
input.onWebSearchPolicyResolved?.(false);
|
||||
return [];
|
||||
@@ -299,8 +295,8 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
requireExplicitMessageTarget:
|
||||
params.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey),
|
||||
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
|
||||
disableMessageTool: input.ignoreDisableMessageTool ? false : params.disableMessageTool,
|
||||
forceMessageTool: shouldForceMessageTool(messagePolicyParams),
|
||||
disableMessageTool: params.disableMessageTool,
|
||||
forceMessageTool: shouldForceMessageTool(params),
|
||||
enableHeartbeatTool: params.trigger === "heartbeat" || input.forceHeartbeatTool === true,
|
||||
forceHeartbeatTool: params.trigger === "heartbeat" || input.forceHeartbeatTool === true,
|
||||
onYield: (message) => {
|
||||
@@ -379,7 +375,7 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
transientWebSearchRestriction &&
|
||||
webSearchPolicy.persistentAllowed),
|
||||
);
|
||||
const toolsAllow = includeForcedCodexDynamicToolAllow(params.toolsAllow, messagePolicyParams);
|
||||
const toolsAllow = includeForcedCodexDynamicToolAllow(params.toolsAllow, params);
|
||||
const filteredTools = filterCodexDynamicToolsForAllowlist(visionFilteredTools, toolsAllow);
|
||||
toolBuildStages.mark("allowlist-filter");
|
||||
const normalizedTools = normalizeAgentRuntimeTools({
|
||||
|
||||
@@ -161,7 +161,7 @@ describe("OpenClaw-owned tool runtime contract — Codex app-server adapter", ()
|
||||
expectRecordFields(eventRecord, {
|
||||
toolName: "exec",
|
||||
toolCallId: "call-middleware",
|
||||
args: mergedParams,
|
||||
args: { command: "status" },
|
||||
});
|
||||
expectRecordFields(requireRecord(eventRecord.result, "tool_result middleware result"), {
|
||||
content: [{ type: "text", text: "raw output" }],
|
||||
|
||||
@@ -1055,6 +1055,14 @@
|
||||
"description": "Usually the first user message in the thread, if available.",
|
||||
"type": "string"
|
||||
},
|
||||
"recencyAt": {
|
||||
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"sessionId": {
|
||||
"description": "Session id shared by threads that belong to the same session tree.",
|
||||
"type": "string"
|
||||
|
||||
@@ -1055,6 +1055,14 @@
|
||||
"description": "Usually the first user message in the thread, if available.",
|
||||
"type": "string"
|
||||
},
|
||||
"recencyAt": {
|
||||
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"sessionId": {
|
||||
"description": "Session id shared by threads that belong to the same session tree.",
|
||||
"type": "string"
|
||||
|
||||
@@ -66,25 +66,6 @@ function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAtt
|
||||
} as EmbeddedRunAttemptParams;
|
||||
}
|
||||
|
||||
const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
});
|
||||
|
||||
function writeCodexAppServerBinding(
|
||||
...args: Parameters<typeof writeRawCodexAppServerBinding>
|
||||
) {
|
||||
const [sessionFile, binding, lookup] = args;
|
||||
return writeRawCodexAppServerBinding(
|
||||
sessionFile,
|
||||
{
|
||||
webSearchThreadConfigFingerprint: DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT,
|
||||
...binding,
|
||||
},
|
||||
lookup,
|
||||
);
|
||||
}
|
||||
|
||||
function assistantMessage(text: string, timestamp: number): AgentMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
@@ -264,6 +245,23 @@ function createContextEngine(overrides: Partial<ContextEngine> = {}): ContextEng
|
||||
return engine;
|
||||
}
|
||||
|
||||
const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
});
|
||||
|
||||
function writeCodexAppServerBinding(...args: Parameters<typeof writeRawCodexAppServerBinding>) {
|
||||
const [sessionFile, binding, lookup] = args;
|
||||
return writeRawCodexAppServerBinding(
|
||||
sessionFile,
|
||||
{
|
||||
webSearchThreadConfigFingerprint: DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT,
|
||||
...binding,
|
||||
},
|
||||
lookup,
|
||||
);
|
||||
}
|
||||
|
||||
type MockCallReader = { mock: { calls: unknown[][] } };
|
||||
|
||||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
|
||||
@@ -18,32 +18,10 @@ import {
|
||||
tempDir,
|
||||
} from "./run-attempt-test-harness.js";
|
||||
import { testing } from "./run-attempt.js";
|
||||
import {
|
||||
readCodexAppServerBinding,
|
||||
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
|
||||
} from "./session-binding.js";
|
||||
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
|
||||
|
||||
setupRunAttemptTestHooks();
|
||||
|
||||
const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
});
|
||||
|
||||
function writeCodexAppServerBinding(
|
||||
...args: Parameters<typeof writeRawCodexAppServerBinding>
|
||||
) {
|
||||
const [sessionFile, binding, lookup] = args;
|
||||
return writeRawCodexAppServerBinding(
|
||||
sessionFile,
|
||||
{
|
||||
webSearchThreadConfigFingerprint: DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT,
|
||||
...binding,
|
||||
},
|
||||
lookup,
|
||||
);
|
||||
}
|
||||
|
||||
describe("runCodexAppServerAttempt native hook relay", () => {
|
||||
it("registers native hook relay config for an enabled Codex turn and cleans it up", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
@@ -631,7 +609,6 @@ describe("runCodexAppServerAttempt native hook relay", () => {
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
dynamicToolsFingerprint: "[]",
|
||||
nativeHookRelayGeneration: "generation-from-failed-resume",
|
||||
});
|
||||
const harness = createStartedThreadHarness(async (method) => {
|
||||
|
||||
@@ -74,11 +74,7 @@ import { createSandboxContext } from "./sandbox-exec-server.test-helpers.js";
|
||||
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
|
||||
import * as sharedClientModule from "./shared-client.js";
|
||||
import { createCodexTestModel } from "./test-support.js";
|
||||
import {
|
||||
buildTurnStartParams,
|
||||
codexDynamicToolsFingerprint,
|
||||
startOrResumeThread,
|
||||
} from "./thread-lifecycle.js";
|
||||
import { buildTurnStartParams, startOrResumeThread } from "./thread-lifecycle.js";
|
||||
|
||||
function flushDiagnosticEvents() {
|
||||
return waitForDiagnosticEventsDrained();
|
||||
@@ -120,11 +116,6 @@ function expectResumeRequest(
|
||||
}
|
||||
}
|
||||
|
||||
const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
});
|
||||
|
||||
async function writeExistingBinding(
|
||||
sessionFile: string,
|
||||
workspaceDir: string,
|
||||
@@ -135,7 +126,6 @@ async function writeExistingBinding(
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
webSearchThreadConfigFingerprint: DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
@@ -215,7 +205,7 @@ async function buildDynamicToolsForTest(
|
||||
options: Partial<
|
||||
Pick<
|
||||
Parameters<typeof testing.buildDynamicTools>[0],
|
||||
"forceHeartbeatTool" | "ignoreDisableMessageTool" | "ignoreRuntimePlan"
|
||||
"forceHeartbeatTool" | "ignoreRuntimePlan"
|
||||
>
|
||||
> = {},
|
||||
) {
|
||||
@@ -313,7 +303,7 @@ function createCodexToolBridgeForTest(
|
||||
tools,
|
||||
registeredTools,
|
||||
signal,
|
||||
directToolNames: testing.resolveCodexDynamicToolDirectNames(params),
|
||||
directToolNames: testing.shouldForceMessageTool(params) ? ["message"] : [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1682,55 +1672,6 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(specNames(nextNormalBridge.specs)).toEqual(registeredToolNames);
|
||||
});
|
||||
|
||||
it("keeps message in the registered schema when disabled for an internal turn", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.disableMessageTool = true;
|
||||
params.sourceReplyDeliveryMode = "message_tool_only";
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
|
||||
const availableTools: RuntimeDynamicToolForTest[] = [];
|
||||
const registeredTools = [createRuntimeDynamicTool("message")];
|
||||
const bridge = createCodexToolBridgeForTest(params, availableTools, registeredTools);
|
||||
const normalParams = createParams(sessionFile, workspaceDir);
|
||||
normalParams.disableTools = false;
|
||||
normalParams.sourceReplyDeliveryMode = "message_tool_only";
|
||||
normalParams.runtimePlan = createCodexRuntimePlanFixture();
|
||||
const normalTools = [createRuntimeDynamicTool("message")];
|
||||
const normalRegisteredTools = [createRuntimeDynamicTool("message")];
|
||||
const normalBridge = createCodexToolBridgeForTest(
|
||||
normalParams,
|
||||
normalTools,
|
||||
normalRegisteredTools,
|
||||
);
|
||||
|
||||
expect(bridge.availableSpecs.map((tool) => tool.name)).not.toContain("message");
|
||||
expect(bridge.specs.map((tool) => tool.name)).toContain("message");
|
||||
expect(codexDynamicToolsFingerprint(bridge.specs)).toBe(
|
||||
codexDynamicToolsFingerprint(normalBridge.specs),
|
||||
);
|
||||
await expect(
|
||||
bridge.handleToolCall({
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-1",
|
||||
namespace: null,
|
||||
tool: "message",
|
||||
arguments: {},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
success: false,
|
||||
contentItems: [
|
||||
{
|
||||
type: "inputText",
|
||||
text: "OpenClaw tool is not available for this turn: message",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the persistent dynamic schema stable across heartbeat-only turns", async () => {
|
||||
testing.setOpenClawCodingToolsFactoryForTests((options) => [
|
||||
createRuntimeDynamicTool("message"),
|
||||
|
||||
@@ -778,7 +778,6 @@ export async function runCodexAppServerAttempt(
|
||||
pluginConfig,
|
||||
profilerEnabled,
|
||||
forceHeartbeatTool: true,
|
||||
ignoreDisableMessageTool: true,
|
||||
ignoreRuntimePlan: true,
|
||||
onYieldDetected: () => {
|
||||
yieldDetected = true;
|
||||
@@ -790,7 +789,7 @@ export async function runCodexAppServerAttempt(
|
||||
registeredTools,
|
||||
signal: runAbortController.signal,
|
||||
loading: resolveCodexDynamicToolsLoadingForModel(pluginConfig, params.modelId),
|
||||
directToolNames: resolveCodexDynamicToolDirectNames(params),
|
||||
directToolNames: shouldForceMessageTool(params) ? ["message"] : [],
|
||||
hookContext: {
|
||||
agentId: sessionAgentId,
|
||||
config: params.config,
|
||||
@@ -3187,13 +3186,6 @@ function handleApprovalRequest(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCodexDynamicToolDirectNames(params: EmbeddedRunAttemptParams): string[] {
|
||||
if (params.sourceReplyDeliveryMode !== "message_tool_only") {
|
||||
return [];
|
||||
}
|
||||
return ["message"];
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
buildCodexNativeHookRelayId,
|
||||
buildDeveloperInstructions,
|
||||
@@ -3207,7 +3199,6 @@ export const testing = {
|
||||
resolveOpenClawCodingToolsSessionKeys,
|
||||
shouldEnableCodexAppServerNativeToolSurface,
|
||||
shouldForceMessageTool,
|
||||
resolveCodexDynamicToolDirectNames,
|
||||
hasPendingDynamicToolTerminalDiagnostic,
|
||||
toTranscriptToolResultForTests: toTranscriptToolResult,
|
||||
withCodexStartupTimeout,
|
||||
|
||||
@@ -38,30 +38,11 @@ import { testing } from "./run-attempt.js";
|
||||
import {
|
||||
readCodexAppServerBinding,
|
||||
resolveCodexAppServerBindingPath,
|
||||
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
|
||||
writeCodexAppServerBinding,
|
||||
} from "./session-binding.js";
|
||||
|
||||
setupRunAttemptTestHooks();
|
||||
|
||||
const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
});
|
||||
|
||||
function writeCodexAppServerBinding(
|
||||
...args: Parameters<typeof writeRawCodexAppServerBinding>
|
||||
) {
|
||||
const [sessionFile, binding, lookup] = args;
|
||||
return writeRawCodexAppServerBinding(
|
||||
sessionFile,
|
||||
{
|
||||
webSearchThreadConfigFingerprint: DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT,
|
||||
...binding,
|
||||
},
|
||||
lookup,
|
||||
);
|
||||
}
|
||||
|
||||
const tinyPngBase64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
|
||||
|
||||
|
||||
@@ -60,8 +60,6 @@ describe("codex app-server session binding", () => {
|
||||
cwd: tempDir,
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
networkProxyProfileName: "openclaw-network",
|
||||
networkProxyConfigFingerprint: "network-proxy-v1",
|
||||
dynamicToolsFingerprint: "tools-v1",
|
||||
webSearchThreadConfigFingerprint: "web-search-v1",
|
||||
userMcpServersFingerprint: "user-mcp-v1",
|
||||
@@ -76,8 +74,6 @@ describe("codex app-server session binding", () => {
|
||||
expect(binding?.cwd).toBe(tempDir);
|
||||
expect(binding?.model).toBe("gpt-5.4-codex");
|
||||
expect(binding?.modelProvider).toBe("openai");
|
||||
expect(binding?.networkProxyProfileName).toBe("openclaw-network");
|
||||
expect(binding?.networkProxyConfigFingerprint).toBe("network-proxy-v1");
|
||||
expect(binding?.dynamicToolsFingerprint).toBe("tools-v1");
|
||||
expect(binding?.webSearchThreadConfigFingerprint).toBe("web-search-v1");
|
||||
expect(binding?.userMcpServersFingerprint).toBe("user-mcp-v1");
|
||||
|
||||
@@ -66,8 +66,6 @@ export type CodexAppServerThreadBinding = {
|
||||
approvalPolicy?: CodexAppServerApprovalPolicy;
|
||||
sandbox?: CodexAppServerSandboxMode;
|
||||
serviceTier?: CodexServiceTier;
|
||||
networkProxyProfileName?: string;
|
||||
networkProxyConfigFingerprint?: string;
|
||||
dynamicToolsFingerprint?: string;
|
||||
dynamicToolsContainDeferred?: boolean;
|
||||
webSearchThreadConfigFingerprint?: string;
|
||||
@@ -183,14 +181,6 @@ export async function readCodexAppServerBinding(
|
||||
approvalPolicy: readApprovalPolicy(parsed.approvalPolicy),
|
||||
sandbox: readSandboxMode(parsed.sandbox),
|
||||
serviceTier: readServiceTier(parsed.serviceTier),
|
||||
networkProxyProfileName:
|
||||
typeof parsed.networkProxyProfileName === "string"
|
||||
? parsed.networkProxyProfileName
|
||||
: undefined,
|
||||
networkProxyConfigFingerprint:
|
||||
typeof parsed.networkProxyConfigFingerprint === "string"
|
||||
? parsed.networkProxyConfigFingerprint
|
||||
: undefined,
|
||||
dynamicToolsFingerprint:
|
||||
typeof parsed.dynamicToolsFingerprint === "string"
|
||||
? parsed.dynamicToolsFingerprint
|
||||
@@ -266,8 +256,6 @@ export async function writeCodexAppServerBinding(
|
||||
approvalPolicy: binding.approvalPolicy,
|
||||
sandbox: binding.sandbox,
|
||||
serviceTier: binding.serviceTier,
|
||||
networkProxyProfileName: binding.networkProxyProfileName,
|
||||
networkProxyConfigFingerprint: binding.networkProxyConfigFingerprint,
|
||||
dynamicToolsFingerprint: binding.dynamicToolsFingerprint,
|
||||
dynamicToolsContainDeferred: binding.dynamicToolsContainDeferred,
|
||||
webSearchThreadConfigFingerprint: binding.webSearchThreadConfigFingerprint,
|
||||
|
||||
@@ -1151,53 +1151,6 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
expect(config?.["features.code_mode_only"]).toBe(true);
|
||||
});
|
||||
|
||||
it("applies network-proxy config to side-thread forks", async () => {
|
||||
const client = createFakeClient();
|
||||
getSharedCodexAppServerClientMock.mockResolvedValue(client);
|
||||
|
||||
await expect(
|
||||
runCodexAppServerSideQuestion(sideParams(), {
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
networkProxy: {
|
||||
enabled: true,
|
||||
profileName: "side-proxy",
|
||||
domains: { "api.openai.com": "allow" },
|
||||
unixSockets: { "/tmp/proxy.sock": "allow" },
|
||||
allowUpstreamProxy: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({ text: "Side answer." });
|
||||
|
||||
const forkParams = mockCall(client.request)[1] as Record<string, unknown> | undefined;
|
||||
const config = forkParams?.config as Record<string, unknown> | undefined;
|
||||
expect(forkParams).not.toHaveProperty("sandbox");
|
||||
expect(config).toMatchObject({
|
||||
"features.network_proxy.enabled": true,
|
||||
default_permissions: "side-proxy",
|
||||
permissions: {
|
||||
"side-proxy": {
|
||||
filesystem: {
|
||||
":minimal": "read",
|
||||
":project_roots": { ".": "write" },
|
||||
},
|
||||
network: {
|
||||
enabled: true,
|
||||
domains: { "api.openai.com": "allow" },
|
||||
unix_sockets: { "/tmp/proxy.sock": "allow" },
|
||||
allow_upstream_proxy: true,
|
||||
proxy_url: "http://127.0.0.1:3128",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(config?.["features.code_mode"]).toBe(true);
|
||||
expect(config?.["features.code_mode_only"]).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps Codex code-mode-only while disabling Guardian for provider-qualified local models", async () => {
|
||||
const client = createFakeClient();
|
||||
getSharedCodexAppServerClientMock.mockResolvedValue(client);
|
||||
|
||||
@@ -322,16 +322,12 @@ export async function runCodexAppServerSideQuestion(
|
||||
threadId: childThreadId,
|
||||
turnId,
|
||||
nativeHookRelay,
|
||||
execPolicy,
|
||||
execReviewerAgentId: sessionAgentId,
|
||||
internalExecAutoReview: modelScopedAppServer.approvalsReviewer === "user",
|
||||
autoApprove: shouldAutoApproveCodexAppServerApprovals({
|
||||
approvalPolicy,
|
||||
networkProxy: modelScopedAppServer.networkProxy,
|
||||
sandbox,
|
||||
}),
|
||||
signal: runAbortController.signal,
|
||||
});
|
||||
execPolicy,
|
||||
execReviewerAgentId: sessionAgentId,
|
||||
internalExecAutoReview: modelScopedAppServer.approvalsReviewer === "user",
|
||||
autoApprove: shouldAutoApproveCodexAppServerApprovals({ approvalPolicy, sandbox }),
|
||||
signal: runAbortController.signal,
|
||||
});
|
||||
}
|
||||
if (request.method !== "item/tool/call") {
|
||||
return undefined;
|
||||
@@ -419,12 +415,8 @@ export async function runCodexAppServerSideQuestion(
|
||||
nativeCodeModeEnabled: nativeToolSurfaceEnabled,
|
||||
nativeCodeModeOnlyEnabled: appServer.codeModeOnly,
|
||||
});
|
||||
const threadConfig =
|
||||
mergeCodexThreadConfigs(
|
||||
nativeHookRelayConfig,
|
||||
runtimeThreadConfig,
|
||||
modelScopedAppServer.networkProxy?.configPatch,
|
||||
) ?? runtimeThreadConfig;
|
||||
const threadConfig =
|
||||
mergeCodexThreadConfigs(nativeHookRelayConfig, runtimeThreadConfig) ?? runtimeThreadConfig;
|
||||
const forkResponse = assertCodexThreadForkResponse(
|
||||
await forkCodexSideThread(
|
||||
client,
|
||||
@@ -436,7 +428,7 @@ export async function runCodexAppServerSideQuestion(
|
||||
cwd,
|
||||
approvalPolicy,
|
||||
approvalsReviewer: modelScopedAppServer.approvalsReviewer,
|
||||
...(modelScopedAppServer.networkProxy ? {} : { sandbox }),
|
||||
sandbox,
|
||||
...(serviceTier ? { serviceTier } : {}),
|
||||
config: threadConfig,
|
||||
developerInstructions: SIDE_DEVELOPER_INSTRUCTIONS,
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
readCodexAppServerBinding,
|
||||
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
|
||||
} from "./session-binding.js";
|
||||
import { fingerprintCodexAppServerNetworkProxyConfigPatch } from "./config.js";
|
||||
import { startOrResumeThread } from "./thread-lifecycle.js";
|
||||
|
||||
function createThreadLifecycleAppServerOptions(): Parameters<
|
||||
@@ -34,38 +33,6 @@ function createThreadLifecycleAppServerOptions(): Parameters<
|
||||
};
|
||||
}
|
||||
|
||||
function createNetworkProxyThreadLifecycleAppServerOptions() {
|
||||
const configPatch = {
|
||||
"features.network_proxy.enabled": true,
|
||||
default_permissions: "openclaw-network",
|
||||
permissions: {
|
||||
"openclaw-network": {
|
||||
filesystem: {
|
||||
":minimal": "read",
|
||||
":project_roots": {
|
||||
".": "write",
|
||||
},
|
||||
},
|
||||
network: {
|
||||
enabled: true,
|
||||
domains: {
|
||||
"api.openai.com": "allow",
|
||||
},
|
||||
proxy_url: "http://127.0.0.1:3128",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
return {
|
||||
...createThreadLifecycleAppServerOptions(),
|
||||
networkProxy: {
|
||||
profileName: "openclaw-network",
|
||||
configFingerprint: fingerprintCodexAppServerNetworkProxyConfigPatch(configPatch),
|
||||
configPatch,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createParams(sessionFile: string, workspaceDir: string) {
|
||||
const params = createRunAttemptParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
@@ -1480,42 +1447,6 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
expect(binding?.threadId).toBe("thread-existing");
|
||||
});
|
||||
|
||||
it("starts a new thread when the network proxy config is not active on the binding", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-existing",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
dynamicToolsFingerprint: "[]",
|
||||
});
|
||||
const appServer = createNetworkProxyThreadLifecycleAppServerOptions();
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-network-proxy");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer,
|
||||
});
|
||||
|
||||
const requestCalls = request.mock.calls as unknown as Array<[string, { config?: unknown }]>;
|
||||
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
expect(requestCalls[0]?.[1]).not.toHaveProperty("sandbox");
|
||||
expect(requestCalls[0]?.[1].config).toMatchObject(appServer.networkProxy.configPatch);
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(binding?.threadId).toBe("thread-network-proxy");
|
||||
expect(binding?.networkProxyProfileName).toBe("openclaw-network");
|
||||
expect(binding?.networkProxyConfigFingerprint).toBe(appServer.networkProxy.configFingerprint);
|
||||
});
|
||||
|
||||
it("passes native hook relay config on thread start and resume", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
|
||||
@@ -5,7 +5,6 @@ import path from "node:path";
|
||||
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "../../prompt-overlay.js";
|
||||
import { fingerprintCodexAppServerNetworkProxyConfigPatch } from "./config.js";
|
||||
import { createCodexTestModel } from "./test-support.js";
|
||||
import {
|
||||
buildDeveloperInstructions,
|
||||
@@ -84,39 +83,6 @@ function createAppServerOptions() {
|
||||
approvalPolicy: "on-request",
|
||||
approvalsReviewer: "user",
|
||||
sandbox: "workspace-write",
|
||||
};
|
||||
}
|
||||
|
||||
function createNetworkProxyAppServerOptions() {
|
||||
const configPatch = {
|
||||
"features.network_proxy.enabled": true,
|
||||
default_permissions: "mock-proxy",
|
||||
permissions: {
|
||||
"mock-proxy": {
|
||||
filesystem: {
|
||||
":minimal": "read",
|
||||
":project_roots": {
|
||||
".": "write",
|
||||
},
|
||||
},
|
||||
network: {
|
||||
enabled: true,
|
||||
domains: {
|
||||
"api.openai.com": "allow",
|
||||
},
|
||||
allow_upstream_proxy: true,
|
||||
proxy_url: "http://127.0.0.1:3128",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
return {
|
||||
...createAppServerOptions(),
|
||||
networkProxy: {
|
||||
profileName: "mock-proxy",
|
||||
configFingerprint: fingerprintCodexAppServerNetworkProxyConfigPatch(configPatch),
|
||||
configPatch,
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -457,55 +423,6 @@ describe("Codex app-server native code mode config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("selects the Codex network-proxy permissions profile in thread/start config", () => {
|
||||
const request = buildThreadStartParams(createAttemptParams({ provider: "openai" }), {
|
||||
cwd: "/repo",
|
||||
dynamicTools: [],
|
||||
appServer: createNetworkProxyAppServerOptions() as never,
|
||||
developerInstructions: "test instructions",
|
||||
});
|
||||
|
||||
expect(request).not.toHaveProperty("permissions");
|
||||
expect(request).not.toHaveProperty("sandbox");
|
||||
expect(request.config).toMatchObject({
|
||||
"features.network_proxy.enabled": true,
|
||||
default_permissions: "mock-proxy",
|
||||
permissions: {
|
||||
"mock-proxy": {
|
||||
network: {
|
||||
enabled: true,
|
||||
allow_upstream_proxy: true,
|
||||
proxy_url: "http://127.0.0.1:3128",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("selects the Codex network-proxy permissions profile in thread/resume config", () => {
|
||||
const request = buildThreadResumeParams(createAttemptParams({ provider: "openai" }), {
|
||||
threadId: "thread-1",
|
||||
appServer: createNetworkProxyAppServerOptions() as never,
|
||||
developerInstructions: "test instructions",
|
||||
});
|
||||
|
||||
expect(request).not.toHaveProperty("permissions");
|
||||
expect(request).not.toHaveProperty("sandbox");
|
||||
expect(request.config).toMatchObject({
|
||||
"features.network_proxy.enabled": true,
|
||||
default_permissions: "mock-proxy",
|
||||
permissions: {
|
||||
"mock-proxy": {
|
||||
network: {
|
||||
domains: {
|
||||
"api.openai.com": "allow",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("disables Codex tool-search features for nano models", () => {
|
||||
const request = buildThreadStartParams(
|
||||
createAttemptParams({ provider: "openai", modelId: "gpt-5.4-nano" }),
|
||||
@@ -724,35 +641,6 @@ describe("Codex app-server turn input image sanitizing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses Codex permissions for network-proxy turn/start requests", () => {
|
||||
const request = buildTurnStartParams(createAttemptParams({ provider: "openai" }), {
|
||||
threadId: "thread-1",
|
||||
cwd: "/repo",
|
||||
appServer: createNetworkProxyAppServerOptions() as never,
|
||||
});
|
||||
|
||||
expect(request).not.toHaveProperty("permissions");
|
||||
expect(request).not.toHaveProperty("sandboxPolicy");
|
||||
});
|
||||
|
||||
it("keeps explicit sandbox policy overrides ahead of network-proxy turn permissions", () => {
|
||||
const request = buildTurnStartParams(createAttemptParams({ provider: "openai" }), {
|
||||
threadId: "thread-1",
|
||||
cwd: "/repo",
|
||||
appServer: createNetworkProxyAppServerOptions() as never,
|
||||
sandboxPolicy: {
|
||||
type: "externalSandbox",
|
||||
networkAccess: "enabled",
|
||||
},
|
||||
});
|
||||
|
||||
expect(request).not.toHaveProperty("permissions");
|
||||
expect(request.sandboxPolicy).toEqual({
|
||||
type: "externalSandbox",
|
||||
networkAccess: "enabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches turn-scoped developer instructions without changing thread config", () => {
|
||||
const request = buildTurnStartParams(createAttemptParams({ provider: "openai" }), {
|
||||
threadId: "thread-1",
|
||||
|
||||
@@ -338,7 +338,6 @@ export async function startOrResumeThread(params: {
|
||||
}),
|
||||
);
|
||||
const webSearchThreadConfigFingerprint = fingerprintJsonObject(webSearchPlan.threadConfig);
|
||||
const networkProxyConfigFingerprint = params.appServer.networkProxy?.configFingerprint;
|
||||
const contextEngineBinding = lifecycleTiming.measureSync("context-engine-binding", () =>
|
||||
buildContextEngineBinding(params.params, params.contextEngineProjection),
|
||||
);
|
||||
@@ -396,39 +395,6 @@ export async function startOrResumeThread(params: {
|
||||
binding.webSearchThreadConfigFingerprint !== webSearchThreadConfigFingerprint;
|
||||
const persistentWebSearchRestriction =
|
||||
params.webSearchAllowed === false && params.persistentWebSearchAllowed === false;
|
||||
const transientNativeToolRestriction =
|
||||
params.nativeCodeModeEnabled === false && !persistentWebSearchRestriction;
|
||||
const transientWebSearchRestriction = isTransientWebSearchRestriction(params);
|
||||
const explicitTransientWebSearchRestriction =
|
||||
params.webSearchAllowed === false &&
|
||||
params.persistentWebSearchAllowed !== false &&
|
||||
transientWebSearchRestriction;
|
||||
const unknownProviderWebSearchSupport = params.nativeProviderWebSearchSupport === "unknown";
|
||||
if (
|
||||
binding?.threadId &&
|
||||
params.mcpServersFingerprintEvaluated === true &&
|
||||
binding.mcpServersFingerprint !== params.mcpServersFingerprint
|
||||
) {
|
||||
if (
|
||||
transientNativeToolRestriction ||
|
||||
(webSearchBindingChanged &&
|
||||
(explicitTransientWebSearchRestriction || unknownProviderWebSearchSupport))
|
||||
) {
|
||||
embeddedAgentLog.debug(
|
||||
"codex app-server MCP config changed during transient restricted turn; starting transient thread",
|
||||
{
|
||||
threadId: binding.threadId,
|
||||
},
|
||||
);
|
||||
preserveExistingBinding = true;
|
||||
} else {
|
||||
embeddedAgentLog.debug("codex app-server MCP config changed; starting a new thread", {
|
||||
threadId: binding.threadId,
|
||||
});
|
||||
await clearCodexAppServerBinding(params.params.sessionFile);
|
||||
}
|
||||
binding = undefined;
|
||||
}
|
||||
// A transient native-tool restriction must not replace a legacy binding just
|
||||
// because that binding predates search fingerprints. Explicit persistent
|
||||
// search denial still rotates first so the restricted thread can persist.
|
||||
@@ -441,6 +407,7 @@ export async function startOrResumeThread(params: {
|
||||
webSearchBindingChanged &&
|
||||
!deferLegacyWebSearchRotationToTransientNativeSurface
|
||||
) {
|
||||
const transientWebSearchRestriction = isTransientWebSearchRestriction(params);
|
||||
if (transientWebSearchRestriction) {
|
||||
embeddedAgentLog.debug(
|
||||
"codex app-server web search restricted for turn; starting transient thread",
|
||||
@@ -459,7 +426,11 @@ export async function startOrResumeThread(params: {
|
||||
}
|
||||
binding = undefined;
|
||||
}
|
||||
if (binding?.threadId && transientNativeToolRestriction) {
|
||||
if (
|
||||
binding?.threadId &&
|
||||
params.nativeCodeModeEnabled === false &&
|
||||
!persistentWebSearchRestriction
|
||||
) {
|
||||
embeddedAgentLog.debug(
|
||||
"codex app-server native tool surface disabled for turn; starting transient thread",
|
||||
{
|
||||
@@ -515,10 +486,10 @@ export async function startOrResumeThread(params: {
|
||||
}
|
||||
if (
|
||||
binding?.threadId &&
|
||||
(binding.networkProxyConfigFingerprint !== networkProxyConfigFingerprint ||
|
||||
binding.networkProxyProfileName !== params.appServer.networkProxy?.profileName)
|
||||
params.mcpServersFingerprintEvaluated === true &&
|
||||
binding.mcpServersFingerprint !== params.mcpServersFingerprint
|
||||
) {
|
||||
embeddedAgentLog.debug("codex app-server network proxy config changed; starting a new thread", {
|
||||
embeddedAgentLog.debug("codex app-server MCP config changed; starting a new thread", {
|
||||
threadId: binding.threadId,
|
||||
});
|
||||
await clearCodexAppServerBinding(params.params.sessionFile);
|
||||
@@ -560,6 +531,17 @@ export async function startOrResumeThread(params: {
|
||||
binding = undefined;
|
||||
}
|
||||
}
|
||||
if (
|
||||
binding?.threadId &&
|
||||
params.mcpServersFingerprintEvaluated === true &&
|
||||
binding.mcpServersFingerprint !== params.mcpServersFingerprint
|
||||
) {
|
||||
embeddedAgentLog.debug("codex app-server MCP config changed; starting a new thread", {
|
||||
threadId: binding.threadId,
|
||||
});
|
||||
await clearCodexAppServerBinding(params.params.sessionFile);
|
||||
binding = undefined;
|
||||
}
|
||||
if (binding?.threadId) {
|
||||
if (
|
||||
binding.dynamicToolsFingerprint &&
|
||||
@@ -608,12 +590,11 @@ export async function startOrResumeThread(params: {
|
||||
await clearCodexAppServerBinding(params.params.sessionFile);
|
||||
}
|
||||
} else {
|
||||
const resumeBinding = binding;
|
||||
try {
|
||||
const authProfileId = params.params.authProfileId ?? resumeBinding.authProfileId;
|
||||
const authProfileId = params.params.authProfileId ?? binding.authProfileId;
|
||||
const finalConfigPatch = params.buildFinalConfigPatch?.({
|
||||
action: "resume",
|
||||
binding: resumeBinding,
|
||||
binding,
|
||||
}) ?? {
|
||||
configPatch: params.finalConfigPatch,
|
||||
nativeHookRelayGeneration: params.nativeHookRelayGeneration,
|
||||
@@ -625,7 +606,7 @@ export async function startOrResumeThread(params: {
|
||||
);
|
||||
const resumeParams = lifecycleTiming.measureSync("thread-resume-params", () =>
|
||||
buildThreadResumeParams(params.params, {
|
||||
threadId: resumeBinding.threadId,
|
||||
threadId: binding.threadId,
|
||||
authProfileId,
|
||||
model: startModelSelection.model,
|
||||
modelProvider: startModelProvider,
|
||||
@@ -653,7 +634,7 @@ export async function startOrResumeThread(params: {
|
||||
const nextMcpServersFingerprint =
|
||||
params.mcpServersFingerprintEvaluated === true
|
||||
? params.mcpServersFingerprint
|
||||
: resumeBinding.mcpServersFingerprint;
|
||||
: binding.mcpServersFingerprint;
|
||||
await lifecycleTiming.measure("thread-resume-write-binding", () =>
|
||||
writeCodexAppServerBinding(
|
||||
params.params.sessionFile,
|
||||
@@ -668,17 +649,14 @@ export async function startOrResumeThread(params: {
|
||||
webSearchThreadConfigFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
networkProxyProfileName: params.appServer.networkProxy?.profileName,
|
||||
networkProxyConfigFingerprint,
|
||||
nativeHookRelayGeneration:
|
||||
finalConfigPatch.nativeHookRelayGeneration ??
|
||||
resumeBinding.nativeHookRelayGeneration,
|
||||
pluginAppsFingerprint: resumeBinding.pluginAppsFingerprint,
|
||||
pluginAppsInputFingerprint: resumeBinding.pluginAppsInputFingerprint,
|
||||
pluginAppPolicyContext: resumeBinding.pluginAppPolicyContext,
|
||||
finalConfigPatch.nativeHookRelayGeneration ?? binding.nativeHookRelayGeneration,
|
||||
pluginAppsFingerprint: binding.pluginAppsFingerprint,
|
||||
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
|
||||
pluginAppPolicyContext: binding.pluginAppPolicyContext,
|
||||
contextEngine: contextEngineBinding,
|
||||
environmentSelectionFingerprint,
|
||||
createdAt: resumeBinding.createdAt,
|
||||
createdAt: binding.createdAt,
|
||||
},
|
||||
{
|
||||
authProfileStore: params.params.authProfileStore,
|
||||
@@ -708,7 +686,7 @@ export async function startOrResumeThread(params: {
|
||||
});
|
||||
const activeTurnIds = readActiveCodexTurnIds(response.thread);
|
||||
return {
|
||||
...resumeBinding,
|
||||
...binding,
|
||||
threadId: response.thread.id,
|
||||
cwd: params.cwd,
|
||||
authProfileId: boundAuthProfileId,
|
||||
@@ -719,13 +697,11 @@ export async function startOrResumeThread(params: {
|
||||
webSearchThreadConfigFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
networkProxyProfileName: params.appServer.networkProxy?.profileName,
|
||||
networkProxyConfigFingerprint,
|
||||
nativeHookRelayGeneration:
|
||||
finalConfigPatch.nativeHookRelayGeneration ?? resumeBinding.nativeHookRelayGeneration,
|
||||
pluginAppsFingerprint: resumeBinding.pluginAppsFingerprint,
|
||||
pluginAppsInputFingerprint: resumeBinding.pluginAppsInputFingerprint,
|
||||
pluginAppPolicyContext: resumeBinding.pluginAppPolicyContext,
|
||||
finalConfigPatch.nativeHookRelayGeneration ?? binding.nativeHookRelayGeneration,
|
||||
pluginAppsFingerprint: binding.pluginAppsFingerprint,
|
||||
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
|
||||
pluginAppPolicyContext: binding.pluginAppPolicyContext,
|
||||
contextEngine: contextEngineBinding,
|
||||
environmentSelectionFingerprint,
|
||||
lifecycle: {
|
||||
@@ -821,8 +797,6 @@ export async function startOrResumeThread(params: {
|
||||
webSearchThreadConfigFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
networkProxyProfileName: params.appServer.networkProxy?.profileName,
|
||||
networkProxyConfigFingerprint,
|
||||
nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration,
|
||||
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
|
||||
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
|
||||
@@ -871,8 +845,6 @@ export async function startOrResumeThread(params: {
|
||||
dynamicToolsContainDeferred,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
networkProxyProfileName: params.appServer.networkProxy?.profileName,
|
||||
networkProxyConfigFingerprint,
|
||||
nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration,
|
||||
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
|
||||
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
|
||||
@@ -1082,7 +1054,7 @@ export function buildThreadStartParams(
|
||||
cwd: options.cwd,
|
||||
approvalPolicy: options.appServer.approvalPolicy,
|
||||
approvalsReviewer: options.appServer.approvalsReviewer,
|
||||
...codexThreadSandboxOrPermissions(options.appServer),
|
||||
sandbox: options.appServer.sandbox,
|
||||
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
serviceName: "OpenClaw",
|
||||
@@ -1091,7 +1063,6 @@ export function buildThreadStartParams(
|
||||
nativeProviderWebSearchSupport: options.nativeProviderWebSearchSupport,
|
||||
nativeCodeModeOnlyEnabled: options.nativeCodeModeOnlyEnabled,
|
||||
webSearchAllowed: options.webSearchAllowed,
|
||||
appServer: options.appServer,
|
||||
}),
|
||||
...resolveCodexThreadEnvironmentSelection(options),
|
||||
developerInstructions:
|
||||
@@ -1162,7 +1133,7 @@ export function buildThreadResumeParams(
|
||||
...(modelSelection.modelProvider ? { modelProvider: modelSelection.modelProvider } : {}),
|
||||
approvalPolicy: options.appServer.approvalPolicy,
|
||||
approvalsReviewer: options.appServer.approvalsReviewer,
|
||||
...codexThreadSandboxOrPermissions(options.appServer),
|
||||
sandbox: options.appServer.sandbox,
|
||||
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
config: buildCodexRuntimeThreadConfigForRun(params, options.config, {
|
||||
@@ -1170,7 +1141,6 @@ export function buildThreadResumeParams(
|
||||
nativeProviderWebSearchSupport: options.nativeProviderWebSearchSupport,
|
||||
nativeCodeModeOnlyEnabled: options.nativeCodeModeOnlyEnabled,
|
||||
webSearchAllowed: options.webSearchAllowed,
|
||||
appServer: options.appServer,
|
||||
}),
|
||||
developerInstructions:
|
||||
options.developerInstructions ??
|
||||
@@ -1324,7 +1294,6 @@ function buildCodexRuntimeThreadConfigForRun(
|
||||
nativeProviderWebSearchSupport?: CodexNativeWebSearchSupport;
|
||||
nativeCodeModeOnlyEnabled?: boolean;
|
||||
webSearchAllowed?: boolean;
|
||||
appServer?: Pick<CodexAppServerRuntimeOptions, "networkProxy">;
|
||||
} = {},
|
||||
): JsonObject {
|
||||
const webSearchConfig = resolveCodexWebSearchPlan({
|
||||
@@ -1341,7 +1310,6 @@ function buildCodexRuntimeThreadConfigForRun(
|
||||
const runtimeConfig =
|
||||
mergeCodexThreadConfigs(
|
||||
baseConfig,
|
||||
options.appServer?.networkProxy?.configPatch,
|
||||
shouldDisableCodexToolSearchForModel(params.modelId)
|
||||
? CODEX_TOOL_SEARCH_UNSUPPORTED_THREAD_CONFIG
|
||||
: undefined,
|
||||
@@ -1382,20 +1350,14 @@ export function buildTurnStartParams(
|
||||
agentDir: params.agentDir,
|
||||
config: params.config,
|
||||
});
|
||||
const useThreadPermissionProfile = options.appServer.networkProxy && !options.sandboxPolicy;
|
||||
return {
|
||||
threadId: options.threadId,
|
||||
input: buildUserInput(params, options.promptText),
|
||||
cwd: options.cwd,
|
||||
approvalPolicy: options.appServer.approvalPolicy,
|
||||
approvalsReviewer: options.appServer.approvalsReviewer,
|
||||
...(useThreadPermissionProfile
|
||||
? {}
|
||||
: {
|
||||
sandboxPolicy:
|
||||
options.sandboxPolicy ??
|
||||
codexSandboxPolicyForTurn(options.appServer.sandbox, options.cwd),
|
||||
}),
|
||||
sandboxPolicy:
|
||||
options.sandboxPolicy ?? codexSandboxPolicyForTurn(options.appServer.sandbox, options.cwd),
|
||||
model: modelSelection.model,
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
|
||||
@@ -1411,15 +1373,6 @@ export function buildTurnStartParams(
|
||||
};
|
||||
}
|
||||
|
||||
function codexThreadSandboxOrPermissions(
|
||||
appServer: Pick<CodexAppServerRuntimeOptions, "networkProxy" | "sandbox">,
|
||||
): Pick<CodexThreadStartParams, "sandbox"> {
|
||||
if (appServer.networkProxy) {
|
||||
return {};
|
||||
}
|
||||
return { sandbox: appServer.sandbox };
|
||||
}
|
||||
|
||||
function resolveCodexThreadEnvironmentSelection(options: {
|
||||
nativeCodeModeEnabled?: boolean;
|
||||
environmentSelection?: CodexTurnEnvironmentParams[];
|
||||
|
||||
@@ -300,7 +300,6 @@ describe("startOrResumeThread — user mcp.servers projection (regression: #8081
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
dynamicToolsFingerprint: "[]",
|
||||
});
|
||||
const request = vi.fn(async (method: string, _params: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
@@ -339,87 +338,6 @@ describe("startOrResumeThread — user mcp.servers projection (regression: #8081
|
||||
expect(preservedBinding?.threadId).toBe("thread-native");
|
||||
});
|
||||
|
||||
it("preserves MCP-mismatched bindings across transient native-tool-disabled turns", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-native",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
dynamicToolsFingerprint: "[]",
|
||||
mcpServersFingerprint: "mcp-v1",
|
||||
});
|
||||
const request = vi.fn(async (method: string, _params: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-restricted");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createAppServerOptions(),
|
||||
mcpServersFingerprint: undefined,
|
||||
mcpServersFingerprintEvaluated: true,
|
||||
nativeCodeModeEnabled: false,
|
||||
userMcpServersEnabled: false,
|
||||
});
|
||||
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
const startParams = request.mock.calls[0]?.[1] as {
|
||||
config?: {
|
||||
"features.code_mode"?: boolean;
|
||||
mcp_servers?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
expect(startParams?.config?.["features.code_mode"]).toBe(false);
|
||||
expect(startParams?.config?.mcp_servers).toBeUndefined();
|
||||
const preservedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(preservedBinding?.threadId).toBe("thread-native");
|
||||
expect(preservedBinding?.mcpServersFingerprint).toBe("mcp-v1");
|
||||
});
|
||||
|
||||
it("preserves MCP-mismatched bindings when provider web-search support is unknown", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-native",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
dynamicToolsFingerprint: "[]",
|
||||
webSearchThreadConfigFingerprint: "web-search-v1",
|
||||
mcpServersFingerprint: "mcp-v1",
|
||||
});
|
||||
const request = vi.fn(async (method: string, _params: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-fallback");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createAppServerOptions(),
|
||||
mcpServersFingerprint: undefined,
|
||||
mcpServersFingerprintEvaluated: true,
|
||||
nativeProviderWebSearchSupport: "unknown",
|
||||
userMcpServersEnabled: false,
|
||||
});
|
||||
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
const preservedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(preservedBinding?.threadId).toBe("thread-native");
|
||||
expect(preservedBinding?.mcpServersFingerprint).toBe("mcp-v1");
|
||||
});
|
||||
|
||||
it("starts a new thread without user MCP servers when runtime policy disables them", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
|
||||
@@ -74,62 +74,9 @@ import {
|
||||
handleCodexConversationInboundClaim,
|
||||
startCodexConversationThread,
|
||||
} from "./conversation-binding.js";
|
||||
import { resolveCodexAppServerRuntimeOptions } from "./app-server/config.js";
|
||||
|
||||
let tempDir: string;
|
||||
|
||||
const NETWORK_PROXY_PLUGIN_CONFIG = {
|
||||
appServer: {
|
||||
networkProxy: {
|
||||
enabled: true,
|
||||
domains: { "api.openai.com": "allow" },
|
||||
allowUpstreamProxy: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
},
|
||||
},
|
||||
};
|
||||
const NETWORK_PROXY_RUNTIME = resolveCodexAppServerRuntimeOptions({
|
||||
env: {},
|
||||
requirementsToml: null,
|
||||
pluginConfig: NETWORK_PROXY_PLUGIN_CONFIG,
|
||||
});
|
||||
const NETWORK_PROXY_PROFILE_NAME = NETWORK_PROXY_RUNTIME.networkProxy?.profileName ?? "missing";
|
||||
const NETWORK_PROXY_CONFIG_PATCH = NETWORK_PROXY_RUNTIME.networkProxy?.configPatch ?? {};
|
||||
const NETWORK_PROXY_CONFIG_FINGERPRINT =
|
||||
NETWORK_PROXY_RUNTIME.networkProxy?.configFingerprint ?? "missing";
|
||||
|
||||
function conversationThreadStartResult(threadId: string) {
|
||||
return {
|
||||
approvalPolicy: "never",
|
||||
approvalsReviewer: "user",
|
||||
cwd: tempDir,
|
||||
model: "gpt-5.4-mini",
|
||||
modelProvider: "openai",
|
||||
sandbox: { type: "workspaceWrite", networkAccess: false },
|
||||
serviceTier: null,
|
||||
activePermissionProfile: null,
|
||||
thread: {
|
||||
id: threadId,
|
||||
sessionId: "session-1",
|
||||
preview: "",
|
||||
ephemeral: false,
|
||||
modelProvider: "openai",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
status: { type: "idle" },
|
||||
path: null,
|
||||
cwd: tempDir,
|
||||
cliVersion: "0.125.0",
|
||||
source: "unknown",
|
||||
agentNickname: null,
|
||||
agentRole: null,
|
||||
gitInfo: null,
|
||||
name: null,
|
||||
turns: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mockCallArg(mock: ReturnType<typeof vi.fn>, callIndex = 0, argIndex = 0): unknown {
|
||||
const call = mock.mock.calls[callIndex];
|
||||
if (!call) {
|
||||
@@ -233,70 +180,6 @@ describe("codex conversation binding", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("selects Codex network-proxy permissions through app-server bind thread config", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const requests: Array<{ method: string; params: Record<string, unknown> }> = [];
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
|
||||
request: vi.fn(async (method: string, requestParams: Record<string, unknown>) => {
|
||||
requests.push({ method, params: requestParams });
|
||||
return {
|
||||
thread: { id: "thread-new", sessionId: "session-1", cwd: tempDir },
|
||||
model: "gpt-5.4-mini",
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
await startCodexConversationThread({
|
||||
pluginConfig: NETWORK_PROXY_PLUGIN_CONFIG,
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
model: "gpt-5.4-mini",
|
||||
modelProvider: "openai",
|
||||
});
|
||||
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0]?.method).toBe("thread/start");
|
||||
expect(requests[0]?.params).not.toHaveProperty("permissions");
|
||||
expect(requests[0]?.params).not.toHaveProperty("sandbox");
|
||||
expect(requests[0]?.params.config).toMatchObject(NETWORK_PROXY_CONFIG_PATCH);
|
||||
});
|
||||
|
||||
it("starts a fresh proxy-backed thread when binding an explicit app-server thread id", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const requests: Array<{ method: string; params: Record<string, unknown> }> = [];
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
|
||||
request: vi.fn(async (method: string, requestParams: Record<string, unknown>) => {
|
||||
requests.push({ method, params: requestParams });
|
||||
if (method === "thread/resume") {
|
||||
throw new Error("thread/resume should not receive network proxy config");
|
||||
}
|
||||
return conversationThreadStartResult("thread-new");
|
||||
}),
|
||||
});
|
||||
|
||||
await startCodexConversationThread({
|
||||
pluginConfig: NETWORK_PROXY_PLUGIN_CONFIG,
|
||||
sessionFile,
|
||||
threadId: "thread-old",
|
||||
workspaceDir: tempDir,
|
||||
model: "gpt-5.4-mini",
|
||||
modelProvider: "openai",
|
||||
});
|
||||
|
||||
expect(requests.map((request) => request.method)).toEqual(["thread/start"]);
|
||||
expect(requests[0]?.params).not.toHaveProperty("threadId");
|
||||
expect(requests[0]?.params).not.toHaveProperty("sandbox");
|
||||
expect(requests[0]?.params.config).toMatchObject(NETWORK_PROXY_CONFIG_PATCH);
|
||||
const bindingAfterStart = JSON.parse(
|
||||
await fs.readFile(`${sessionFile}.codex-app-server.json`, "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
expect(bindingAfterStart.threadId).toBe("thread-new");
|
||||
expect(bindingAfterStart.networkProxyProfileName).toBe(NETWORK_PROXY_PROFILE_NAME);
|
||||
expect(bindingAfterStart.networkProxyConfigFingerprint).toBe(
|
||||
NETWORK_PROXY_CONFIG_FINGERPRINT,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves Codex auth and omits the public OpenAI provider for native bind threads", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
agentRuntimeMocks.ensureAuthProfileStore.mockReturnValue({
|
||||
@@ -1054,7 +937,7 @@ describe("codex conversation binding", () => {
|
||||
await fs.writeFile(
|
||||
`${sessionFile}.codex-app-server.json`,
|
||||
JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
schemaVersion: 1,
|
||||
threadId: "thread-1",
|
||||
cwd: tempDir,
|
||||
approvalPolicy: "never",
|
||||
@@ -1320,196 +1203,6 @@ describe("codex conversation binding", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps network-proxy bound app-server turns on their thread permissions profile", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
await fs.writeFile(
|
||||
`${sessionFile}.codex-app-server.json`,
|
||||
JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
threadId: "thread-1",
|
||||
cwd: tempDir,
|
||||
networkProxyProfileName: NETWORK_PROXY_PROFILE_NAME,
|
||||
networkProxyConfigFingerprint: NETWORK_PROXY_CONFIG_FINGERPRINT,
|
||||
}),
|
||||
);
|
||||
let notificationHandler: ((notification: unknown) => void) | undefined;
|
||||
const turnStartParams: Record<string, unknown>[] = [];
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
|
||||
request: vi.fn(async (method: string, requestParams: Record<string, unknown>) => {
|
||||
if (method === "turn/start") {
|
||||
turnStartParams.push(requestParams);
|
||||
setImmediate(() =>
|
||||
notificationHandler?.({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turn: {
|
||||
id: "turn-1",
|
||||
status: "completed",
|
||||
items: [{ type: "agentMessage", id: "item-1", text: "done" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return { turn: { id: "turn-1" } };
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
}),
|
||||
addNotificationHandler: vi.fn((handler: (notification: unknown) => void) => {
|
||||
notificationHandler = handler;
|
||||
return () => undefined;
|
||||
}),
|
||||
addRequestHandler: vi.fn(() => () => undefined),
|
||||
});
|
||||
|
||||
const result = await handleCodexConversationInboundClaim(
|
||||
{
|
||||
content: "hello",
|
||||
channel: "telegram",
|
||||
isGroup: false,
|
||||
commandAuthorized: true,
|
||||
},
|
||||
{
|
||||
channelId: "telegram",
|
||||
pluginBinding: {
|
||||
bindingId: "binding-1",
|
||||
pluginId: "codex",
|
||||
pluginRoot: tempDir,
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "5185575566",
|
||||
boundAt: Date.now(),
|
||||
data: {
|
||||
kind: "codex-app-server-session",
|
||||
version: 1,
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
networkProxy: {
|
||||
enabled: true,
|
||||
domains: { "api.openai.com": "allow" },
|
||||
allowUpstreamProxy: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
},
|
||||
},
|
||||
},
|
||||
timeoutMs: 50,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ handled: true, reply: { text: "done" } });
|
||||
expect(turnStartParams[0]).not.toHaveProperty("permissions");
|
||||
expect(turnStartParams[0]).not.toHaveProperty("sandboxPolicy");
|
||||
});
|
||||
|
||||
it("refreshes stale network-proxy bound app-server threads before the turn", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
await fs.writeFile(
|
||||
`${sessionFile}.codex-app-server.json`,
|
||||
JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
threadId: "thread-old",
|
||||
cwd: tempDir,
|
||||
networkProxyProfileName: "openclaw-network-stale",
|
||||
networkProxyConfigFingerprint: "stale-proxy-config",
|
||||
}),
|
||||
);
|
||||
let notificationHandler: ((notification: unknown) => void) | undefined;
|
||||
const requests: Array<{ method: string; params: Record<string, unknown> }> = [];
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
|
||||
request: vi.fn(async (method: string, requestParams: Record<string, unknown>) => {
|
||||
requests.push({ method, params: requestParams });
|
||||
if (method === "thread/start") {
|
||||
return conversationThreadStartResult("thread-new");
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
setImmediate(() =>
|
||||
notificationHandler?.({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-new",
|
||||
turn: {
|
||||
id: "turn-1",
|
||||
status: "completed",
|
||||
items: [{ type: "agentMessage", id: "item-1", text: "done" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return { turn: { id: "turn-1" } };
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
}),
|
||||
addNotificationHandler: vi.fn((handler: (notification: unknown) => void) => {
|
||||
notificationHandler = handler;
|
||||
return () => undefined;
|
||||
}),
|
||||
addRequestHandler: vi.fn(() => () => undefined),
|
||||
});
|
||||
|
||||
const result = await handleCodexConversationInboundClaim(
|
||||
{
|
||||
content: "hello",
|
||||
channel: "telegram",
|
||||
isGroup: false,
|
||||
commandAuthorized: true,
|
||||
},
|
||||
{
|
||||
channelId: "telegram",
|
||||
pluginBinding: {
|
||||
bindingId: "binding-1",
|
||||
pluginId: "codex",
|
||||
pluginRoot: tempDir,
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "5185575566",
|
||||
boundAt: Date.now(),
|
||||
data: {
|
||||
kind: "codex-app-server-session",
|
||||
version: 1,
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
serviceTier: "priority",
|
||||
networkProxy: {
|
||||
enabled: true,
|
||||
domains: { "api.openai.com": "allow" },
|
||||
allowUpstreamProxy: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
},
|
||||
},
|
||||
},
|
||||
timeoutMs: 50,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ handled: true, reply: { text: "done" } });
|
||||
expect(requests.map((request) => request.method)).toEqual(["thread/start", "turn/start"]);
|
||||
expect(requests[0]?.params.config).toMatchObject(NETWORK_PROXY_CONFIG_PATCH);
|
||||
expect(requests[0]?.params).not.toHaveProperty("sandbox");
|
||||
expect(requests[0]?.params.serviceTier).toBe("priority");
|
||||
expect(requests[1]?.params.threadId).toBe("thread-new");
|
||||
expect(requests[1]?.params).not.toHaveProperty("sandboxPolicy");
|
||||
const bindingAfterRefresh = JSON.parse(
|
||||
await fs.readFile(`${sessionFile}.codex-app-server.json`, "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
expect(bindingAfterRefresh.threadId).toBe("thread-new");
|
||||
expect(bindingAfterRefresh.networkProxyProfileName).toBe(NETWORK_PROXY_PROFILE_NAME);
|
||||
expect(bindingAfterRefresh.networkProxyConfigFingerprint).toBe(
|
||||
NETWORK_PROXY_CONFIG_FINGERPRINT,
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks Guardian-mode bound turns with stale no-approval policy on custom model providers", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
await fs.writeFile(
|
||||
|
||||
@@ -33,7 +33,6 @@ import type {
|
||||
CodexThreadResumeResponse,
|
||||
CodexThreadStartResponse,
|
||||
CodexTurnStartResponse,
|
||||
JsonObject,
|
||||
JsonValue,
|
||||
} from "./app-server/protocol.js";
|
||||
import {
|
||||
@@ -52,7 +51,6 @@ import {
|
||||
getLeasedSharedCodexAppServerClient,
|
||||
releaseLeasedSharedCodexAppServerClient,
|
||||
} from "./app-server/shared-client.js";
|
||||
import { assertCodexThreadStartResponse } from "./app-server/protocol-validators.js";
|
||||
import {
|
||||
CODEX_NATIVE_PERSONALITY_NONE,
|
||||
resolveCodexAppServerRequestModelSelection,
|
||||
@@ -158,8 +156,6 @@ async function resolveConversationAppServerRuntime(params: {
|
||||
}
|
||||
|
||||
const CODEX_CONVERSATION_GLOBAL_STATE = Symbol.for("openclaw.codex.conversationBinding");
|
||||
const CODEX_CONVERSATION_THREAD_DEVELOPER_INSTRUCTIONS =
|
||||
"This Codex thread is bound to an OpenClaw conversation. Answer normally; OpenClaw will deliver your final response back to the conversation.";
|
||||
|
||||
function getGlobalState(): CodexConversationGlobalState {
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
@@ -419,60 +415,22 @@ function buildThreadRequestRuntimeOptions(
|
||||
): {
|
||||
approvalPolicy: ConversationAppServerRuntime["runtime"]["approvalPolicy"];
|
||||
approvalsReviewer: ConversationAppServerRuntime["runtime"]["approvalsReviewer"];
|
||||
sandbox?: ConversationAppServerRuntime["runtime"]["sandbox"];
|
||||
sandbox: ConversationAppServerRuntime["runtime"]["sandbox"];
|
||||
serviceTier?: CodexServiceTier;
|
||||
config?: JsonObject;
|
||||
} {
|
||||
const serviceTier = params.serviceTier ?? resolved.runtime.serviceTier;
|
||||
const sandbox = resolved.execPolicy?.touched
|
||||
? resolved.runtime.sandbox
|
||||
: (params.sandbox ?? resolved.runtime.sandbox);
|
||||
return {
|
||||
approvalPolicy: resolved.execPolicy?.touched
|
||||
? resolved.runtime.approvalPolicy
|
||||
: (params.approvalPolicy ?? resolved.runtime.approvalPolicy),
|
||||
approvalsReviewer: resolved.runtime.approvalsReviewer,
|
||||
...codexConversationSandboxOrPermissions(resolved.runtime, sandbox),
|
||||
sandbox: resolved.execPolicy?.touched
|
||||
? resolved.runtime.sandbox
|
||||
: (params.sandbox ?? resolved.runtime.sandbox),
|
||||
...(serviceTier ? { serviceTier } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function codexConversationSandboxOrPermissions(
|
||||
runtime: Pick<ConversationAppServerRuntime["runtime"], "networkProxy">,
|
||||
sandbox: ConversationAppServerRuntime["runtime"]["sandbox"],
|
||||
): {
|
||||
sandbox?: ConversationAppServerRuntime["runtime"]["sandbox"];
|
||||
config?: JsonObject;
|
||||
} {
|
||||
const networkProxy = runtime.networkProxy;
|
||||
if (networkProxy) {
|
||||
return {
|
||||
config: networkProxy.configPatch,
|
||||
};
|
||||
}
|
||||
return { sandbox };
|
||||
}
|
||||
|
||||
async function requestNewConversationBindingThread(
|
||||
params: CodexThreadBindingParams,
|
||||
resolved: CodexThreadBindingRuntime,
|
||||
): Promise<CodexThreadStartResponse> {
|
||||
return await resolved.client.request(
|
||||
"thread/start",
|
||||
{
|
||||
cwd: params.workspaceDir,
|
||||
...(resolved.model ? { model: resolved.model } : {}),
|
||||
...(resolved.modelProvider ? { modelProvider: resolved.modelProvider } : {}),
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
...buildThreadRequestRuntimeOptions(params, resolved),
|
||||
developerInstructions: CODEX_CONVERSATION_THREAD_DEVELOPER_INSTRUCTIONS,
|
||||
experimentalRawEvents: true,
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
{ timeoutMs: resolved.runtime.requestTimeoutMs },
|
||||
);
|
||||
}
|
||||
|
||||
async function writeThreadBindingFromResponse(
|
||||
params: CodexThreadBindingParams,
|
||||
resolved: CodexThreadBindingRuntime,
|
||||
@@ -501,8 +459,6 @@ async function writeThreadBindingFromResponse(
|
||||
? resolved.runtime.sandbox
|
||||
: (params.sandbox ?? resolved.runtime.sandbox),
|
||||
serviceTier: params.serviceTier ?? resolved.runtime.serviceTier,
|
||||
networkProxyProfileName: resolved.runtime.networkProxy?.profileName,
|
||||
networkProxyConfigFingerprint: resolved.runtime.networkProxy?.configFingerprint,
|
||||
},
|
||||
{
|
||||
...resolved.agentLookup,
|
||||
@@ -517,23 +473,18 @@ async function attachExistingThread(
|
||||
): Promise<void> {
|
||||
const resolved = await resolveThreadBindingRuntime(params);
|
||||
try {
|
||||
// Codex applies network-proxy permission profiles at thread/start. Resuming
|
||||
// an arbitrary existing thread cannot prove that profile is active.
|
||||
const response: CodexThreadResumeResponse | CodexThreadStartResponse =
|
||||
resolved.runtime.networkProxy
|
||||
? await requestNewConversationBindingThread(params, resolved)
|
||||
: await resolved.client.request(
|
||||
CODEX_CONTROL_METHODS.resumeThread,
|
||||
{
|
||||
threadId: params.threadId,
|
||||
...(resolved.model ? { model: resolved.model } : {}),
|
||||
...(resolved.modelProvider ? { modelProvider: resolved.modelProvider } : {}),
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
...buildThreadRequestRuntimeOptions(params, resolved),
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
{ timeoutMs: resolved.runtime.requestTimeoutMs },
|
||||
);
|
||||
const response: CodexThreadResumeResponse = await resolved.client.request(
|
||||
CODEX_CONTROL_METHODS.resumeThread,
|
||||
{
|
||||
threadId: params.threadId,
|
||||
...(resolved.model ? { model: resolved.model } : {}),
|
||||
...(resolved.modelProvider ? { modelProvider: resolved.modelProvider } : {}),
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
...buildThreadRequestRuntimeOptions(params, resolved),
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
{ timeoutMs: resolved.runtime.requestTimeoutMs },
|
||||
);
|
||||
await writeThreadBindingFromResponse(params, resolved, response);
|
||||
} finally {
|
||||
releaseLeasedSharedCodexAppServerClient(resolved.client);
|
||||
@@ -543,7 +494,21 @@ async function attachExistingThread(
|
||||
async function createThread(params: CodexThreadBindingParams): Promise<void> {
|
||||
const resolved = await resolveThreadBindingRuntime(params);
|
||||
try {
|
||||
const response = await requestNewConversationBindingThread(params, resolved);
|
||||
const response: CodexThreadStartResponse = await resolved.client.request(
|
||||
"thread/start",
|
||||
{
|
||||
cwd: params.workspaceDir,
|
||||
...(resolved.model ? { model: resolved.model } : {}),
|
||||
...(resolved.modelProvider ? { modelProvider: resolved.modelProvider } : {}),
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
...buildThreadRequestRuntimeOptions(params, resolved),
|
||||
developerInstructions:
|
||||
"This Codex thread is bound to an OpenClaw conversation. Answer normally; OpenClaw will deliver your final response back to the conversation.",
|
||||
experimentalRawEvents: true,
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
{ timeoutMs: resolved.runtime.requestTimeoutMs },
|
||||
);
|
||||
await writeThreadBindingFromResponse(params, resolved, response);
|
||||
} finally {
|
||||
releaseLeasedSharedCodexAppServerClient(resolved.client);
|
||||
@@ -561,10 +526,10 @@ async function runBoundTurn(params: {
|
||||
}): Promise<BoundTurnResult> {
|
||||
const agentLookup = buildAgentLookup({ agentDir: params.data.agentDir, config: params.config });
|
||||
const binding = await readCodexAppServerBinding(params.data.sessionFile, agentLookup);
|
||||
if (!binding?.threadId) {
|
||||
const threadId = binding?.threadId;
|
||||
if (!threadId) {
|
||||
throw new Error("bound Codex conversation has no thread binding");
|
||||
}
|
||||
let threadId = binding.threadId;
|
||||
const workspaceDir = binding.cwd || params.data.workspaceDir;
|
||||
const reviewerModelProvider = resolveModelBackedReviewerPolicyProvider({
|
||||
authProfileId: binding.authProfileId,
|
||||
@@ -603,16 +568,6 @@ async function runBoundTurn(params: {
|
||||
const sandbox = useModelScopedPolicy
|
||||
? modelScopedRuntime.sandbox
|
||||
: (binding.sandbox ?? modelScopedRuntime.sandbox);
|
||||
const permissionProfile = modelScopedRuntime.networkProxy?.profileName;
|
||||
const networkProxyConfigFingerprint = modelScopedRuntime.networkProxy?.configFingerprint;
|
||||
const networkProxyBindingChanged =
|
||||
binding.networkProxyProfileName !== permissionProfile ||
|
||||
binding.networkProxyConfigFingerprint !== networkProxyConfigFingerprint;
|
||||
const serviceTier = binding.serviceTier ?? runtime.serviceTier;
|
||||
let useStickyNetworkProfile =
|
||||
permissionProfile !== undefined &&
|
||||
binding.networkProxyProfileName === permissionProfile &&
|
||||
binding.networkProxyConfigFingerprint === networkProxyConfigFingerprint;
|
||||
assertNativeConversationApprovalPolicySupported({
|
||||
execPolicy,
|
||||
approvalPolicy,
|
||||
@@ -634,59 +589,12 @@ async function runBoundTurn(params: {
|
||||
authProfileId: binding.authProfileId,
|
||||
...agentLookup,
|
||||
});
|
||||
let notificationCleanup: () => void = () => undefined;
|
||||
let requestCleanup: () => void = () => undefined;
|
||||
try {
|
||||
if (networkProxyBindingChanged) {
|
||||
const response = assertCodexThreadStartResponse(
|
||||
await client.request(
|
||||
"thread/start",
|
||||
{
|
||||
cwd: workspaceDir,
|
||||
...(modelSelection?.model ? { model: modelSelection.model } : {}),
|
||||
...(modelSelection?.modelProvider ? { modelProvider: modelSelection.modelProvider } : {}),
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
approvalPolicy,
|
||||
approvalsReviewer: modelScopedRuntime.approvalsReviewer,
|
||||
...(modelScopedRuntime.networkProxy
|
||||
? { config: modelScopedRuntime.networkProxy.configPatch }
|
||||
: { sandbox }),
|
||||
...(serviceTier ? { serviceTier } : {}),
|
||||
developerInstructions: CODEX_CONVERSATION_THREAD_DEVELOPER_INSTRUCTIONS,
|
||||
experimentalRawEvents: true,
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
{ timeoutMs: runtime.requestTimeoutMs },
|
||||
),
|
||||
);
|
||||
threadId = response.thread.id;
|
||||
await writeCodexAppServerBinding(
|
||||
params.data.sessionFile,
|
||||
{
|
||||
threadId,
|
||||
cwd: response.thread.cwd ?? workspaceDir,
|
||||
authProfileId: binding.authProfileId,
|
||||
model: response.model ?? modelSelection?.model ?? binding.model,
|
||||
modelProvider: normalizeCodexAppServerBindingModelProvider({
|
||||
authProfileId: binding.authProfileId,
|
||||
modelProvider: response.modelProvider ?? modelSelection?.modelProvider ?? binding.modelProvider,
|
||||
...agentLookup,
|
||||
}),
|
||||
approvalPolicy: typeof approvalPolicy === "string" ? approvalPolicy : undefined,
|
||||
sandbox,
|
||||
serviceTier,
|
||||
networkProxyProfileName: modelScopedRuntime.networkProxy?.profileName,
|
||||
networkProxyConfigFingerprint: modelScopedRuntime.networkProxy?.configFingerprint,
|
||||
},
|
||||
agentLookup,
|
||||
);
|
||||
useStickyNetworkProfile = modelScopedRuntime.networkProxy !== undefined;
|
||||
}
|
||||
const collector = createCodexConversationTurnCollector(threadId);
|
||||
notificationCleanup = client.addNotificationHandler((notification) =>
|
||||
collector.handleNotification(notification),
|
||||
);
|
||||
requestCleanup = client.addRequestHandler(async (request): Promise<JsonValue | undefined> => {
|
||||
const collector = createCodexConversationTurnCollector(threadId);
|
||||
const notificationCleanup = client.addNotificationHandler((notification) =>
|
||||
collector.handleNotification(notification),
|
||||
);
|
||||
const requestCleanup = client.addRequestHandler(
|
||||
async (request): Promise<JsonValue | undefined> => {
|
||||
if (request.method === "item/tool/call") {
|
||||
return {
|
||||
contentItems: [
|
||||
@@ -719,7 +627,9 @@ async function runBoundTurn(params: {
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
},
|
||||
);
|
||||
try {
|
||||
const response: CodexTurnStartResponse = await client.request(
|
||||
"turn/start",
|
||||
{
|
||||
@@ -731,12 +641,12 @@ async function runBoundTurn(params: {
|
||||
cwd: workspaceDir,
|
||||
approvalPolicy,
|
||||
approvalsReviewer: modelScopedRuntime.approvalsReviewer,
|
||||
...(useStickyNetworkProfile
|
||||
? {}
|
||||
: { sandboxPolicy: codexSandboxPolicyForTurn(sandbox, workspaceDir) }),
|
||||
sandboxPolicy: codexSandboxPolicyForTurn(sandbox, workspaceDir),
|
||||
...(modelSelection?.model ? { model: modelSelection.model } : {}),
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
...(serviceTier ? { serviceTier } : {}),
|
||||
...((binding.serviceTier ?? runtime.serviceTier)
|
||||
? { serviceTier: binding.serviceTier ?? runtime.serviceTier }
|
||||
: {}),
|
||||
},
|
||||
{ timeoutMs: runtime.requestTimeoutMs },
|
||||
);
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
||||
import type { Context, Model } from "openclaw/plugin-sdk/llm";
|
||||
import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { buildOpenAICompletionsParams } from "openclaw/plugin-sdk/provider-transport-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
import { buildCohereProvider } from "./provider-catalog.js";
|
||||
import { createCohereCompletionsWrapper } from "./stream.js";
|
||||
|
||||
function readManifest() {
|
||||
return JSON.parse(readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf8")) as {
|
||||
providerAuthChoices?: Array<{ choiceId?: string; optionKey?: string; cliFlag?: string }>;
|
||||
setup?: { providers?: Array<{ id?: string; envVars?: string[] }> };
|
||||
};
|
||||
}
|
||||
|
||||
function requireCohereModel(): Model<"openai-completions"> {
|
||||
const model = buildCohereProvider().models?.[0];
|
||||
if (!model) {
|
||||
throw new Error("Cohere catalog did not provide a model");
|
||||
}
|
||||
return model as Model<"openai-completions">;
|
||||
}
|
||||
|
||||
function captureCoherePayload(context: Context): Record<string, unknown> {
|
||||
let captured: Record<string, unknown> | undefined;
|
||||
const baseStreamFn: StreamFn = (model, streamContext, options) => {
|
||||
const payload = buildOpenAICompletionsParams(
|
||||
model as Model<"openai-completions">,
|
||||
streamContext,
|
||||
{ maxTokens: 2048 } as never,
|
||||
);
|
||||
options?.onPayload?.(payload, model);
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
|
||||
const wrappedStreamFn = createCohereCompletionsWrapper(baseStreamFn);
|
||||
if (!wrappedStreamFn) {
|
||||
throw new Error("Cohere wrapper did not return a stream function");
|
||||
}
|
||||
void wrappedStreamFn(requireCohereModel(), context, {
|
||||
onPayload: (payload) => {
|
||||
captured = payload as Record<string, unknown>;
|
||||
},
|
||||
});
|
||||
if (!captured) {
|
||||
throw new Error("Cohere payload was not captured");
|
||||
}
|
||||
return captured;
|
||||
}
|
||||
|
||||
describe("Cohere provider plugin", () => {
|
||||
it("registers the manifest-owned API key onboarding flow", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
|
||||
expect(provider.auth.map((method) => method.wizard?.choiceId)).toEqual(["cohere-api-key"]);
|
||||
expect(provider).toMatchObject({
|
||||
id: "cohere",
|
||||
envVars: ["COHERE_API_KEY"],
|
||||
});
|
||||
expect(provider.auth[0]).toMatchObject({
|
||||
id: "api-key",
|
||||
kind: "api_key",
|
||||
wizard: { choiceId: "cohere-api-key" },
|
||||
});
|
||||
expect(readManifest().providerAuthChoices).toEqual([
|
||||
expect.objectContaining({
|
||||
choiceId: "cohere-api-key",
|
||||
optionKey: "cohereApiKey",
|
||||
cliFlag: "--cohere-api-key",
|
||||
}),
|
||||
]);
|
||||
expect(readManifest().setup?.providers).toEqual([
|
||||
{ id: "cohere", envVars: ["COHERE_API_KEY"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("exposes the static Cohere catalog", () => {
|
||||
expect(buildCohereProvider()).toMatchObject({
|
||||
baseUrl: "https://api.cohere.ai/compatibility/v1",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
expect.objectContaining({
|
||||
id: "command-a-03-2025",
|
||||
compat: {
|
||||
supportsStore: false,
|
||||
supportsUsageInStreaming: false,
|
||||
maxTokensField: "max_tokens",
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses Cohere's OpenAI-compatible completions payload fields", () => {
|
||||
const params = captureCoherePayload({
|
||||
systemPrompt: "system",
|
||||
messages: [],
|
||||
tools: [
|
||||
{
|
||||
name: "lookup",
|
||||
description: "Look up a value",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
],
|
||||
} as Context);
|
||||
|
||||
expect(params.max_tokens).toBe(2048);
|
||||
expect(params).not.toHaveProperty("max_completion_tokens");
|
||||
expect(params).not.toHaveProperty("store");
|
||||
expect(params).not.toHaveProperty("stream_options");
|
||||
expect(params).not.toHaveProperty("tool_choice");
|
||||
expect(params.messages).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ role: "developer", content: "system" })]),
|
||||
);
|
||||
expect(params.messages).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ role: "system", content: "system" })]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
|
||||
import { applyCohereConfig, COHERE_DEFAULT_MODEL_REF } from "./onboard.js";
|
||||
import { buildCohereProvider } from "./provider-catalog.js";
|
||||
import { createCohereCompletionsWrapper } from "./stream.js";
|
||||
|
||||
export default defineSingleProviderPluginEntry({
|
||||
id: "cohere",
|
||||
name: "Cohere Provider",
|
||||
description: "Bundled Cohere provider plugin",
|
||||
provider: {
|
||||
label: "Cohere",
|
||||
docsPath: "/providers/cohere",
|
||||
auth: [
|
||||
{
|
||||
methodId: "api-key",
|
||||
label: "Cohere API key",
|
||||
hint: "OpenAI-compatible inference",
|
||||
optionKey: "cohereApiKey",
|
||||
flagName: "--cohere-api-key",
|
||||
envVar: "COHERE_API_KEY",
|
||||
promptMessage: "Enter Cohere API key",
|
||||
defaultModel: COHERE_DEFAULT_MODEL_REF,
|
||||
applyConfig: (cfg) => applyCohereConfig(cfg),
|
||||
wizard: {
|
||||
groupLabel: "Cohere",
|
||||
groupHint: "OpenAI-compatible inference",
|
||||
},
|
||||
},
|
||||
],
|
||||
catalog: {
|
||||
buildProvider: buildCohereProvider,
|
||||
buildStaticProvider: buildCohereProvider,
|
||||
},
|
||||
wrapStreamFn: (ctx) => createCohereCompletionsWrapper(ctx.streamFn),
|
||||
wrapSimpleCompletionStreamFn: (ctx) => createCohereCompletionsWrapper(ctx.streamFn),
|
||||
},
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* Cohere model catalog helpers derived from the plugin manifest.
|
||||
*/
|
||||
import { buildManifestModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import manifest from "./openclaw.plugin.json" with { type: "json" };
|
||||
|
||||
const COHERE_MANIFEST_CATALOG = manifest.modelCatalog.providers.cohere;
|
||||
|
||||
export const COHERE_BASE_URL = COHERE_MANIFEST_CATALOG.baseUrl;
|
||||
export const COHERE_MODEL_CATALOG = COHERE_MANIFEST_CATALOG.models;
|
||||
|
||||
export function buildCohereCatalogModels(): ModelDefinitionConfig[] {
|
||||
return buildManifestModelProviderConfig({
|
||||
providerId: "cohere",
|
||||
catalog: COHERE_MANIFEST_CATALOG,
|
||||
}).models;
|
||||
}
|
||||
|
||||
export function buildCohereModelDefinition(
|
||||
model: (typeof COHERE_MODEL_CATALOG)[number],
|
||||
): ModelDefinitionConfig {
|
||||
return buildManifestModelProviderConfig({
|
||||
providerId: "cohere",
|
||||
catalog: { ...COHERE_MANIFEST_CATALOG, models: [model] },
|
||||
}).models[0];
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { resolveAgentModelPrimaryValue } from "openclaw/plugin-sdk/provider-onboard";
|
||||
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({});
|
||||
const provider = result.models?.providers?.cohere;
|
||||
|
||||
expect(provider).toMatchObject({
|
||||
baseUrl: COHERE_BASE_URL,
|
||||
api: "openai-completions",
|
||||
});
|
||||
expect(provider?.models?.map((model) => model.id)).toEqual([COHERE_DEFAULT_MODEL_ID]);
|
||||
expect(buildCohereCatalogModels()).toHaveLength(COHERE_MODEL_CATALOG.length);
|
||||
});
|
||||
|
||||
it("sets Cohere only when there is no primary model", () => {
|
||||
const existing: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.5" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = applyCohereConfig(existing);
|
||||
|
||||
expect(resolveAgentModelPrimaryValue(result.agents?.defaults?.model)).toBe("openai/gpt-5.5");
|
||||
expect(result.agents?.defaults?.models?.[COHERE_DEFAULT_MODEL_REF]).toEqual({
|
||||
alias: "Cohere Command A",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses Cohere as the first configured primary model", () => {
|
||||
const result = applyCohereConfig({});
|
||||
|
||||
expect(resolveAgentModelPrimaryValue(result.agents?.defaults?.model)).toBe(
|
||||
COHERE_DEFAULT_MODEL_REF,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import {
|
||||
createModelCatalogPresetAppliers,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/provider-onboard";
|
||||
import { buildCohereModelDefinition, COHERE_BASE_URL, COHERE_MODEL_CATALOG } from "./models.js";
|
||||
|
||||
export const COHERE_DEFAULT_MODEL_ID = "command-a-03-2025";
|
||||
export const COHERE_DEFAULT_MODEL_REF = `cohere/${COHERE_DEFAULT_MODEL_ID}`;
|
||||
|
||||
const coherePresetAppliers = createModelCatalogPresetAppliers({
|
||||
primaryModelRef: COHERE_DEFAULT_MODEL_REF,
|
||||
resolveParams: (_cfg: OpenClawConfig) => ({
|
||||
providerId: "cohere",
|
||||
api: "openai-completions",
|
||||
baseUrl: COHERE_BASE_URL,
|
||||
catalogModels: COHERE_MODEL_CATALOG.map(buildCohereModelDefinition),
|
||||
aliases: [{ modelRef: COHERE_DEFAULT_MODEL_REF, alias: "Cohere Command A" }],
|
||||
}),
|
||||
});
|
||||
|
||||
export function applyCohereProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
|
||||
return coherePresetAppliers.applyProviderConfig(cfg);
|
||||
}
|
||||
|
||||
export function applyCohereConfig(cfg: OpenClawConfig): OpenClawConfig {
|
||||
return coherePresetAppliers.applyConfig(cfg);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
{
|
||||
"id": "cohere",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"providers": ["cohere"],
|
||||
"modelCatalog": {
|
||||
"providers": {
|
||||
"cohere": {
|
||||
"baseUrl": "https://api.cohere.ai/compatibility/v1",
|
||||
"api": "openai-completions",
|
||||
"models": [
|
||||
{
|
||||
"id": "command-a-03-2025",
|
||||
"name": "Command A",
|
||||
"input": ["text"],
|
||||
"contextWindow": 256000,
|
||||
"maxTokens": 8000,
|
||||
"cost": {
|
||||
"input": 2.5,
|
||||
"output": 10,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"compat": {
|
||||
"supportsStore": false,
|
||||
"supportsUsageInStreaming": false,
|
||||
"maxTokensField": "max_tokens"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"cohere": "static"
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
"providers": [
|
||||
{
|
||||
"id": "cohere",
|
||||
"envVars": ["COHERE_API_KEY"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"providerAuthChoices": [
|
||||
{
|
||||
"provider": "cohere",
|
||||
"method": "api-key",
|
||||
"choiceId": "cohere-api-key",
|
||||
"choiceLabel": "Cohere API key",
|
||||
"groupId": "cohere",
|
||||
"groupLabel": "Cohere",
|
||||
"groupHint": "OpenAI-compatible inference",
|
||||
"optionKey": "cohereApiKey",
|
||||
"cliFlag": "--cohere-api-key",
|
||||
"cliOption": "--cohere-api-key <key>",
|
||||
"cliDescription": "Cohere API key"
|
||||
}
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "@openclaw/cohere-provider",
|
||||
"version": "2026.6.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw Cohere provider plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { buildCohereCatalogModels, COHERE_BASE_URL } from "./models.js";
|
||||
|
||||
export function buildCohereProvider(): ModelProviderConfig {
|
||||
return {
|
||||
baseUrl: COHERE_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: buildCohereCatalogModels(),
|
||||
};
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createPayloadPatchStreamWrapper } from "openclaw/plugin-sdk/provider-stream-shared";
|
||||
|
||||
function patchCoherePayload(payload: Record<string, unknown>): void {
|
||||
// Cohere's Compatibility API uses developer, not system, for instructions.
|
||||
if (Array.isArray(payload.messages)) {
|
||||
payload.messages = payload.messages.map((message) =>
|
||||
message &&
|
||||
typeof message === "object" &&
|
||||
(message as Record<string, unknown>).role === "system"
|
||||
? { ...(message as Record<string, unknown>), role: "developer" }
|
||||
: message,
|
||||
);
|
||||
}
|
||||
|
||||
// Cohere lets tool-capable models choose a tool when tool_choice is omitted.
|
||||
delete payload.tool_choice;
|
||||
}
|
||||
|
||||
export function createCohereCompletionsWrapper(
|
||||
baseStreamFn: ProviderWrapStreamFnContext["streamFn"],
|
||||
): ProviderWrapStreamFnContext["streamFn"] {
|
||||
return createPayloadPatchStreamWrapper(baseStreamFn, ({ payload }) =>
|
||||
patchCoherePayload(payload),
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
@@ -173,7 +173,6 @@ import {
|
||||
import {
|
||||
emitDiagnosticEventWithTrustedTraceContext,
|
||||
emitInternalDiagnosticEventForTest,
|
||||
emitTrustedSecurityEvent,
|
||||
logMessageDispatchStarted,
|
||||
logMessageProcessed,
|
||||
onTrustedInternalDiagnosticEvent,
|
||||
@@ -954,119 +953,6 @@ describe("diagnostics-otel service", () => {
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
test("exports trusted security events as bounded OTLP logs", async () => {
|
||||
const service = createDiagnosticsOtelService();
|
||||
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { logs: true });
|
||||
const trace = createDiagnosticTraceContext({
|
||||
traceId: TRACE_ID,
|
||||
spanId: SPAN_ID,
|
||||
traceFlags: "01",
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
emitTrustedSecurityEvent({
|
||||
eventId: "security-event-1",
|
||||
category: "tool",
|
||||
action: "tool.execution.blocked",
|
||||
outcome: "denied",
|
||||
severity: "medium",
|
||||
reason: "tools.deny",
|
||||
actor: {
|
||||
kind: "agent",
|
||||
idHash: "agent-hash-1",
|
||||
role: "operator",
|
||||
scopes: ["operator.read", "operator.approvals"],
|
||||
},
|
||||
target: {
|
||||
kind: "plugin",
|
||||
name: "@acme/security-event-plugin",
|
||||
owner: "plugin-installer",
|
||||
},
|
||||
policy: {
|
||||
id: "tools.exec",
|
||||
decision: "deny",
|
||||
reason: "allowlist.miss",
|
||||
},
|
||||
control: {
|
||||
id: "exec-approval",
|
||||
family: "approval",
|
||||
},
|
||||
attributes: {
|
||||
params_kind: "object",
|
||||
secretish: "token sk-test-secret",
|
||||
[PROTO_KEY]: "blocked",
|
||||
},
|
||||
trace,
|
||||
});
|
||||
await flushDiagnosticEvents();
|
||||
|
||||
const emitCall = mockCallArg(logEmit, 0) as {
|
||||
attributes?: Record<string, unknown>;
|
||||
body?: string;
|
||||
context?: unknown;
|
||||
severityNumber?: number;
|
||||
severityText?: string;
|
||||
};
|
||||
expect(emitCall.body).toBe("openclaw.security.event");
|
||||
expect(emitCall.severityText).toBe("WARN");
|
||||
expect(emitCall.severityNumber).toBe(13);
|
||||
expect(emitCall.attributes).toMatchObject({
|
||||
"openclaw.security.event_id": "security-event-1",
|
||||
"openclaw.security.category": "tool",
|
||||
"openclaw.security.action": "tool.execution.blocked",
|
||||
"openclaw.security.outcome": "denied",
|
||||
"openclaw.security.severity": "medium",
|
||||
"openclaw.security.reason": "tools.deny",
|
||||
"openclaw.security.actor.kind": "agent",
|
||||
"openclaw.security.actor.id_hash": "agent-hash-1",
|
||||
"openclaw.security.actor.role": "operator",
|
||||
"openclaw.security.actor.scopes": "operator.read,operator.approvals",
|
||||
"openclaw.security.target.kind": "plugin",
|
||||
"openclaw.security.target.name": "@acme/security-event-plugin",
|
||||
"openclaw.security.target.owner": "plugin-installer",
|
||||
"openclaw.security.policy.id": "tools.exec",
|
||||
"openclaw.security.policy.decision": "deny",
|
||||
"openclaw.security.policy.reason": "allowlist.miss",
|
||||
"openclaw.security.control.id": "exec-approval",
|
||||
"openclaw.security.control.family": "approval",
|
||||
"openclaw.security.attribute.params_kind": "object",
|
||||
"openclaw.security.attribute.secretish": "unknown",
|
||||
});
|
||||
expect(emitCall.context).toEqual({
|
||||
spanContext: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: SPAN_ID,
|
||||
traceFlags: 1,
|
||||
isRemote: true,
|
||||
},
|
||||
});
|
||||
expect(Object.hasOwn(emitCall.attributes ?? {}, "openclaw.security.attribute.__proto__")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(JSON.stringify(emitCall)).not.toContain("sk-test-secret");
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
test("does not export security events when OTLP logs are disabled", async () => {
|
||||
const service = createDiagnosticsOtelService();
|
||||
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { logs: false, metrics: true });
|
||||
|
||||
await service.start(ctx);
|
||||
emitTrustedSecurityEvent({
|
||||
eventId: "security-event-logs-disabled",
|
||||
category: "auth",
|
||||
action: "gateway.auth.failed",
|
||||
outcome: "failure",
|
||||
severity: "high",
|
||||
});
|
||||
await flushDiagnosticEvents();
|
||||
|
||||
expect(logEmit).not.toHaveBeenCalled();
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
test("records liveness warning diagnostics", async () => {
|
||||
const service = createDiagnosticsOtelService();
|
||||
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
|
||||
|
||||
@@ -67,7 +67,6 @@ const DROPPED_OTEL_ATTRIBUTE_KEYS = new Set([
|
||||
"openclaw.trace_id",
|
||||
]);
|
||||
const LOW_CARDINALITY_VALUE_RE = /^[A-Za-z0-9_.:-]{1,120}$/u;
|
||||
const SECURITY_TARGET_NAME_VALUE_RE = /^[A-Za-z0-9@/_.:-]{1,256}$/u;
|
||||
const MAX_OTEL_CONTENT_ATTRIBUTE_CHARS = 128 * 1024;
|
||||
const MAX_OTEL_CONTENT_ARRAY_ITEMS = 200;
|
||||
const MAX_OTEL_LOG_BODY_CHARS = 4 * 1024;
|
||||
@@ -139,7 +138,6 @@ type SessionRecoveryDiagnosticEvent = Extract<
|
||||
{ type: "session.recovery.requested" | "session.recovery.completed" }
|
||||
>;
|
||||
type TalkDiagnosticEvent = Extract<DiagnosticEventPayload, { type: "talk.event" }>;
|
||||
type SecuritySeverityText = "FATAL" | "ERROR" | "WARN" | "INFO";
|
||||
type TrustedSpanAliasOwner = { kind: "run"; id: string };
|
||||
|
||||
const NO_CONTENT_CAPTURE: OtelContentCapturePolicy = {
|
||||
@@ -320,18 +318,6 @@ function lowCardinalityAttr(value: string | undefined, fallback = "unknown"): st
|
||||
return LOW_CARDINALITY_VALUE_RE.test(redacted) ? redacted : fallback;
|
||||
}
|
||||
|
||||
function securityTargetNameAttr(value: string | undefined, fallback = "unknown"): string {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
const redacted = redactSensitiveText(value.trim());
|
||||
const redactedLower = redacted.toLowerCase();
|
||||
if (redactedLower.startsWith("agent:") || redactedLower.includes(":agent:")) {
|
||||
return fallback;
|
||||
}
|
||||
return SECURITY_TARGET_NAME_VALUE_RE.test(redacted) ? redacted : fallback;
|
||||
}
|
||||
|
||||
function lowCardinalityQueueLaneAttr(value: string | undefined, fallback = "unknown"): string {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
@@ -1023,173 +1009,6 @@ function assignOtelLogEventAttributes(
|
||||
}
|
||||
}
|
||||
|
||||
function assignOtelSecurityEventAttributes(
|
||||
attributes: Record<string, string | number | boolean>,
|
||||
eventAttributes: Record<string, string | number | boolean> | undefined,
|
||||
): void {
|
||||
if (!eventAttributes) {
|
||||
return;
|
||||
}
|
||||
for (const rawKey in eventAttributes) {
|
||||
if (Object.keys(attributes).length >= MAX_OTEL_LOG_ATTRIBUTE_COUNT) {
|
||||
break;
|
||||
}
|
||||
if (!Object.hasOwn(eventAttributes, rawKey)) {
|
||||
continue;
|
||||
}
|
||||
const key = rawKey.trim();
|
||||
if (BLOCKED_OTEL_LOG_ATTRIBUTE_KEYS.has(key)) {
|
||||
continue;
|
||||
}
|
||||
if (redactSensitiveText(key) !== key) {
|
||||
continue;
|
||||
}
|
||||
if (!OTEL_LOG_RAW_ATTRIBUTE_KEY_RE.test(key)) {
|
||||
continue;
|
||||
}
|
||||
const value = eventAttributes[rawKey];
|
||||
assignOtelLogAttribute(
|
||||
attributes,
|
||||
`openclaw.security.attribute.${key}`,
|
||||
typeof value === "string" ? lowCardinalityAttr(value) : value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function securitySeverityText(
|
||||
severity: Extract<DiagnosticEventPayload, { type: "security.event" }>["severity"],
|
||||
): SecuritySeverityText {
|
||||
switch (severity) {
|
||||
case "critical":
|
||||
return "FATAL";
|
||||
case "high":
|
||||
return "ERROR";
|
||||
case "medium":
|
||||
return "WARN";
|
||||
case "info":
|
||||
case "low":
|
||||
return "INFO";
|
||||
}
|
||||
const unreachable: never = severity;
|
||||
return unreachable;
|
||||
}
|
||||
|
||||
function assignOtelSecurityAttributes(
|
||||
attributes: Record<string, string | number | boolean>,
|
||||
evt: Extract<DiagnosticEventPayload, { type: "security.event" }>,
|
||||
): void {
|
||||
assignOtelLogAttribute(attributes, "openclaw.security.event_id", evt.eventId);
|
||||
assignOtelLogAttribute(attributes, "openclaw.security.category", evt.category);
|
||||
assignOtelLogAttribute(attributes, "openclaw.security.action", lowCardinalityAttr(evt.action));
|
||||
assignOtelLogAttribute(attributes, "openclaw.security.outcome", evt.outcome);
|
||||
assignOtelLogAttribute(attributes, "openclaw.security.severity", evt.severity);
|
||||
if (evt.reason) {
|
||||
assignOtelLogAttribute(attributes, "openclaw.security.reason", lowCardinalityAttr(evt.reason));
|
||||
}
|
||||
if (evt.actor) {
|
||||
assignOtelLogAttribute(attributes, "openclaw.security.actor.kind", evt.actor.kind);
|
||||
if (evt.actor.idHash) {
|
||||
assignOtelLogAttribute(
|
||||
attributes,
|
||||
"openclaw.security.actor.id_hash",
|
||||
lowCardinalityAttr(evt.actor.idHash),
|
||||
);
|
||||
}
|
||||
if (evt.actor.deviceIdHash) {
|
||||
assignOtelLogAttribute(
|
||||
attributes,
|
||||
"openclaw.security.actor.device_id_hash",
|
||||
lowCardinalityAttr(evt.actor.deviceIdHash),
|
||||
);
|
||||
}
|
||||
if (evt.actor.channel) {
|
||||
assignOtelLogAttribute(
|
||||
attributes,
|
||||
"openclaw.security.actor.channel",
|
||||
lowCardinalityAttr(evt.actor.channel),
|
||||
);
|
||||
}
|
||||
if (evt.actor.role) {
|
||||
assignOtelLogAttribute(
|
||||
attributes,
|
||||
"openclaw.security.actor.role",
|
||||
lowCardinalityAttr(evt.actor.role),
|
||||
);
|
||||
}
|
||||
if (evt.actor.scopes?.length) {
|
||||
assignOtelLogAttribute(
|
||||
attributes,
|
||||
"openclaw.security.actor.scopes",
|
||||
evt.actor.scopes.map((scope) => lowCardinalityAttr(scope)).join(","),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (evt.target) {
|
||||
assignOtelLogAttribute(attributes, "openclaw.security.target.kind", evt.target.kind);
|
||||
if (evt.target.idHash) {
|
||||
assignOtelLogAttribute(
|
||||
attributes,
|
||||
"openclaw.security.target.id_hash",
|
||||
lowCardinalityAttr(evt.target.idHash),
|
||||
);
|
||||
}
|
||||
if (evt.target.name) {
|
||||
assignOtelLogAttribute(
|
||||
attributes,
|
||||
"openclaw.security.target.name",
|
||||
securityTargetNameAttr(evt.target.name),
|
||||
);
|
||||
}
|
||||
if (evt.target.owner) {
|
||||
assignOtelLogAttribute(
|
||||
attributes,
|
||||
"openclaw.security.target.owner",
|
||||
lowCardinalityAttr(evt.target.owner),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (evt.policy) {
|
||||
if (evt.policy.id) {
|
||||
assignOtelLogAttribute(
|
||||
attributes,
|
||||
"openclaw.security.policy.id",
|
||||
lowCardinalityAttr(evt.policy.id),
|
||||
);
|
||||
}
|
||||
if (evt.policy.decision) {
|
||||
assignOtelLogAttribute(
|
||||
attributes,
|
||||
"openclaw.security.policy.decision",
|
||||
evt.policy.decision,
|
||||
);
|
||||
}
|
||||
if (evt.policy.reason) {
|
||||
assignOtelLogAttribute(
|
||||
attributes,
|
||||
"openclaw.security.policy.reason",
|
||||
lowCardinalityAttr(evt.policy.reason),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (evt.control) {
|
||||
if (evt.control.id) {
|
||||
assignOtelLogAttribute(
|
||||
attributes,
|
||||
"openclaw.security.control.id",
|
||||
lowCardinalityAttr(evt.control.id),
|
||||
);
|
||||
}
|
||||
if (evt.control.family) {
|
||||
assignOtelLogAttribute(
|
||||
attributes,
|
||||
"openclaw.security.control.family",
|
||||
evt.control.family,
|
||||
);
|
||||
}
|
||||
}
|
||||
assignOtelSecurityEventAttributes(attributes, evt.attributes);
|
||||
}
|
||||
|
||||
function traceFlagsToOtel(traceFlags: string | undefined): TraceFlags {
|
||||
const parsed = Number.parseInt(traceFlags ?? "00", 16);
|
||||
return (parsed & TraceFlags.SAMPLED) !== 0 ? TraceFlags.SAMPLED : TraceFlags.NONE;
|
||||
@@ -1766,12 +1585,6 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
metadata: DiagnosticEventMetadata,
|
||||
) => void)
|
||||
| undefined;
|
||||
let recordSecurityEvent:
|
||||
| ((
|
||||
evt: Extract<DiagnosticEventPayload, { type: "security.event" }>,
|
||||
metadata: DiagnosticEventMetadata,
|
||||
) => void)
|
||||
| undefined;
|
||||
if (logsEnabled) {
|
||||
let logRecordExportFailureLastReportedAt = Number.NEGATIVE_INFINITY;
|
||||
const logExporter = new OTLPLogExporter({
|
||||
@@ -1849,47 +1662,6 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
}
|
||||
}
|
||||
};
|
||||
recordSecurityEvent = (evt, metadata) => {
|
||||
if (!metadata.trusted) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const severityText = securitySeverityText(evt.severity);
|
||||
const attributes = Object.create(null) as Record<string, string | number | boolean>;
|
||||
assignOtelSecurityAttributes(attributes, evt);
|
||||
|
||||
const logRecord: LogRecord = {
|
||||
body: "openclaw.security.event",
|
||||
severityText,
|
||||
severityNumber: logSeverityMap[severityText] ?? (9 as SeverityNumber),
|
||||
attributes: redactOtelAttributes(attributes),
|
||||
timestamp: evt.ts,
|
||||
};
|
||||
const logContext = contextForTrustedTraceContext(evt, metadata);
|
||||
if (logContext) {
|
||||
logRecord.context = logContext;
|
||||
}
|
||||
otelLogger.emit(logRecord);
|
||||
} catch (err) {
|
||||
emitExporterEvent({
|
||||
exporter: "diagnostics-otel",
|
||||
signal: "logs",
|
||||
status: "failure",
|
||||
reason: "emit_failed",
|
||||
errorCategory: errorCategory(err),
|
||||
});
|
||||
const now = Date.now();
|
||||
if (
|
||||
now - logRecordExportFailureLastReportedAt >=
|
||||
LOG_RECORD_EXPORT_FAILURE_REPORT_INTERVAL_MS
|
||||
) {
|
||||
logRecordExportFailureLastReportedAt = now;
|
||||
ctx.logger.error(
|
||||
`diagnostics-otel: security event export failed: ${formatError(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const spanWithDuration = (
|
||||
@@ -3671,9 +3443,6 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
case "log.record":
|
||||
recordLogRecord?.(evt, metadata);
|
||||
return;
|
||||
case "security.event":
|
||||
recordSecurityEvent?.(evt, metadata);
|
||||
return;
|
||||
case "tool.loop":
|
||||
recordToolLoop(evt);
|
||||
return;
|
||||
|
||||
@@ -3057,8 +3057,8 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
const lastUpdate = draftStream.update.mock.calls.at(-1)?.[0];
|
||||
expect(lastUpdate).toContain("completed");
|
||||
expect(lastUpdate).not.toContain("install dependencies");
|
||||
expect(lastUpdate).toContain("install dependencies");
|
||||
expect(lastUpdate).not.toContain("completed");
|
||||
});
|
||||
|
||||
it("drops later tool warning finals after progress preview final replies", async () => {
|
||||
|
||||
@@ -5,14 +5,6 @@ import { createToolFactoryHarness } from "./tool-factory-test-harness.js";
|
||||
|
||||
const createFeishuClientMock = vi.fn((creds: { appId?: string } | undefined) => ({
|
||||
__appId: creds?.appId,
|
||||
application: {
|
||||
scope: {
|
||||
list: vi.fn(async () => ({
|
||||
code: 0,
|
||||
data: { scopes: [] },
|
||||
})),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
function feishuClientAppId(callIndex: number): string | undefined {
|
||||
@@ -69,28 +61,6 @@ describe("feishu_doc account selection", () => {
|
||||
} as OpenClawPluginApi["config"];
|
||||
}
|
||||
|
||||
function createMixedToolConfig(): OpenClawPluginApi["config"] {
|
||||
return {
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
accounts: {
|
||||
a: {
|
||||
appId: "app-a",
|
||||
appSecret: "sec-a", // pragma: allowlist secret
|
||||
tools: { doc: false, scopes: false },
|
||||
},
|
||||
b: {
|
||||
appId: "app-b",
|
||||
appSecret: "sec-b", // pragma: allowlist secret
|
||||
tools: { doc: true, scopes: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawPluginApi["config"];
|
||||
}
|
||||
|
||||
test("uses agentAccountId context when params omit accountId", async () => {
|
||||
const cfg = createDocEnabledConfig();
|
||||
|
||||
@@ -123,44 +93,4 @@ describe("feishu_doc account selection", () => {
|
||||
|
||||
expect(feishuClientAppId(-1)).toBe("app-a");
|
||||
});
|
||||
|
||||
test("rejects a disabled contextual account when another account enables docs", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness(createMixedToolConfig());
|
||||
registerFeishuDocTools(api);
|
||||
|
||||
const docTool = resolveTool("feishu_doc", { agentAccountId: "a" });
|
||||
const result = await docTool.execute("call-disabled", {
|
||||
action: "list_blocks",
|
||||
doc_token: "d",
|
||||
});
|
||||
|
||||
expect(createFeishuClientMock).not.toHaveBeenCalled();
|
||||
expect(result.details.error).toBe('Feishu Doc tools are disabled for account "a"');
|
||||
});
|
||||
|
||||
test("rejects an explicit disabled account override for docs", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness(createMixedToolConfig());
|
||||
registerFeishuDocTools(api);
|
||||
|
||||
const docTool = resolveTool("feishu_doc", { agentAccountId: "b" });
|
||||
const result = await docTool.execute("call-disabled", {
|
||||
action: "list_blocks",
|
||||
doc_token: "d",
|
||||
accountId: "a",
|
||||
});
|
||||
|
||||
expect(createFeishuClientMock).not.toHaveBeenCalled();
|
||||
expect(result.details.error).toBe('Feishu Doc tools are disabled for account "a"');
|
||||
});
|
||||
|
||||
test("rejects a disabled contextual account when another account enables app scopes", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness(createMixedToolConfig());
|
||||
registerFeishuDocTools(api);
|
||||
|
||||
const scopesTool = resolveTool("feishu_app_scopes", { agentAccountId: "a" });
|
||||
const result = await scopesTool.execute("call-disabled", {});
|
||||
|
||||
expect(createFeishuClientMock).not.toHaveBeenCalled();
|
||||
expect(result.details.error).toBe('Feishu App Scopes tools are disabled for account "a"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1384,23 +1384,14 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
|
||||
type FeishuDocExecuteParams = FeishuDocParams & { accountId?: string };
|
||||
|
||||
const getClient = (params: { accountId?: string } | undefined, defaultAccountId?: string) =>
|
||||
createFeishuToolClient({
|
||||
api,
|
||||
executeParams: params,
|
||||
defaultAccountId,
|
||||
requiredTool: { family: "doc", label: "Doc" },
|
||||
});
|
||||
createFeishuToolClient({ api, executeParams: params, defaultAccountId });
|
||||
|
||||
const getMediaMaxBytes = (
|
||||
params: { accountId?: string } | undefined,
|
||||
defaultAccountId?: string,
|
||||
) =>
|
||||
(resolveFeishuToolAccount({
|
||||
api,
|
||||
executeParams: params,
|
||||
defaultAccountId,
|
||||
requiredTool: { family: "doc", label: "Doc" },
|
||||
}).config?.mediaMaxMb ?? 30) *
|
||||
(resolveFeishuToolAccount({ api, executeParams: params, defaultAccountId }).config
|
||||
?.mediaMaxMb ?? 30) *
|
||||
1024 *
|
||||
1024;
|
||||
|
||||
@@ -1593,13 +1584,7 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
|
||||
parameters: Type.Object({}),
|
||||
async execute() {
|
||||
try {
|
||||
const result = await listAppScopes(
|
||||
createFeishuToolClient({
|
||||
api,
|
||||
defaultAccountId: ctx.agentAccountId,
|
||||
requiredTool: { family: "scopes", label: "App Scopes" },
|
||||
}),
|
||||
);
|
||||
const result = await listAppScopes(getClient(undefined, ctx.agentAccountId));
|
||||
return json(result);
|
||||
} catch (err) {
|
||||
return json({ error: formatErrorMessage(err) });
|
||||
|
||||
@@ -765,7 +765,6 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) {
|
||||
api,
|
||||
executeParams: p,
|
||||
defaultAccountId,
|
||||
requiredTool: { family: "drive", label: "Drive" },
|
||||
});
|
||||
switch (p.action) {
|
||||
case "list":
|
||||
|
||||
@@ -145,7 +145,6 @@ export function registerFeishuPermTools(api: OpenClawPluginApi) {
|
||||
api,
|
||||
executeParams: p,
|
||||
defaultAccountId,
|
||||
requiredTool: { family: "perm", label: "Perm" },
|
||||
});
|
||||
switch (p.action) {
|
||||
case "list":
|
||||
|
||||
@@ -258,39 +258,7 @@ describe("getMessageFeishu", () => {
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect(typeof result.receipt.sentAt).toBe("number");
|
||||
expect(result).toEqual({
|
||||
messageId: "om_mentions",
|
||||
chatId: "oc_send",
|
||||
receipt: {
|
||||
primaryPlatformMessageId: "om_mentions",
|
||||
platformMessageIds: ["om_mentions"],
|
||||
parts: [
|
||||
{
|
||||
platformMessageId: "om_mentions",
|
||||
kind: "text",
|
||||
index: 0,
|
||||
raw: {
|
||||
channel: "feishu",
|
||||
messageId: "om_mentions",
|
||||
chatId: "oc_send",
|
||||
conversationId: "oc_send",
|
||||
},
|
||||
threadId: "oc_send",
|
||||
},
|
||||
],
|
||||
threadId: "oc_send",
|
||||
sentAt: result.receipt.sentAt,
|
||||
raw: [
|
||||
{
|
||||
channel: "feishu",
|
||||
messageId: "om_mentions",
|
||||
chatId: "oc_send",
|
||||
conversationId: "oc_send",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ messageId: "om_mentions", chatId: "oc_send" });
|
||||
});
|
||||
|
||||
it("extracts text content from interactive card elements", async () => {
|
||||
|
||||
@@ -119,21 +119,6 @@ describe("feishu tool account routing", () => {
|
||||
expect(lastClientAppId()).toBe("app-b");
|
||||
});
|
||||
|
||||
test("wiki tool implicit fallback selects an account with wiki enabled", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness(
|
||||
createConfig({
|
||||
toolsA: { drive: true, wiki: false },
|
||||
toolsB: { wiki: true },
|
||||
}),
|
||||
);
|
||||
registerFeishuWikiTools(api);
|
||||
|
||||
const tool = resolveTool("feishu_wiki");
|
||||
await tool.execute("call", { action: "search" });
|
||||
|
||||
expect(lastClientAppId()).toBe("app-b");
|
||||
});
|
||||
|
||||
test("wiki tool prefers the active contextual account over configured defaultAccount", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness(
|
||||
createConfig({
|
||||
@@ -205,22 +190,6 @@ describe("feishu tool account routing", () => {
|
||||
expect(lastClientAppId()).toBe("app-b");
|
||||
});
|
||||
|
||||
test("drive tool rejects a disabled contextual account when another account enables it", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness(
|
||||
createConfig({
|
||||
toolsA: { drive: false },
|
||||
toolsB: { drive: true },
|
||||
}),
|
||||
);
|
||||
registerFeishuDriveTools(api);
|
||||
|
||||
const tool = resolveTool("feishu_drive", { agentAccountId: "a" });
|
||||
const result = await tool.execute("call", { action: "unknown_action" });
|
||||
|
||||
expect(createFeishuClientMock).not.toHaveBeenCalled();
|
||||
expect(result.details.error).toBe('Feishu Drive tools are disabled for account "a"');
|
||||
});
|
||||
|
||||
test("perm tool registers when only second account enables it and routes to agentAccountId", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness(
|
||||
createConfig({
|
||||
@@ -236,38 +205,6 @@ describe("feishu tool account routing", () => {
|
||||
expect(lastClientAppId()).toBe("app-b");
|
||||
});
|
||||
|
||||
test("perm tool rejects a disabled contextual account when another account enables it", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness(
|
||||
createConfig({
|
||||
toolsA: { perm: false },
|
||||
toolsB: { perm: true },
|
||||
}),
|
||||
);
|
||||
registerFeishuPermTools(api);
|
||||
|
||||
const tool = resolveTool("feishu_perm", { agentAccountId: "a" });
|
||||
const result = await tool.execute("call", { action: "unknown_action" });
|
||||
|
||||
expect(createFeishuClientMock).not.toHaveBeenCalled();
|
||||
expect(result.details.error).toBe('Feishu Perm tools are disabled for account "a"');
|
||||
});
|
||||
|
||||
test("perm tool rejects an explicit disabled account override", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness(
|
||||
createConfig({
|
||||
toolsA: { perm: false },
|
||||
toolsB: { perm: true },
|
||||
}),
|
||||
);
|
||||
registerFeishuPermTools(api);
|
||||
|
||||
const tool = resolveTool("feishu_perm", { agentAccountId: "b" });
|
||||
const result = await tool.execute("call", { action: "unknown_action", accountId: "a" });
|
||||
|
||||
expect(createFeishuClientMock).not.toHaveBeenCalled();
|
||||
expect(result.details.error).toBe('Feishu Perm tools are disabled for account "a"');
|
||||
});
|
||||
|
||||
test("bitable tool registers when only second account enables it and routes to agentAccountId", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness(
|
||||
createConfig({
|
||||
@@ -449,22 +386,6 @@ describe("feishu tool account routing", () => {
|
||||
expect(lastClientAppId()).toBe("app-a");
|
||||
});
|
||||
|
||||
test("wiki tool rejects an explicit disabled account override", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness(
|
||||
createConfig({
|
||||
toolsA: { wiki: false },
|
||||
toolsB: { wiki: true },
|
||||
}),
|
||||
);
|
||||
registerFeishuWikiTools(api);
|
||||
|
||||
const tool = resolveTool("feishu_wiki", { agentAccountId: "b" });
|
||||
const result = await tool.execute("call", { action: "search", accountId: "a" });
|
||||
|
||||
expect(createFeishuClientMock).not.toHaveBeenCalled();
|
||||
expect(result.details.error).toBe('Feishu Wiki tools are disabled for account "a"');
|
||||
});
|
||||
|
||||
test("does not silently fall back when the contextual account is real but uses non-env SecretRefs", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness({
|
||||
channels: {
|
||||
|
||||
@@ -12,17 +12,11 @@ import { resolveToolsConfig } from "./tools-config.js";
|
||||
import type { FeishuToolsConfig, ResolvedFeishuAccount } from "./types.js";
|
||||
|
||||
type AccountAwareParams = { accountId?: string };
|
||||
type FeishuToolFamily = keyof FeishuToolsConfig;
|
||||
type FeishuToolRequirement = {
|
||||
family: FeishuToolFamily;
|
||||
label: string;
|
||||
};
|
||||
|
||||
function resolveImplicitToolAccountId(params: {
|
||||
api: Pick<OpenClawPluginApi, "config">;
|
||||
executeParams?: AccountAwareParams;
|
||||
defaultAccountId?: string;
|
||||
requiredTool?: FeishuToolRequirement;
|
||||
}): string | undefined {
|
||||
const explicitAccountId = normalizeOptionalString(params.executeParams?.accountId);
|
||||
if (explicitAccountId) {
|
||||
@@ -51,19 +45,6 @@ function resolveImplicitToolAccountId(params: {
|
||||
return configuredDefaultAccountId;
|
||||
}
|
||||
|
||||
if (params.requiredTool && params.api.config) {
|
||||
for (const accountId of listFeishuAccountIds(params.api.config)) {
|
||||
const account = resolveFeishuAccount({ cfg: params.api.config, accountId });
|
||||
if (
|
||||
account.enabled &&
|
||||
account.configured &&
|
||||
resolveToolsConfig(account.config.tools)[params.requiredTool.family]
|
||||
) {
|
||||
return accountId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -71,31 +52,20 @@ export function resolveFeishuToolAccount(params: {
|
||||
api: Pick<OpenClawPluginApi, "config">;
|
||||
executeParams?: AccountAwareParams;
|
||||
defaultAccountId?: string;
|
||||
requiredTool?: FeishuToolRequirement;
|
||||
}): ResolvedFeishuAccount {
|
||||
if (!params.api.config) {
|
||||
throw new Error("Feishu config unavailable");
|
||||
}
|
||||
const account = resolveFeishuRuntimeAccount({
|
||||
return resolveFeishuRuntimeAccount({
|
||||
cfg: params.api.config,
|
||||
accountId: resolveImplicitToolAccountId(params),
|
||||
});
|
||||
if (
|
||||
params.requiredTool &&
|
||||
!resolveToolsConfig(account.config.tools)[params.requiredTool.family]
|
||||
) {
|
||||
throw new Error(
|
||||
`Feishu ${params.requiredTool.label} tools are disabled for account "${account.accountId}"`,
|
||||
);
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
export function createFeishuToolClient(params: {
|
||||
api: Pick<OpenClawPluginApi, "config">;
|
||||
executeParams?: AccountAwareParams;
|
||||
defaultAccountId?: string;
|
||||
requiredTool?: FeishuToolRequirement;
|
||||
}): Lark.Client {
|
||||
return createFeishuClient(resolveFeishuToolAccount(params));
|
||||
}
|
||||
|
||||
@@ -238,7 +238,6 @@ export function registerFeishuWikiTools(api: OpenClawPluginApi) {
|
||||
api,
|
||||
executeParams: p,
|
||||
defaultAccountId,
|
||||
requiredTool: { family: "wiki", label: "Wiki" },
|
||||
});
|
||||
switch (p.action) {
|
||||
case "spaces":
|
||||
|
||||
@@ -44,17 +44,6 @@ export async function startGoogleChatGatewayAccount(ctx: {
|
||||
audienceType: account.config.audienceType,
|
||||
audience: account.config.audience,
|
||||
});
|
||||
let stopped = false;
|
||||
const markStopped = () => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
stopped = true;
|
||||
statusSink({
|
||||
running: false,
|
||||
lastStopAt: Date.now(),
|
||||
});
|
||||
};
|
||||
if (
|
||||
isGoogleChatNativeApprovalClientEnabled({
|
||||
cfg: ctx.cfg,
|
||||
@@ -70,28 +59,26 @@ export async function startGoogleChatGatewayAccount(ctx: {
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
}
|
||||
try {
|
||||
await runPassiveAccountLifecycle({
|
||||
abortSignal: ctx.abortSignal,
|
||||
start: async () =>
|
||||
await startGoogleChatMonitor({
|
||||
account,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
webhookPath: account.config.webhookPath,
|
||||
webhookUrl: account.config.webhookUrl,
|
||||
statusSink,
|
||||
}),
|
||||
stop: async (unregister) => {
|
||||
unregister?.();
|
||||
},
|
||||
onStop: async () => {
|
||||
markStopped();
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
markStopped();
|
||||
throw error;
|
||||
}
|
||||
await runPassiveAccountLifecycle({
|
||||
abortSignal: ctx.abortSignal,
|
||||
start: async () =>
|
||||
await startGoogleChatMonitor({
|
||||
account,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
webhookPath: account.config.webhookPath,
|
||||
webhookUrl: account.config.webhookUrl,
|
||||
statusSink,
|
||||
}),
|
||||
stop: async (unregister) => {
|
||||
unregister?.();
|
||||
},
|
||||
onStop: async () => {
|
||||
statusSink({
|
||||
running: false,
|
||||
lastStopAt: Date.now(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Googlechat tests cover setup plugin behavior.
|
||||
import {
|
||||
createStartAccountContext,
|
||||
expectLifecyclePatch,
|
||||
expectPendingUntilAbort,
|
||||
startAccountAndTrackLifecycle,
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import type { WizardPrompter } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
|
||||
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/status-helpers";
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import {
|
||||
@@ -385,22 +383,6 @@ describe("googlechat setup", () => {
|
||||
expectLifecyclePatch(patches, { running: true });
|
||||
expectLifecyclePatch(patches, { running: false });
|
||||
});
|
||||
|
||||
it("clears running status when monitor startup fails", async () => {
|
||||
hoisted.startGoogleChatMonitor.mockRejectedValue(new Error("webhook bind failed"));
|
||||
const patches: ChannelAccountSnapshot[] = [];
|
||||
|
||||
const task = startGoogleChatGatewayAccount(
|
||||
createStartAccountContext({
|
||||
account: buildAccount(),
|
||||
statusPatchSink: (next) => patches.push({ ...next }),
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(task).rejects.toThrow("webhook bind failed");
|
||||
expectLifecyclePatch(patches, { running: true });
|
||||
expectLifecyclePatch(patches, { running: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGoogleChatAccount", () => {
|
||||
|
||||
@@ -315,24 +315,6 @@ describe("monitorLineProvider lifecycle", () => {
|
||||
monitor.stop();
|
||||
});
|
||||
|
||||
it("does not record running state when bot startup fails", async () => {
|
||||
createLineBotMock.mockImplementation(() => {
|
||||
throw new Error("line bot startup failed");
|
||||
});
|
||||
|
||||
await expect(
|
||||
monitorLineProvider({
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret", // pragma: allowlist secret
|
||||
config: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
}),
|
||||
).rejects.toThrow("line bot startup failed");
|
||||
|
||||
expect(getLineRuntimeState("default")?.running).not.toBe(true);
|
||||
expect(registerWebhookTargetWithPluginRouteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dispatches shared-path webhook posts to the account matching the signature", async () => {
|
||||
const firstMonitor = await monitorLineProvider({
|
||||
channelAccessToken: "first-token",
|
||||
|
||||
@@ -175,6 +175,15 @@ export async function monitorLineProvider(
|
||||
throw new Error("LINE webhook mode requires a non-empty channel secret.");
|
||||
}
|
||||
|
||||
recordChannelRuntimeState({
|
||||
channel: "line",
|
||||
accountId: resolvedAccountId,
|
||||
state: {
|
||||
running: true,
|
||||
lastStartAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
const bot = createLineBot({
|
||||
channelAccessToken: token,
|
||||
channelSecret: secret,
|
||||
@@ -464,15 +473,6 @@ export async function monitorLineProvider(
|
||||
},
|
||||
});
|
||||
|
||||
recordChannelRuntimeState({
|
||||
channel: "line",
|
||||
accountId: resolvedAccountId,
|
||||
state: {
|
||||
running: true,
|
||||
lastStartAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
logVerbose(`line: registered webhook handler at ${normalizedPath}`);
|
||||
|
||||
let stopped = false;
|
||||
|
||||
@@ -858,7 +858,6 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
|
||||
extra: {
|
||||
botTokenSource: account.botTokenSource,
|
||||
baseUrl: account.baseUrl,
|
||||
dmPolicy: account.config.dmPolicy ?? "pairing",
|
||||
connected: runtime?.connected ?? false,
|
||||
lastConnectedAt: runtime?.lastConnectedAt ?? null,
|
||||
lastDisconnect: runtime?.lastDisconnect ?? null,
|
||||
|
||||
@@ -30,21 +30,6 @@ describe("MattermostConfigSchema", () => {
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects dmPolicy="open" without wildcard allowFrom', () => {
|
||||
const result = MattermostConfigSchema.safeParse({
|
||||
dmPolicy: "open",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts dmPolicy="open" with wildcard allowFrom', () => {
|
||||
const result = MattermostConfigSchema.safeParse({
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts documented streaming modes and progress config", () => {
|
||||
const result = MattermostConfigSchema.safeParse({
|
||||
streaming: {
|
||||
|
||||
@@ -11,7 +11,6 @@ vi.mock("./runtime-api.js", () => ({
|
||||
|
||||
describe("mattermost monitor auth", () => {
|
||||
let authorizeMattermostCommandInvocation: typeof import("./monitor-auth.js").authorizeMattermostCommandInvocation;
|
||||
let formatMattermostDirectMessageDropLog: typeof import("./monitor-auth.js").formatMattermostDirectMessageDropLog;
|
||||
let isMattermostSenderAllowed: typeof import("./monitor-auth.js").isMattermostSenderAllowed;
|
||||
let normalizeMattermostAllowEntry: typeof import("./monitor-auth.js").normalizeMattermostAllowEntry;
|
||||
let normalizeMattermostAllowList: typeof import("./monitor-auth.js").normalizeMattermostAllowList;
|
||||
@@ -19,7 +18,6 @@ describe("mattermost monitor auth", () => {
|
||||
beforeAll(async () => {
|
||||
({
|
||||
authorizeMattermostCommandInvocation,
|
||||
formatMattermostDirectMessageDropLog,
|
||||
isMattermostSenderAllowed,
|
||||
normalizeMattermostAllowEntry,
|
||||
normalizeMattermostAllowList,
|
||||
@@ -60,18 +58,6 @@ describe("mattermost monitor auth", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("formats direct-message drops with the ingress reason and open-policy hint", () => {
|
||||
expect(
|
||||
formatMattermostDirectMessageDropLog({
|
||||
senderId: "alice-id",
|
||||
dmPolicy: "open",
|
||||
reasonCode: "dm_policy_not_allowlisted",
|
||||
}),
|
||||
).toBe(
|
||||
"mattermost: drop dm sender=alice-id (dmPolicy=open reason=dm_policy_not_allowlisted hint=add-allowFrom-wildcard)",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves direct command authorization from shared ingress", async () => {
|
||||
isDangerousNameMatchingEnabled.mockReturnValue(false);
|
||||
resolveAllowlistMatchSimple.mockReturnValue({ allowed: false });
|
||||
|
||||
@@ -61,19 +61,6 @@ export function normalizeMattermostAllowList(entries: Array<string | number>): s
|
||||
return uniqueStrings(normalized);
|
||||
}
|
||||
|
||||
export function formatMattermostDirectMessageDropLog(params: {
|
||||
senderId: string;
|
||||
dmPolicy: string;
|
||||
reasonCode?: string;
|
||||
}): string {
|
||||
const reason = params.reasonCode ? ` reason=${params.reasonCode}` : "";
|
||||
const hint =
|
||||
params.dmPolicy === "open" && params.reasonCode === "dm_policy_not_allowlisted"
|
||||
? " hint=add-allowFrom-wildcard"
|
||||
: "";
|
||||
return `mattermost: drop dm sender=${params.senderId} (dmPolicy=${params.dmPolicy}${reason}${hint})`;
|
||||
}
|
||||
|
||||
export function isMattermostSenderAllowed(params: {
|
||||
senderId: string;
|
||||
senderName?: string;
|
||||
|
||||
@@ -57,7 +57,6 @@ import {
|
||||
} from "./model-picker.js";
|
||||
import {
|
||||
authorizeMattermostCommandInvocation,
|
||||
formatMattermostDirectMessageDropLog,
|
||||
normalizeMattermostAllowEntry,
|
||||
resolveMattermostMonitorInboundAccess,
|
||||
} from "./monitor-auth.js";
|
||||
@@ -1392,13 +1391,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
logVerboseMessage(
|
||||
formatMattermostDirectMessageDropLog({
|
||||
senderId,
|
||||
dmPolicy,
|
||||
reasonCode: accessDecision.senderAccess.reasonCode,
|
||||
}),
|
||||
);
|
||||
logVerboseMessage(`mattermost: drop dm sender=${senderId} (dmPolicy=${dmPolicy})`);
|
||||
return;
|
||||
}
|
||||
if (accessDecision.ingress.reasonCode === "group_policy_disabled") {
|
||||
|
||||
@@ -94,57 +94,12 @@ function createActivityHandler() {
|
||||
return { handler, run };
|
||||
}
|
||||
|
||||
async function runAdaptiveCardInvoke(
|
||||
registered: MSTeamsActivityHandler & {
|
||||
run: NonNullable<MSTeamsActivityHandler["run"]>;
|
||||
},
|
||||
value: unknown,
|
||||
) {
|
||||
await registered.run({
|
||||
activity: {
|
||||
id: "invoke-1",
|
||||
type: "invoke",
|
||||
name: "adaptiveCard/action",
|
||||
channelId: "msteams",
|
||||
serviceUrl: "https://service.example.test",
|
||||
from: {
|
||||
id: "user-bf",
|
||||
aadObjectId: "user-aad",
|
||||
name: "User",
|
||||
},
|
||||
recipient: {
|
||||
id: "bot-id",
|
||||
name: "Bot",
|
||||
},
|
||||
conversation: {
|
||||
id: "19:personal-chat;messageid=abc123",
|
||||
conversationType: "personal",
|
||||
},
|
||||
channelData: {},
|
||||
attachments: [],
|
||||
value,
|
||||
},
|
||||
sendActivity: vi.fn(async () => ({ id: "activity-id" })),
|
||||
sendActivities: async () => [],
|
||||
} as unknown as MSTeamsTurnContext);
|
||||
}
|
||||
|
||||
function lastDispatchedCtxPayload(): Record<string, unknown> {
|
||||
const dispatched = runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher.mock.calls.at(
|
||||
-1,
|
||||
)?.[0] as { ctxPayload?: Record<string, unknown> } | undefined;
|
||||
if (!dispatched?.ctxPayload) {
|
||||
throw new Error("expected dispatched context payload");
|
||||
}
|
||||
return dispatched.ctxPayload;
|
||||
}
|
||||
|
||||
describe("msteams adaptive card action invoke", () => {
|
||||
beforeEach(() => {
|
||||
runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher.mockClear();
|
||||
});
|
||||
|
||||
it("forwards adaptive card submitted data to the agent as message text", async () => {
|
||||
it("forwards adaptive card invoke values to the agent as message text", async () => {
|
||||
const deps = createDeps();
|
||||
const { handler, run } = createActivityHandler();
|
||||
const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & {
|
||||
@@ -161,117 +116,44 @@ describe("msteams adaptive card action invoke", () => {
|
||||
trigger: "button-click",
|
||||
};
|
||||
|
||||
await runAdaptiveCardInvoke(registered, payload);
|
||||
await registered.run({
|
||||
activity: {
|
||||
id: "invoke-1",
|
||||
type: "invoke",
|
||||
name: "adaptiveCard/action",
|
||||
channelId: "msteams",
|
||||
serviceUrl: "https://service.example.test",
|
||||
from: {
|
||||
id: "user-bf",
|
||||
aadObjectId: "user-aad",
|
||||
name: "User",
|
||||
},
|
||||
recipient: {
|
||||
id: "bot-id",
|
||||
name: "Bot",
|
||||
},
|
||||
conversation: {
|
||||
id: "19:personal-chat;messageid=abc123",
|
||||
conversationType: "personal",
|
||||
},
|
||||
channelData: {},
|
||||
attachments: [],
|
||||
value: payload,
|
||||
},
|
||||
sendActivity: vi.fn(async () => ({ id: "activity-id" })),
|
||||
sendActivities: async () => [],
|
||||
} as unknown as MSTeamsTurnContext);
|
||||
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
expect(runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
const expectedBody = JSON.stringify(payload.action.data);
|
||||
const ctxPayload = lastDispatchedCtxPayload();
|
||||
expect(ctxPayload.RawBody).toBe(expectedBody);
|
||||
expect(ctxPayload.BodyForAgent).toBe(expectedBody);
|
||||
expect(ctxPayload.CommandBody).toBe(expectedBody);
|
||||
expect(ctxPayload.SessionKey).toBe("msteams:direct:user-aad");
|
||||
expect(ctxPayload.SenderId).toBe("user-aad");
|
||||
});
|
||||
|
||||
it("routes Teams imBack actions as the submitted message text", async () => {
|
||||
const deps = createDeps();
|
||||
const { handler } = createActivityHandler();
|
||||
const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & {
|
||||
run: NonNullable<MSTeamsActivityHandler["run"]>;
|
||||
};
|
||||
|
||||
await runAdaptiveCardInvoke(registered, {
|
||||
action: {
|
||||
type: "Action.Submit",
|
||||
data: { msteams: { type: "imBack", value: "Summarize my last meeting" } },
|
||||
},
|
||||
});
|
||||
|
||||
const ctxPayload = lastDispatchedCtxPayload();
|
||||
expect(ctxPayload.BodyForAgent).toBe("Summarize my last meeting");
|
||||
expect(ctxPayload.CommandBody).toBe("Summarize my last meeting");
|
||||
});
|
||||
|
||||
it("routes typed command submit actions as command text", async () => {
|
||||
const deps = createDeps();
|
||||
const { handler } = createActivityHandler();
|
||||
const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & {
|
||||
run: NonNullable<MSTeamsActivityHandler["run"]>;
|
||||
};
|
||||
|
||||
await runAdaptiveCardInvoke(registered, {
|
||||
action: {
|
||||
type: "Action.Submit",
|
||||
data: "/codex plugins menu",
|
||||
},
|
||||
});
|
||||
|
||||
const ctxPayload = lastDispatchedCtxPayload();
|
||||
expect(ctxPayload.BodyForAgent).toBe("/codex plugins menu");
|
||||
expect(ctxPayload.CommandBody).toBe("/codex plugins menu");
|
||||
});
|
||||
|
||||
it("preserves legacy presentation submit values as structured data", async () => {
|
||||
const deps = createDeps();
|
||||
const { handler } = createActivityHandler();
|
||||
const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & {
|
||||
run: NonNullable<MSTeamsActivityHandler["run"]>;
|
||||
};
|
||||
const data = { value: "/codex permissions yolo", label: "Run" };
|
||||
|
||||
await runAdaptiveCardInvoke(registered, {
|
||||
action: {
|
||||
type: "Action.Submit",
|
||||
data,
|
||||
},
|
||||
});
|
||||
|
||||
const ctxPayload = lastDispatchedCtxPayload();
|
||||
expect(ctxPayload.BodyForAgent).toBe(JSON.stringify(data));
|
||||
expect(ctxPayload.CommandBody).toBe(JSON.stringify(data));
|
||||
});
|
||||
|
||||
it("preserves arbitrary submitted data with a value field", async () => {
|
||||
const deps = createDeps();
|
||||
const { handler } = createActivityHandler();
|
||||
const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & {
|
||||
run: NonNullable<MSTeamsActivityHandler["run"]>;
|
||||
};
|
||||
const data = { value: "selected", formId: "deploy-approval", choices: ["canary"] };
|
||||
|
||||
await runAdaptiveCardInvoke(registered, {
|
||||
action: {
|
||||
type: "Action.Submit",
|
||||
data,
|
||||
},
|
||||
});
|
||||
|
||||
const ctxPayload = lastDispatchedCtxPayload();
|
||||
expect(ctxPayload.BodyForAgent).toBe(JSON.stringify(data));
|
||||
expect(ctxPayload.CommandBody).toBe(JSON.stringify(data));
|
||||
});
|
||||
|
||||
it("preserves generic Action.Execute verb metadata", async () => {
|
||||
const deps = createDeps();
|
||||
const { handler } = createActivityHandler();
|
||||
const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & {
|
||||
run: NonNullable<MSTeamsActivityHandler["run"]>;
|
||||
};
|
||||
const payload = {
|
||||
action: {
|
||||
type: "Action.Execute",
|
||||
verb: "ticket.approve",
|
||||
data: { ticketId: "ticket-123" },
|
||||
},
|
||||
};
|
||||
|
||||
await runAdaptiveCardInvoke(registered, payload);
|
||||
|
||||
const ctxPayload = lastDispatchedCtxPayload();
|
||||
expect(ctxPayload.BodyForAgent).toBe(JSON.stringify(payload));
|
||||
expect(ctxPayload.CommandBody).toBe(JSON.stringify(payload));
|
||||
const dispatched = runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher.mock
|
||||
.calls[0]?.[0] as { ctxPayload?: Record<string, unknown> } | undefined;
|
||||
expect(dispatched?.ctxPayload?.RawBody).toBe(JSON.stringify(payload));
|
||||
expect(dispatched?.ctxPayload?.BodyForAgent).toBe(JSON.stringify(payload));
|
||||
expect(dispatched?.ctxPayload?.CommandBody).toBe(JSON.stringify(payload));
|
||||
expect(dispatched?.ctxPayload?.SessionKey).toBe("msteams:direct:user-aad");
|
||||
expect(dispatched?.ctxPayload?.SenderId).toBe("user-aad");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
// Msteams plugin module implements monitor handler behavior.
|
||||
import {
|
||||
isRecord,
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { formatUnknownError } from "./errors.js";
|
||||
import { resolveMSTeamsSenderAccess } from "./monitor-handler/access.js";
|
||||
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
|
||||
@@ -29,43 +25,16 @@ export type MSTeamsActivityHandler = {
|
||||
run?: (context: unknown) => Promise<void>;
|
||||
};
|
||||
|
||||
function extractAdaptiveCardSubmittedData(value: unknown): unknown {
|
||||
if (!isRecord(value)) {
|
||||
return value;
|
||||
}
|
||||
const action = isRecord(value.action) ? value.action : undefined;
|
||||
if (action && normalizeOptionalLowercaseString(action.type) === "action.submit" && "data" in action) {
|
||||
return action.data;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readMSTeamsImBackValue(value: unknown): string | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
const msteams = isRecord(value.msteams) ? value.msteams : undefined;
|
||||
if (!msteams || normalizeOptionalLowercaseString(msteams.type) !== "imback") {
|
||||
return null;
|
||||
}
|
||||
return normalizeOptionalString(msteams.value) ?? null;
|
||||
}
|
||||
|
||||
function serializeAdaptiveCardActionValue(value: unknown): string | null {
|
||||
const submittedValue = extractAdaptiveCardSubmittedData(value);
|
||||
if (typeof submittedValue === "string") {
|
||||
const trimmed = submittedValue.trim();
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
const imBackValue = readMSTeamsImBackValue(submittedValue);
|
||||
if (imBackValue) {
|
||||
return imBackValue;
|
||||
}
|
||||
if (submittedValue == null) {
|
||||
if (value === undefined) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(submittedValue);
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
// Qa Channel tests cover gateway lifecycle behavior.
|
||||
import { createServer } from "node:http";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { startQaGatewayAccount } from "./gateway.js";
|
||||
import type { ChannelGatewayContext } from "./runtime-api.js";
|
||||
import type { ResolvedQaChannelAccount } from "./types.js";
|
||||
|
||||
async function startJsonServer(
|
||||
handler: (req: { url?: string | undefined }) => { statusCode?: number; body: string },
|
||||
) {
|
||||
const server = createServer((req, res) => {
|
||||
const response = handler({ url: req.url });
|
||||
res.writeHead(response.statusCode ?? 200, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
});
|
||||
res.end(response.body);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("test server failed to bind");
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
async stop() {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("qa-channel gateway", () => {
|
||||
const stops: Array<() => Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(stops.splice(0).map((stop) => stop()));
|
||||
});
|
||||
|
||||
it("clears running status when polling fails", async () => {
|
||||
const server = await startJsonServer(() => ({
|
||||
statusCode: 500,
|
||||
body: JSON.stringify({ error: "qa bus unavailable" }),
|
||||
}));
|
||||
stops.push(() => server.stop());
|
||||
const account: ResolvedQaChannelAccount = {
|
||||
accountId: "default",
|
||||
baseUrl: server.baseUrl,
|
||||
botDisplayName: "QA Bot",
|
||||
botUserId: "qa-bot",
|
||||
config: {},
|
||||
configured: true,
|
||||
enabled: true,
|
||||
pollTimeoutMs: 1,
|
||||
};
|
||||
const setStatus = vi.fn();
|
||||
|
||||
await expect(
|
||||
startQaGatewayAccount("qa-channel", "QA Channel", {
|
||||
abortSignal: new AbortController().signal,
|
||||
account,
|
||||
cfg: {},
|
||||
setStatus,
|
||||
} as unknown as ChannelGatewayContext<ResolvedQaChannelAccount>),
|
||||
).rejects.toThrow("qa bus unavailable");
|
||||
|
||||
expect(setStatus.mock.calls.map(([status]) => status)).toEqual([
|
||||
{
|
||||
accountId: "default",
|
||||
baseUrl: server.baseUrl,
|
||||
configured: true,
|
||||
enabled: true,
|
||||
running: true,
|
||||
},
|
||||
{
|
||||
accountId: "default",
|
||||
running: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -48,10 +48,9 @@ export async function startQaGatewayAccount(
|
||||
if (!(error instanceof Error) || error.name !== "AbortError") {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
running: false,
|
||||
});
|
||||
}
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
running: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@ vi.mock("./model-selection.runtime.js", () => ({
|
||||
import { resolveRepoRelativeOutputDir } from "./cli-paths.js";
|
||||
import {
|
||||
runQaLabSelfCheckCommand,
|
||||
runQaCredentialsAddCommand,
|
||||
runQaDockerBuildImageCommand,
|
||||
runQaDockerScaffoldCommand,
|
||||
runQaDockerUpCommand,
|
||||
@@ -2189,30 +2188,6 @@ describe("qa cli runtime", () => {
|
||||
expectWriteContains(stdoutWrite, "QA self-check report: /tmp/failed-report.md");
|
||||
});
|
||||
|
||||
it("rejects oversized credential payload files before broker setup", async () => {
|
||||
const previousMaxBytes = process.env.OPENCLAW_QA_CREDENTIAL_PAYLOAD_MAX_BYTES;
|
||||
const payloadPath = path.join(suiteArtifactsDir, "oversized-credential.json");
|
||||
await fs.writeFile(payloadPath, JSON.stringify({ blob: "x".repeat(64) }), "utf8");
|
||||
process.env.OPENCLAW_QA_CREDENTIAL_PAYLOAD_MAX_BYTES = "32";
|
||||
|
||||
try {
|
||||
await expect(
|
||||
runQaCredentialsAddCommand({
|
||||
kind: "telegram",
|
||||
payloadFile: payloadPath,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Payload file exceeds OPENCLAW_QA_CREDENTIAL_PAYLOAD_MAX_BYTES (32 bytes).",
|
||||
);
|
||||
} finally {
|
||||
if (previousMaxBytes === undefined) {
|
||||
delete process.env.OPENCLAW_QA_CREDENTIAL_PAYLOAD_MAX_BYTES;
|
||||
} else {
|
||||
process.env.OPENCLAW_QA_CREDENTIAL_PAYLOAD_MAX_BYTES = previousMaxBytes;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves docker scaffold paths relative to the explicit repo root", async () => {
|
||||
await runQaDockerScaffoldCommand({
|
||||
repoRoot: "/tmp/openclaw-repo",
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
buildQaAgenticParityComparison,
|
||||
@@ -91,8 +90,6 @@ import {
|
||||
} from "./tool-coverage-report.js";
|
||||
|
||||
const QA_SUITE_INFRA_RETRY_LIMIT = 1;
|
||||
const QA_CREDENTIAL_PAYLOAD_MAX_BYTES_ENV = "OPENCLAW_QA_CREDENTIAL_PAYLOAD_MAX_BYTES";
|
||||
const DEFAULT_QA_CREDENTIAL_PAYLOAD_MAX_BYTES = 64 * 1024 * 1024;
|
||||
const QA_SUITE_INFRA_RETRY_NETWORK_ERROR_CODES = new Set([
|
||||
"ECONNRESET",
|
||||
"ECONNREFUSED",
|
||||
@@ -546,29 +543,7 @@ async function runInterruptibleServer(label: string, server: InterruptibleServer
|
||||
await new Promise(() => {});
|
||||
}
|
||||
|
||||
function resolveQaCredentialPayloadFileMaxBytes(env: NodeJS.ProcessEnv = process.env) {
|
||||
const raw = env[QA_CREDENTIAL_PAYLOAD_MAX_BYTES_ENV]?.trim();
|
||||
if (!raw) {
|
||||
return DEFAULT_QA_CREDENTIAL_PAYLOAD_MAX_BYTES;
|
||||
}
|
||||
const parsed = parseStrictPositiveInteger(raw);
|
||||
if (parsed === undefined) {
|
||||
throw new Error(`${QA_CREDENTIAL_PAYLOAD_MAX_BYTES_ENV} must be a positive integer.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function readQaCredentialPayloadFile(filePath: string) {
|
||||
const maxBytes = resolveQaCredentialPayloadFileMaxBytes();
|
||||
const stat = await fs.stat(filePath);
|
||||
if (!stat.isFile()) {
|
||||
throw new Error("Payload file must be a regular JSON file.");
|
||||
}
|
||||
if (stat.size > maxBytes) {
|
||||
throw new Error(
|
||||
`Payload file exceeds ${QA_CREDENTIAL_PAYLOAD_MAX_BYTES_ENV} (${maxBytes} bytes).`,
|
||||
);
|
||||
}
|
||||
const text = await fs.readFile(filePath, "utf8");
|
||||
let payload: unknown;
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Qa Lab tests cover docker up plugin behavior.
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { createServer } from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
@@ -7,6 +8,31 @@ import { runQaDockerUp } from "./docker-up.runtime.js";
|
||||
|
||||
type QaDockerUpDeps = NonNullable<Parameters<typeof runQaDockerUp>[1]>;
|
||||
|
||||
async function occupyPortOrAcceptExisting(port: number): Promise<{ close: () => Promise<void> }> {
|
||||
const server = createServer();
|
||||
const listening = await new Promise<boolean>((resolve, reject) => {
|
||||
server.once("error", (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "EADDRINUSE") {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
server.listen(port, "127.0.0.1", () => resolve(true));
|
||||
});
|
||||
|
||||
return {
|
||||
close: async () => {
|
||||
if (!listening) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createHealthyDockerDeps(calls: string[]): QaDockerUpDeps {
|
||||
return {
|
||||
async runCommand(command, args, cwd) {
|
||||
@@ -137,8 +163,7 @@ describe("runQaDockerUp", () => {
|
||||
const outputDir = await mkdtemp(path.join(os.tmpdir(), "qa-docker-up-"));
|
||||
const gatewayPort = 18789;
|
||||
const qaLabPort = 43124;
|
||||
const resolveHostPort = vi.fn(async (preferredPort: number, pinned: boolean) => {
|
||||
expect(pinned).toBe(false);
|
||||
const resolveHostPort = vi.fn(async (preferredPort: number) => {
|
||||
if (preferredPort === gatewayPort) {
|
||||
return 28001;
|
||||
}
|
||||
@@ -147,12 +172,16 @@ describe("runQaDockerUp", () => {
|
||||
}
|
||||
return preferredPort;
|
||||
});
|
||||
const gatewayPortReservation = await occupyPortOrAcceptExisting(18789);
|
||||
const qaLabPortReservation = await occupyPortOrAcceptExisting(43124);
|
||||
|
||||
try {
|
||||
const result = await runQaDockerUp(
|
||||
{
|
||||
repoRoot: "/repo/openclaw",
|
||||
outputDir,
|
||||
gatewayPort,
|
||||
qaLabPort,
|
||||
skipUiBuild: true,
|
||||
usePrebuiltImage: true,
|
||||
},
|
||||
@@ -173,9 +202,9 @@ describe("runQaDockerUp", () => {
|
||||
expect(result.qaLabUrl).not.toBe(`http://127.0.0.1:${qaLabPort}`);
|
||||
expect(result.gatewayUrl).toBe("http://127.0.0.1:28001/");
|
||||
expect(result.qaLabUrl).toBe("http://127.0.0.1:28002");
|
||||
expect(resolveHostPort).toHaveBeenCalledWith(gatewayPort, false);
|
||||
expect(resolveHostPort).toHaveBeenCalledWith(qaLabPort, false);
|
||||
} finally {
|
||||
await gatewayPortReservation.close();
|
||||
await qaLabPortReservation.close();
|
||||
await rm(outputDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -229,7 +258,7 @@ describe("runQaDockerUp", () => {
|
||||
`docker compose -f ${composeFile} up -d @${repoRoot}`,
|
||||
`docker compose -f ${composeFile} ps --format json openclaw-qa-gateway @${repoRoot}`,
|
||||
`docker compose -f ${composeFile} ps -q openclaw-qa-gateway @${repoRoot}`,
|
||||
`docker inspect --format {{range .NetworkSettings.Networks}}{{println .IPAddress}}{{end}} gateway-container @${repoRoot}`,
|
||||
`docker inspect --format {{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}} gateway-container @${repoRoot}`,
|
||||
]);
|
||||
expect(fetchCalls).toEqual([
|
||||
"http://127.0.0.1:43124/healthz",
|
||||
|
||||
@@ -978,47 +978,6 @@ describe("buildQaRuntimeEnv", () => {
|
||||
expect([child.exitCode, child.signalCode]).not.toEqual([null, null]);
|
||||
});
|
||||
|
||||
it("does not trust an exited gateway wrapper while its process group is alive", async () => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
pid: 12346,
|
||||
exitCode: 0 as number | null,
|
||||
signalCode: null as string | null,
|
||||
kill: vi.fn(),
|
||||
});
|
||||
let sawForceKill = false;
|
||||
let postKillLivenessChecks = 0;
|
||||
const processKill = vi.spyOn(process, "kill").mockImplementation((_pid, signal) => {
|
||||
if (signal === "SIGKILL") {
|
||||
sawForceKill = true;
|
||||
return true;
|
||||
}
|
||||
if (signal === 0 && sawForceKill) {
|
||||
postKillLivenessChecks += 1;
|
||||
if (postKillLivenessChecks >= 2) {
|
||||
throw Object.assign(new Error("no such process"), { code: "ESRCH" });
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
await testing.stopQaGatewayChildProcessTree(
|
||||
child as unknown as Parameters<typeof testing.stopQaGatewayChildProcessTree>[0],
|
||||
{
|
||||
gracefulTimeoutMs: 1,
|
||||
forceTimeoutMs: 50,
|
||||
},
|
||||
);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(processKill).toHaveBeenCalledWith(-12346, "SIGTERM");
|
||||
expect(processKill).toHaveBeenCalledWith(-12346, "SIGKILL");
|
||||
expect(postKillLivenessChecks).toBe(2);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("treats bind collisions as retryable gateway startup errors", () => {
|
||||
expect(
|
||||
testing.isRetryableGatewayStartupError(
|
||||
|
||||
@@ -354,28 +354,6 @@ function hasChildExited(child: ChildProcess) {
|
||||
return child.exitCode !== null || child.signalCode !== null;
|
||||
}
|
||||
|
||||
function isProcessAlreadyExitedError(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | undefined)?.code === "ESRCH";
|
||||
}
|
||||
|
||||
function isQaGatewayChildProcessTreeAlive(child: ChildProcess) {
|
||||
if (!child.pid) {
|
||||
return false;
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return !hasChildExited(child);
|
||||
}
|
||||
try {
|
||||
process.kill(-child.pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isProcessAlreadyExitedError(error)) {
|
||||
return false;
|
||||
}
|
||||
return !hasChildExited(child);
|
||||
}
|
||||
}
|
||||
|
||||
function signalQaGatewayChildProcessTree(child: ChildProcess, signal: NodeJS.Signals) {
|
||||
if (!child.pid) {
|
||||
return;
|
||||
@@ -396,21 +374,22 @@ function signalQaGatewayChildProcessTree(child: ChildProcess, signal: NodeJS.Sig
|
||||
}
|
||||
|
||||
async function waitForQaGatewayChildExit(child: ChildProcess, timeoutMs: number) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() <= deadline) {
|
||||
if (!isQaGatewayChildProcessTreeAlive(child)) {
|
||||
return true;
|
||||
}
|
||||
await sleep(Math.min(25, Math.max(0, deadline - Date.now())));
|
||||
if (hasChildExited(child)) {
|
||||
return true;
|
||||
}
|
||||
return !isQaGatewayChildProcessTreeAlive(child);
|
||||
return await Promise.race([
|
||||
new Promise<boolean>((resolve) => {
|
||||
child.once("exit", () => resolve(true));
|
||||
}),
|
||||
sleep(timeoutMs).then(() => false),
|
||||
]);
|
||||
}
|
||||
|
||||
async function stopQaGatewayChildProcessTree(
|
||||
child: ChildProcess,
|
||||
opts?: { gracefulTimeoutMs?: number; forceTimeoutMs?: number },
|
||||
) {
|
||||
if (!isQaGatewayChildProcessTreeAlive(child)) {
|
||||
if (hasChildExited(child)) {
|
||||
return;
|
||||
}
|
||||
signalQaGatewayChildProcessTree(child, "SIGTERM");
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createServer } from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { readQaJsonBody } from "./bus-server.js";
|
||||
import {
|
||||
startQaLabServer,
|
||||
@@ -12,23 +12,7 @@ import {
|
||||
type QaLabServerStartParams,
|
||||
} from "./lab-server.js";
|
||||
|
||||
const qaChannelMock = vi.hoisted(() => ({
|
||||
resolveAccount: vi.fn(),
|
||||
setRuntime: vi.fn(),
|
||||
startAccount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./runtime-api.js", () => ({
|
||||
qaChannelPlugin: {
|
||||
config: {
|
||||
resolveAccount: qaChannelMock.resolveAccount,
|
||||
},
|
||||
gateway: {
|
||||
startAccount: qaChannelMock.startAccount,
|
||||
},
|
||||
},
|
||||
setQaChannelRuntime: qaChannelMock.setRuntime,
|
||||
}));
|
||||
vi.mock("@openclaw/qa-channel/api.js", async () => await import("../../qa-channel/api.js"));
|
||||
|
||||
const captureMock = vi.hoisted(() => {
|
||||
const sessions: Array<Record<string, unknown>> = [];
|
||||
@@ -166,31 +150,6 @@ async function startQaLabServerForTest(params?: QaLabServerStartParams) {
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
qaChannelMock.resolveAccount.mockReset();
|
||||
qaChannelMock.resolveAccount.mockImplementation((_cfg: unknown, accountId: string) => ({
|
||||
accountId,
|
||||
configured: true,
|
||||
enabled: true,
|
||||
}));
|
||||
qaChannelMock.setRuntime.mockReset();
|
||||
qaChannelMock.startAccount.mockReset();
|
||||
qaChannelMock.startAccount.mockImplementation(
|
||||
async ({ abortSignal }: { abortSignal?: AbortSignal }) =>
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!abortSignal) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (abortSignal.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
abortSignal.addEventListener("abort", () => resolve(), { once: true });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
captureMock.reset();
|
||||
while (cleanups.length > 0) {
|
||||
@@ -330,51 +289,6 @@ async function createQaLabRepoRootFixture(params?: {
|
||||
}
|
||||
|
||||
describe("qa-lab server", () => {
|
||||
it("cleans up capture state when embedded gateway setup fails", async () => {
|
||||
qaChannelMock.resolveAccount.mockImplementationOnce(() => {
|
||||
throw new Error("embedded setup failed");
|
||||
});
|
||||
|
||||
await expect(
|
||||
startQaLabServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
}),
|
||||
).rejects.toThrow("embedded setup failed");
|
||||
|
||||
expect(captureMock.store.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("closes the server and capture state when embedded gateway stop fails", async () => {
|
||||
qaChannelMock.startAccount.mockImplementationOnce(
|
||||
async ({ abortSignal }: { abortSignal?: AbortSignal }) =>
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
if (!abortSignal) {
|
||||
return;
|
||||
}
|
||||
if (abortSignal.aborted) {
|
||||
reject(new Error("gateway stop failed"));
|
||||
return;
|
||||
}
|
||||
abortSignal.addEventListener(
|
||||
"abort",
|
||||
() => reject(new Error("gateway stop failed")),
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const lab = await startQaLabServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
});
|
||||
|
||||
await expect(lab.stop()).rejects.toThrow("gateway stop failed");
|
||||
|
||||
expect(captureMock.store.close).toHaveBeenCalledTimes(1);
|
||||
await expect(fetch(`${lab.baseUrl}/healthz`)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("serves bootstrap state and message state", async () => {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "qa-lab-test-"));
|
||||
cleanups.push(async () => {
|
||||
|
||||
@@ -168,10 +168,6 @@ function createQaLabConfig(baseUrl: string): OpenClawConfig {
|
||||
return createQaChannelGatewayConfig({ baseUrl });
|
||||
}
|
||||
|
||||
function normalizeQaLabCleanupError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(formatErrorMessage(error));
|
||||
}
|
||||
|
||||
async function startQaGatewayLoop(params: { state: QaBusState; baseUrl: string }) {
|
||||
const runtime = createQaRunnerRuntime();
|
||||
setQaChannelRuntime(runtime);
|
||||
@@ -246,10 +242,7 @@ export async function startQaLabServer(
|
||||
| undefined;
|
||||
const embeddedGatewayEnabled = params?.embeddedGateway !== "disabled";
|
||||
let labHandle: QaLabServerHandle | null = null;
|
||||
let captureStoreReleased = false;
|
||||
let serverListening = false;
|
||||
|
||||
let listenUrl = "";
|
||||
let publicBaseUrl = "";
|
||||
let runnerModelCatalogPromise: Promise<void> | null = null;
|
||||
let runnerModelCatalogAbort: AbortController | null = null;
|
||||
@@ -635,107 +628,82 @@ export async function startQaLabServer(
|
||||
})();
|
||||
});
|
||||
|
||||
const releaseCaptureStore = () => {
|
||||
if (captureStoreReleased) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(params?.port ?? 0, params?.host ?? "127.0.0.1", () => resolve());
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("qa-lab failed to bind");
|
||||
}
|
||||
const listenUrl = resolveAdvertisedBaseUrl({
|
||||
bindHost: params?.host ?? "127.0.0.1",
|
||||
bindPort: address.port,
|
||||
});
|
||||
publicBaseUrl = resolveAdvertisedBaseUrl({
|
||||
bindHost: params?.host ?? "127.0.0.1",
|
||||
bindPort: address.port,
|
||||
advertiseHost: params?.advertiseHost,
|
||||
advertisePort: params?.advertisePort,
|
||||
});
|
||||
if (embeddedGatewayEnabled) {
|
||||
gateway = await startQaGatewayLoop({ state, baseUrl: listenUrl });
|
||||
}
|
||||
if (params?.sendKickoffOnStart) {
|
||||
injectKickoffMessage({
|
||||
state,
|
||||
defaults: bootstrapDefaults,
|
||||
kickoffTask: scenarioCatalog.kickoffTask,
|
||||
});
|
||||
}
|
||||
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (!controlUiProxyTarget || !isControlUiProxyPath(url.pathname)) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
captureStoreReleased = true;
|
||||
captureStoreLease.release();
|
||||
proxyUpgradeRequest({
|
||||
req,
|
||||
socket,
|
||||
head,
|
||||
target: controlUiProxyTarget,
|
||||
authorizationToken: controlUiProxyToken,
|
||||
});
|
||||
});
|
||||
|
||||
const lab = {
|
||||
baseUrl: publicBaseUrl,
|
||||
listenUrl,
|
||||
state,
|
||||
setControlUi(next: {
|
||||
controlUiUrl?: string | null;
|
||||
controlUiProxyToken?: string | null;
|
||||
controlUiProxyTarget?: string | null;
|
||||
}) {
|
||||
controlUiUrl = sanitizeControlUiPublicUrl(next.controlUiUrl?.trim() || null);
|
||||
controlUiProxyToken = next.controlUiProxyToken?.trim() || null;
|
||||
controlUiProxyTarget = next.controlUiProxyTarget?.trim()
|
||||
? new URL(next.controlUiProxyTarget)
|
||||
: null;
|
||||
},
|
||||
setScenarioRun(next: Omit<QaLabScenarioRun, "counts"> | null) {
|
||||
latestScenarioRun = next ? withQaLabRunCounts(next) : null;
|
||||
},
|
||||
setLatestReport(next: QaLabLatestReport | null) {
|
||||
latestReport = next;
|
||||
},
|
||||
runSelfCheck,
|
||||
async stop() {
|
||||
runnerModelCatalogAbort?.abort();
|
||||
await runnerModelCatalogPromise?.catch(() => undefined);
|
||||
await gateway?.stop();
|
||||
await closeQaHttpServer(server);
|
||||
captureStoreLease.release();
|
||||
},
|
||||
};
|
||||
|
||||
const stopLabServerResources = async (): Promise<Error | undefined> => {
|
||||
runnerModelCatalogAbort?.abort();
|
||||
await runnerModelCatalogPromise?.catch(() => undefined);
|
||||
const results = await Promise.allSettled([
|
||||
Promise.resolve().then(() => gateway?.stop()),
|
||||
Promise.resolve().then(() => (serverListening ? closeQaHttpServer(server) : undefined)),
|
||||
Promise.resolve().then(releaseCaptureStore),
|
||||
]);
|
||||
const failed = results.find((result) => result.status === "rejected");
|
||||
return failed ? normalizeQaLabCleanupError(failed.reason) : undefined;
|
||||
};
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(params?.port ?? 0, params?.host ?? "127.0.0.1", () => resolve());
|
||||
});
|
||||
serverListening = true;
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("qa-lab failed to bind");
|
||||
}
|
||||
listenUrl = resolveAdvertisedBaseUrl({
|
||||
bindHost: params?.host ?? "127.0.0.1",
|
||||
bindPort: address.port,
|
||||
});
|
||||
publicBaseUrl = resolveAdvertisedBaseUrl({
|
||||
bindHost: params?.host ?? "127.0.0.1",
|
||||
bindPort: address.port,
|
||||
advertiseHost: params?.advertiseHost,
|
||||
advertisePort: params?.advertisePort,
|
||||
});
|
||||
if (embeddedGatewayEnabled) {
|
||||
gateway = await startQaGatewayLoop({ state, baseUrl: listenUrl });
|
||||
}
|
||||
if (params?.sendKickoffOnStart) {
|
||||
injectKickoffMessage({
|
||||
state,
|
||||
defaults: bootstrapDefaults,
|
||||
kickoffTask: scenarioCatalog.kickoffTask,
|
||||
});
|
||||
}
|
||||
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (!controlUiProxyTarget || !isControlUiProxyPath(url.pathname)) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
proxyUpgradeRequest({
|
||||
req,
|
||||
socket,
|
||||
head,
|
||||
target: controlUiProxyTarget,
|
||||
authorizationToken: controlUiProxyToken,
|
||||
});
|
||||
});
|
||||
|
||||
const lab = {
|
||||
baseUrl: publicBaseUrl,
|
||||
listenUrl,
|
||||
state,
|
||||
setControlUi(next: {
|
||||
controlUiUrl?: string | null;
|
||||
controlUiProxyToken?: string | null;
|
||||
controlUiProxyTarget?: string | null;
|
||||
}) {
|
||||
controlUiUrl = sanitizeControlUiPublicUrl(next.controlUiUrl?.trim() || null);
|
||||
controlUiProxyToken = next.controlUiProxyToken?.trim() || null;
|
||||
controlUiProxyTarget = next.controlUiProxyTarget?.trim()
|
||||
? new URL(next.controlUiProxyTarget)
|
||||
: null;
|
||||
},
|
||||
setScenarioRun(next: Omit<QaLabScenarioRun, "counts"> | null) {
|
||||
latestScenarioRun = next ? withQaLabRunCounts(next) : null;
|
||||
},
|
||||
setLatestReport(next: QaLabLatestReport | null) {
|
||||
latestReport = next;
|
||||
},
|
||||
runSelfCheck,
|
||||
async stop() {
|
||||
const cleanupError = await stopLabServerResources();
|
||||
if (cleanupError) {
|
||||
throw cleanupError;
|
||||
}
|
||||
},
|
||||
};
|
||||
labHandle = lab;
|
||||
return lab;
|
||||
} catch (error) {
|
||||
await stopLabServerResources().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
labHandle = lab;
|
||||
return lab;
|
||||
}
|
||||
|
||||
function serializeSelfCheck(result: QaSelfCheckResult) {
|
||||
|
||||
@@ -118,44 +118,6 @@ describe("telegram live qa runtime", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("waits until the Telegram channel account is connected", async () => {
|
||||
const gateway = {
|
||||
call: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
channelAccounts: {
|
||||
telegram: [
|
||||
{
|
||||
accountId: "sut",
|
||||
connected: false,
|
||||
restartPending: false,
|
||||
running: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
channelAccounts: {
|
||||
telegram: [
|
||||
{
|
||||
accountId: "sut",
|
||||
connected: true,
|
||||
restartPending: false,
|
||||
running: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
await testing.waitForTelegramChannelRunning(gateway as never, "sut", {
|
||||
pollMs: 1,
|
||||
timeoutMs: 100,
|
||||
});
|
||||
|
||||
expect(gateway.call).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("normalizes the Telegram QA canary timeout env", () => {
|
||||
expect(testing.resolveTelegramQaCanaryTimeoutMs({})).toBe(30_000);
|
||||
expect(
|
||||
|
||||
@@ -1229,15 +1229,9 @@ function assertTelegramScenarioMessageSet(params: {
|
||||
async function waitForTelegramChannelRunning(
|
||||
gateway: Awaited<ReturnType<typeof startQaGatewayChild>>,
|
||||
accountId: string,
|
||||
options?: {
|
||||
pollMs?: number;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
) {
|
||||
const startedAt = Date.now();
|
||||
const timeoutMs = options?.timeoutMs ?? 45_000;
|
||||
const pollMs = options?.pollMs ?? 500;
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
while (Date.now() - startedAt < 45_000) {
|
||||
try {
|
||||
const payload = (await gateway.call(
|
||||
"channels.status",
|
||||
@@ -1246,24 +1240,19 @@ async function waitForTelegramChannelRunning(
|
||||
)) as {
|
||||
channelAccounts?: Record<
|
||||
string,
|
||||
Array<{
|
||||
accountId?: string;
|
||||
connected?: boolean;
|
||||
running?: boolean;
|
||||
restartPending?: boolean;
|
||||
}>
|
||||
Array<{ accountId?: string; running?: boolean; restartPending?: boolean }>
|
||||
>;
|
||||
};
|
||||
const accounts = payload.channelAccounts?.telegram ?? [];
|
||||
const match = accounts.find((entry) => entry.accountId === accountId);
|
||||
if (match?.running && match.connected === true && match.restartPending !== true) {
|
||||
if (match?.running && match.restartPending !== true) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// retry
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, pollMs);
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
}
|
||||
throw new Error(`telegram account "${accountId}" did not become ready`);
|
||||
@@ -2265,7 +2254,6 @@ export const testing = {
|
||||
shouldLogTelegramQaLiveProgress,
|
||||
formatTelegramQaProgressDetails,
|
||||
renderTelegramQaMarkdown,
|
||||
waitForTelegramChannelRunning,
|
||||
waitForObservedMessage,
|
||||
};
|
||||
export { testing as __testing };
|
||||
|
||||
@@ -129,46 +129,6 @@ describe("runQaManualLane", () => {
|
||||
expect(result.reply).toBe("Protocol note: mock reply.");
|
||||
});
|
||||
|
||||
it("cleans up lab and mock provider when gateway startup fails", async () => {
|
||||
startQaGatewayChild.mockRejectedValueOnce(new Error("gateway startup failed"));
|
||||
|
||||
await expect(
|
||||
runQaManualLane({
|
||||
repoRoot: "/tmp/openclaw-repo",
|
||||
providerMode: "mock-openai",
|
||||
primaryModel: "mock-openai/gpt-5.5",
|
||||
alternateModel: "mock-openai/gpt-5.5-alt",
|
||||
message: "check the kickoff file",
|
||||
timeoutMs: 5_000,
|
||||
replySettleMs: 0,
|
||||
}),
|
||||
).rejects.toThrow("gateway startup failed");
|
||||
|
||||
expect(gatewayStop).not.toHaveBeenCalled();
|
||||
expect(mockStop).toHaveBeenCalledTimes(1);
|
||||
expect(labStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("continues provider and lab teardown when gateway stop fails", async () => {
|
||||
gatewayStop.mockRejectedValueOnce(new Error("gateway stop failed"));
|
||||
|
||||
await expect(
|
||||
runQaManualLane({
|
||||
repoRoot: "/tmp/openclaw-repo",
|
||||
providerMode: "mock-openai",
|
||||
primaryModel: "mock-openai/gpt-5.5",
|
||||
alternateModel: "mock-openai/gpt-5.5-alt",
|
||||
message: "check the kickoff file",
|
||||
timeoutMs: 5_000,
|
||||
replySettleMs: 0,
|
||||
}),
|
||||
).rejects.toThrow("gateway stop failed");
|
||||
|
||||
expect(gatewayStop).toHaveBeenCalledTimes(1);
|
||||
expect(mockStop).toHaveBeenCalledTimes(1);
|
||||
expect(labStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("caps the gateway client timeout for oversized manual waits", async () => {
|
||||
const result = await runQaManualLane({
|
||||
repoRoot: "/tmp/openclaw-repo",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user