Compare commits

..

1 Commits

Author SHA1 Message Date
Vincent Koc
a0a9501990 fix(plugins): restore release package compatibility 2026-06-17 15:04:16 +08:00
227 changed files with 1023 additions and 6569 deletions

View File

@@ -26,7 +26,6 @@ Docs: https://docs.openclaw.ai
- Channels and delivery: preserve account-scoped DM channel send policy, intentional rich-message line breaks in Telegram and status output, rich Telegram final replies, rich Telegram tables and lists, Telegram thread-create CLI remapping, Feishu dynamic-agent routes after persisted binding reuse, Slack outbound `message_sent` hooks, contributed message-tool schema optionality, same-channel generated media completions, and channel chunking around surrogate pairs and Infinity limits. (#92788, #93164, #92679, #89421, #89943, #42837, #92814, #91137, #91246, #92735) Thanks @yetval, @obviyus, @spacegeologist, @rishitamrakar, @liuhao1024, @lundog, @TurboTheTurtle, and @yhterrance.
- Gemini CLI: use the selected OpenClaw OAuth/API-key auth profile in an isolated Gemini CLI runtime home, preventing ambient Google machine credentials from overriding the chosen profile. (#88748) Thanks @jason-allen-oneal and @shakkernerd.
- Feishu: fetch quoted/replied message content before the empty-message guard so a mention-only reply that quotes a message with meaningful content is no longer dropped. (#90192) Thanks @bladin.
- Discord: give generated auto-thread titles a 60-second timeout and 4,096-token reasoning-model output budget, clamped to the selected model output cap. (#64734) Thanks @hanamizuki.
- Agent, cron, and Gateway runtime: mark active main sessions before restart shutdown aborts, pause yielded subagent runs whose terminal also signals abort, clamp trusted subagent thinking overrides through provider/model fallback, preserve yielded media completions, deliver channel message-tool final replies through auto-reply while hiding internal delivery hints, restore reset archive fallback reads when active async transcripts are missing, de-duplicate main-session heartbeat events, expose session identity in runtime prompts, reject unknown OpenAI agent selectors, keep generated media completions, slash-command block replies, and trajectory export commands in WebChat, and require admin privileges for HTTP session/model override surfaces. (#91357, #92631, #92412, #92146, #92879, #91287, #92468, #92510, #91246, #92651, #92646) Thanks @ooiuuii, @openperf, @IWhatsskill, @masatohoshino, @CadanHu, @ZengWen-DT, @zhangguiping-xydt, and @TurboTheTurtle.
- Providers and model replay: preserve storeless OpenAI Responses replay compatibility, recover invalid OpenAI reasoning signatures and genericized Anthropic thinking-signature replay errors, route OAuth image defaults through Codex for eligible OpenAI profiles, avoid eager tool streaming for Claude 4.5 in Copilot, quarantine unreadable and post-hook OpenAI/Anthropic-family tool schemas without broadening allowed tool choices, deliver explicit thinking-off requests to LM Studio binary-thinking models, honor profile auth for SecretRef model entries, bound model browsing, strip provider prefixes where runtimes need bare IDs, and surface nested embedding fetch failures. (#90706, #92941, #92201, #92916, #92824, #75393, #92908, #92921, #92928, #92002, #90686, #92247, #92627, #91218, #92628) Thanks @snowzlm, @mmyzwl, @CarlCapital, @bek91, @Kailigithub, @vincentkoc, @rohitjavvadi, @samson910022, @nxmxbbd, @liuhao1024, @bymle, and @mushuiyu886.

View File

@@ -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.

View File

@@ -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,30 +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: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 `pnpm android:release:preflight` to validate Play auth, signing, synced versioning, and release notes.
5. Run `pnpm android:screenshots` to refresh raw Google Play screenshots.
6. Run `pnpm android:release:archive` to produce the signed Play AAB and third-party APK.
7. Run `pnpm android:release:upload` to upload metadata, screenshots, and the Play AAB to Google Play internal testing.
8. 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.

View File

@@ -136,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
@@ -183,29 +178,6 @@ def capture_android_screenshots!
sh(shell_join(["bash", File.join(repo_root, "scripts", "android-screenshots.sh")]))
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!
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))
@@ -258,14 +230,6 @@ platform :android do
UI.success("Google Play API credentials are valid.")
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!
@@ -296,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"

View File

@@ -56,15 +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`.
- 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: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`.

View File

@@ -1,2 +1,2 @@
e1928b7528c130ebac4f8f5cf0d1de545c996898182b50cbdf4efdc89a8637cd plugin-sdk-api-baseline.json
d9c227be6d344676e36d6ccc37c3c8cf05f80dcc82eadc4a686b3dccd1667990 plugin-sdk-api-baseline.jsonl
e2a646aa93124c089fcfed3c3ef982c88d1fdd2170fcdec274446f3d02f20d2b plugin-sdk-api-baseline.json
f1762c7b4bbaea4a3ce47ab943daaa6ca3dbc58322cc5d39688da66b3d483a2d plugin-sdk-api-baseline.jsonl

View File

@@ -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"
}
]

View File

@@ -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.

View File

@@ -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

View File

@@ -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` |

View File

@@ -1417,7 +1417,6 @@
"providers/azure-speech",
"providers/cerebras",
"providers/chutes",
"providers/cohere",
"providers/claude-max-api-proxy",
"providers/cloudflare-ai-gateway",
"providers/comfy",

View File

@@ -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.

View File

@@ -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": "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.
Domain entries use `allow` or `deny`; Unix socket entries use Codex's
`allow` or `deny` 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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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", "deny"]
}
},
"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 deny 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.",

View File

@@ -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: {

View File

@@ -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": "deny",
},
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",
":workspace_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": "deny",
},
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: { ":workspace_roots": { ".": string } } }
>;
expect(profileName).toMatch(/^openclaw-network-[a-f0-9]{16}$/u);
expect(runtime.networkProxy?.configPatch.default_permissions).toBe(profileName);
expect(permissions[profileName ?? ""]?.filesystem[":workspace_roots"]["."]).toBe("read");
});
it("clamps oversized app-server timer config", () => {
const runtime = resolveRuntimeForTest({
pluginConfig: {

View File

@@ -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" | "deny";
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", "deny"]);
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",
":workspace_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 {

View File

@@ -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" }],

View File

@@ -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> {

View File

@@ -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) => {

View File

@@ -116,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,
@@ -131,7 +126,6 @@ async function writeExistingBinding(
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
webSearchThreadConfigFingerprint: DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT,
...overrides,
});
}

View File

@@ -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=";

View File

@@ -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");

View File

@@ -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,

View File

@@ -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",
":workspace_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);

View File

@@ -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,

View File

@@ -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",
":workspace_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");

View File

@@ -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",
":workspace_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",

View File

@@ -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[];

View File

@@ -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");

View File

@@ -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(

View File

@@ -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 },
);

View File

@@ -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" })]),
);
});
});

View File

@@ -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),
},
});

View File

@@ -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];
}

View File

@@ -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,
);
});
});

View File

@@ -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);
}

View File

@@ -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": {}
}
}

View File

@@ -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"
]
}
}

View File

@@ -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(),
};
}

View File

@@ -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),
);
}

View File

@@ -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"
]
}

View File

@@ -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 });

View File

@@ -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;

View File

@@ -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 () => {

View File

@@ -424,7 +424,6 @@ async function dispatchMessage(params: {
currentCfg?: ClawdbotConfig;
event: FeishuMessageEvent;
channelRuntime?: PluginRuntime["channel"];
botOpenId?: string;
}) {
const runtime = createRuntimeEnv();
const feishuConfig = params.cfg.channels?.feishu;
@@ -445,7 +444,6 @@ async function dispatchMessage(params: {
await handleFeishuMessage({
cfg,
event: params.event,
botOpenId: params.botOpenId,
runtime,
channelRuntime: params.channelRuntime,
});
@@ -4166,150 +4164,6 @@ describe("handleFeishuMessage command authorization", () => {
// No reply should be dispatched: empty message is silently skipped
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
});
it("does not drop empty-text message when it quotes a parent message (#90177)", async () => {
// A Feishu reply containing only @bot (no additional text) was being
// dropped before the quoted message content was fetched. The handler
// should fetch quoted content first and only skip if all of current
// text, media, and quoted content are empty.
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockGetMessageFeishu.mockResolvedValueOnce({
messageId: "om_quoted_001",
chatId: "oc-dm",
content: "quoted message content from parent",
contentType: "text",
});
const cfg: ClawdbotConfig = {
channels: {
feishu: {
dmPolicy: "open",
allowFrom: ["*"],
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-reply-only-bot",
},
},
message: {
message_id: "msg-empty-with-quote",
parent_id: "om_quoted_001",
chat_id: "oc-dm",
chat_type: "p2p",
message_type: "text",
// Empty text — only @bot mention, no additional content
content: JSON.stringify({ text: "" }),
},
};
await dispatchMessage({ cfg, event });
// A reply should be dispatched because quoted content provides context
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
});
it("dispatches mention-only group reply with quoted content in requireMention:true group (#90177)", async () => {
// #90177 is specifically about group chats. The empty-message drop happens
// after the group admission/mention gate, so the fix must also work when
// the sender mentions the bot in a requireMention:true group and quotes a
// parent message with meaningful content — the reply should dispatch with
// the quoted text in the body.
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockGetMessageFeishu.mockResolvedValueOnce({
messageId: "om_group_quoted_001",
chatId: "oc-group-90177",
content: "parent message with context",
contentType: "text",
});
const cfg: ClawdbotConfig = {
channels: {
feishu: {
groupPolicy: "open",
groups: {
"oc-group-90177": {
requireMention: true,
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-group-sender",
},
},
message: {
message_id: "msg-group-empty-with-quote",
parent_id: "om_group_quoted_001",
chat_id: "oc-group-90177",
chat_type: "group",
message_type: "text",
// Empty text — only @bot mention, no additional content
content: JSON.stringify({ text: "" }),
// Bot mention so the message passes the requireMention gate
mentions: [
{ key: "@_bot_1", id: { open_id: "ou-bot-90177" }, name: "Bot", tenant_key: "" },
],
},
};
await dispatchMessage({ cfg, event, botOpenId: "ou-bot-90177" });
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
const context = mockCallArg<{ Body?: string }>(mockFinalizeInboundContext, 0, 0);
expect(context.Body).toContain("[Replying to:");
expect(context.Body).toContain("parent message with context");
});
it("does not over-fetch quoted message for unmentioned empty reply in requireMention:true group (#90177)", async () => {
// An empty-text reply that quotes a parent but does NOT mention the bot
// in a requireMention:true group should be rejected at the mention gate
// before the quoted message is fetched, so getMessageFeishu is never
// called and nothing is dispatched.
mockShouldComputeCommandAuthorized.mockReturnValue(false);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
groupPolicy: "open",
groups: {
"oc-group-90177-neg": {
requireMention: true,
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-group-sender-neg",
},
},
message: {
message_id: "msg-group-unmentioned-empty-quote",
parent_id: "om_group_quoted_neg",
chat_id: "oc-group-90177-neg",
chat_type: "group",
message_type: "text",
// Empty text with no bot mention
content: JSON.stringify({ text: "" }),
},
};
await dispatchMessage({ cfg, event, botOpenId: "ou-bot-90177-neg" });
expect(mockGetMessageFeishu).not.toHaveBeenCalled();
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
});
});
describe("createFeishuMessageReceiveHandler media dedupe", () => {

View File

@@ -1026,57 +1026,15 @@ export async function handleFeishuMessage(params: {
log,
accountId: account.accountId,
});
// Fetch quoted/replied message content before the empty-message guard
// so a reply with only @bot (no text, no media) is not dropped when
// the quoted message carries meaningful content.
let quotedMessageInfo: Awaited<ReturnType<typeof getMessageFeishu>> = null;
let quotedContent: string | undefined;
if (ctx.parentId) {
try {
quotedMessageInfo = await getMessageFeishu({
cfg,
messageId: ctx.parentId,
accountId: account.accountId,
});
if (
quotedMessageInfo &&
(await shouldIncludeFetchedGroupContextMessage({
cfg,
accountId: account.accountId,
chatId: ctx.chatId,
isGroup,
allowFrom: effectiveGroupSenderAllowFrom,
mode: contextVisibilityMode,
kind: "quote",
senderId: quotedMessageInfo.senderId,
senderType: quotedMessageInfo.senderType,
}))
) {
quotedContent = quotedMessageInfo.content;
log(
`feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`,
);
} else if (quotedMessageInfo) {
log(
`feishu[${account.accountId}]: skipped quoted message from sender ${quotedMessageInfo.senderId ?? "unknown"} (mode=${contextVisibilityMode})`,
);
}
} catch (err) {
log(`feishu[${account.accountId}]: failed to fetch quoted message: ${String(err)}`);
}
}
// Skip messages with no text content, no media attachments, and no quoted
// content. Feishu can deliver empty-text events (e.g. `{"text":""}`) when
// a user sends a blank message or when media parsing produces an empty
// string. Writing a blank user turn to the session causes downstream LLM
// providers (e.g. MiniMax) to reject the request with "messages must not
// be empty" errors. Logging the skip avoids silent loss without polluting
// the agent session. Quoted content is checked too so a reply-only @bot
// with quoted context is not dropped.
if (!ctx.content.trim() && mediaList.length === 0 && !quotedContent?.trim()) {
// Skip messages with no text content and no media attachments. Feishu can
// deliver empty-text events (e.g. `{"text":""}`) when a user sends a blank
// message or when media parsing produces an empty string. Writing a blank
// user turn to the session causes downstream LLM providers (e.g. MiniMax)
// to reject the request with "messages must not be empty" errors. Logging
// the skip avoids silent loss without polluting the agent session.
if (!ctx.content.trim() && mediaList.length === 0) {
log(
`feishu[${account.accountId}]: skipping empty message (no text, no media, no quoted) from ${ctx.senderOpenId}`,
`feishu[${account.accountId}]: skipping empty message (no text, no media) from ${ctx.senderOpenId}`,
);
return;
}
@@ -1149,6 +1107,44 @@ export async function handleFeishuMessage(params: {
).commandAccess.authorized
: undefined;
// Fetch quoted/replied message content if parentId exists
let quotedMessageInfo: Awaited<ReturnType<typeof getMessageFeishu>> = null;
let quotedContent: string | undefined;
if (ctx.parentId) {
try {
quotedMessageInfo = await getMessageFeishu({
cfg,
messageId: ctx.parentId,
accountId: account.accountId,
});
if (
quotedMessageInfo &&
(await shouldIncludeFetchedGroupContextMessage({
cfg,
accountId: account.accountId,
chatId: ctx.chatId,
isGroup,
allowFrom: effectiveGroupSenderAllowFrom,
mode: contextVisibilityMode,
kind: "quote",
senderId: quotedMessageInfo.senderId,
senderType: quotedMessageInfo.senderType,
}))
) {
quotedContent = quotedMessageInfo.content;
log(
`feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`,
);
} else if (quotedMessageInfo) {
log(
`feishu[${account.accountId}]: skipped quoted message from sender ${quotedMessageInfo.senderId ?? "unknown"} (mode=${contextVisibilityMode})`,
);
}
} catch (err) {
log(`feishu[${account.accountId}]: failed to fetch quoted message: ${String(err)}`);
}
}
const isTopicSessionForThread =
isGroup &&
(groupSession?.groupSessionScope === "group_topic" ||

View File

@@ -0,0 +1,30 @@
// Feishu client module import behavior tests.
import { afterEach, describe, expect, it, vi } from "vitest";
afterEach(() => {
vi.doUnmock("@larksuiteoapi/node-sdk");
vi.doUnmock("@openclaw/proxyline");
vi.resetModules();
});
describe("Feishu client module", () => {
it("loads when the SDK has no default HTTP instance", async () => {
vi.doMock("@larksuiteoapi/node-sdk", () => ({
AppType: { SelfBuild: "self" },
Domain: { Feishu: "https://open.feishu.cn", Lark: "https://open.larksuite.com" },
LoggerLevel: { info: "info" },
Client: vi.fn(),
WSClient: vi.fn(),
EventDispatcher: vi.fn(),
defaultHttpInstance: undefined,
}));
vi.doMock("@openclaw/proxyline", () => ({
createAmbientNodeProxyAgent: vi.fn(),
hasAmbientNodeProxyConfigured: vi.fn(() => false),
}));
await expect(import("./client.js")).resolves.toMatchObject({
createFeishuClient: expect.any(Function),
});
});
});

View File

@@ -387,6 +387,23 @@ describe("createFeishuClient HTTP timeout", () => {
});
});
it("rejects client creation when the SDK default HTTP instance is unavailable", () => {
setFeishuClientRuntimeForTest({
sdk: {
defaultHttpInstance: undefined as never,
},
});
expect(() =>
createFeishuClient({
appId: "app-default-http",
appSecret: "secret-default-http", // pragma: allowlist secret
accountId: "default-http-instance",
}),
).toThrow("Feishu SDK default HTTP instance is unavailable");
expect(clientCtorMock).not.toHaveBeenCalled();
});
it("evicts client cache when SDK is replaced via setFeishuClientRuntimeForTest (#83911)", () => {
const ctorCountA = clientCtorMock.mock.calls.length;

View File

@@ -67,12 +67,14 @@ let feishuClientSdk: FeishuClientSdk = defaultFeishuClientSdk;
// If a future SDK version adds more interceptors, the upgrade will need
// compatibility verification regardless.
{
const inst = Lark.defaultHttpInstance as {
interceptors?: {
request: { handlers: unknown[]; use: (fn: (req: unknown) => unknown) => void };
};
};
if (inst.interceptors?.request) {
const inst = Lark.defaultHttpInstance as
| {
interceptors?: {
request: { handlers: unknown[]; use: (fn: (req: unknown) => unknown) => void };
};
}
| undefined;
if (inst?.interceptors?.request) {
inst.interceptors.request.handlers = [];
inst.interceptors.request.use((req: unknown) => {
const r = req as { headers?: Record<string, string> };
@@ -119,9 +121,10 @@ function resolveDomain(domain: FeishuDomain | undefined): Lark.Domain | string {
* but injects a default request timeout and User-Agent header to prevent
* indefinite hangs and set a standardized User-Agent per OAPI best practices.
*/
function createTimeoutHttpInstance(defaultTimeoutMs: number): Lark.HttpInstance {
const base: FeishuHttpInstanceLike = feishuClientSdk.defaultHttpInstance;
function createTimeoutHttpInstance(
base: FeishuHttpInstanceLike,
defaultTimeoutMs: number,
): Lark.HttpInstance {
function injectTimeout<D>(opts?: Lark.HttpRequestOptions<D>): Lark.HttpRequestOptions<D> {
return { timeout: defaultTimeoutMs, ...opts } as Lark.HttpRequestOptions<D>;
}
@@ -175,13 +178,19 @@ export function createFeishuClient(creds: FeishuClientCredentials): Lark.Client
return cached.client;
}
// Create new client with timeout-aware HTTP instance
const defaultHttpInstance = feishuClientSdk.defaultHttpInstance as
| FeishuHttpInstanceLike
| undefined;
if (!defaultHttpInstance) {
throw new Error("Feishu SDK default HTTP instance is unavailable");
}
const client = new feishuClientSdk.Client({
appId,
appSecret,
appType: feishuClientSdk.AppType.SelfBuild,
domain: resolveDomain(domain),
httpInstance: createTimeoutHttpInstance(defaultHttpTimeoutMs),
httpInstance: createTimeoutHttpInstance(defaultHttpInstance, defaultHttpTimeoutMs),
});
// Cache it

View File

@@ -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 () => {

View File

@@ -8,7 +8,7 @@
"name": "@openclaw/matrix",
"version": "2026.6.8",
"dependencies": {
"@matrix-org/matrix-sdk-crypto-nodejs": "0.6.0",
"@matrix-org/matrix-sdk-crypto-nodejs": "0.4.0",
"@matrix-org/matrix-sdk-crypto-wasm": "18.3.0",
"fake-indexeddb": "6.2.5",
"markdown-it": "14.2.0",
@@ -46,9 +46,9 @@
}
},
"node_modules/@matrix-org/matrix-sdk-crypto-nodejs": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-nodejs/-/matrix-sdk-crypto-nodejs-0.6.0.tgz",
"integrity": "sha512-AndGryzkDtFbaDyPBAQ2B4pUhaA/q4HJf3wgiGpPa/70DsdY1Z3R5Wn9yp+56CeHOpk61mNHz/8WDPlzrZDSJw==",
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-nodejs/-/matrix-sdk-crypto-nodejs-0.4.0.tgz",
"integrity": "sha512-+qqgpn39XFSbsD0dFjssGO9vHEP7sTyfs8yTpt8vuqWpUpF20QMwpCZi0jpYw7GxjErNTsMshopuo8677DfGEA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -56,7 +56,7 @@
"node-downloader-helper": "^2.1.9"
},
"engines": {
"node": ">= 24"
"node": ">= 22"
}
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {

View File

@@ -8,7 +8,7 @@
},
"type": "module",
"dependencies": {
"@matrix-org/matrix-sdk-crypto-nodejs": "0.6.0",
"@matrix-org/matrix-sdk-crypto-nodejs": "0.4.0",
"@matrix-org/matrix-sdk-crypto-wasm": "18.3.0",
"fake-indexeddb": "6.2.5",
"markdown-it": "14.2.0",

View File

@@ -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,

View File

@@ -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: {

View File

@@ -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 });

View File

@@ -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;

View File

@@ -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") {

View File

@@ -2768,12 +2768,7 @@ export const registerTelegramHandlers = ({
} catch (err) {
throw new TelegramRetryableCallbackError(err);
}
const {
byProvider,
providers,
modelNames,
resolvedDefault: activeResolvedDefault,
} = modelData;
const { byProvider, providers, modelNames } = modelData;
const editMessageWithButtons = async (
text: string,
@@ -2847,10 +2842,8 @@ export const registerTelegramHandlers = ({
const totalPages = calculateTotalPages(models.length, pageSize);
const safePage = Math.max(1, Math.min(page, totalPages));
// Resolve current model from session (prefer overrides), then the active default.
const currentModel =
sessionState.model ||
`${activeResolvedDefault.provider}/${activeResolvedDefault.model}`;
// Resolve current model from session (prefer overrides)
const currentModel = sessionState.model;
const buttons = buildModelsKeyboard({
provider,

View File

@@ -2,21 +2,11 @@
import { describe, expect, it, vi } from "vitest";
import { normalizeAllowFrom } from "./bot-access.js";
const {
resolveStickerVisionSupportRuntimeMock,
transcribeFirstAudioMock,
triggerInternalHookMock,
} = vi.hoisted(() => ({
resolveStickerVisionSupportRuntimeMock: vi.fn(async (_params: unknown) => false),
const { transcribeFirstAudioMock, triggerInternalHookMock } = vi.hoisted(() => ({
transcribeFirstAudioMock: vi.fn(),
triggerInternalHookMock: vi.fn<(event: unknown) => Promise<void>>(async () => undefined),
}));
vi.mock("./sticker-vision.runtime.js", () => ({
resolveStickerVisionSupportRuntime: (params: unknown) =>
resolveStickerVisionSupportRuntimeMock(params),
}));
vi.mock("./media-understanding.runtime.js", () => ({
transcribeFirstAudio: (...args: unknown[]) => transcribeFirstAudioMock(...args),
}));
@@ -287,38 +277,6 @@ describe("resolveTelegramInboundBody", () => {
expect(result?.stickerCacheHit).toBe(true);
});
it("keeps cached sticker media available when the active model supports vision", async () => {
resolveStickerVisionSupportRuntimeMock.mockResolvedValueOnce(true);
const result = await resolveTelegramBody({
msg: {
message_id: 8,
date: 1_700_000_008,
chat: { id: 42, type: "private", first_name: "Pat" },
from: { id: 42, first_name: "Pat" },
sticker: {
file_id: "sticker-3",
file_unique_id: "sticker-u3",
type: "regular",
width: 256,
height: 256,
is_animated: false,
is_video: false,
},
} as never,
allMedia: [
{
path: "/tmp/sticker.webp",
contentType: "image/webp",
stickerMetadata: { cachedDescription: "Cached description" },
},
],
});
expect(result?.bodyText).toBe("<media:image>");
expect(result?.stickerCacheHit).toBe(false);
});
it("lets catch-all mention patterns activate captionless group photos", async () => {
const logger = { info: vi.fn() };

View File

@@ -2625,10 +2625,10 @@ describe("dispatchTelegramMessage draft streaming", () => {
});
const lastUpdate = answerDraftStream.updatePreview.mock.calls.at(-1)?.[0];
expect(lastUpdate?.text).toContain("completed");
expect(lastUpdate?.text).not.toContain("install dependencies");
expect(lastUpdate?.text).toContain("install dependencies");
expect(lastUpdate?.text).not.toContain("completed");
expect(lastUpdate?.richMessage).toEqual({
html: "<b>Shelling</b><br><b>🛠️ Exec</b> <code>completed</code>",
html: "<b>Shelling</b><br><b>🛠️ Exec</b> <code>install dependencies</code>",
skip_entity_detection: true,
});
});

View File

@@ -1343,7 +1343,6 @@ describe("createTelegramBot", () => {
replySpy.mockClear();
editMessageTextSpy.mockClear();
const storePath = `/tmp/openclaw-telegram-model-display-names-${process.pid}-${Date.now()}.json`;
const buildModelsProviderDataMock =
telegramBotDepsForTest.buildModelsProviderData as unknown as ReturnType<typeof vi.fn>;
buildModelsProviderDataMock.mockResolvedValueOnce({
@@ -1368,60 +1367,52 @@ describe("createTelegramBot", () => {
allowFrom: ["*"],
},
},
session: {
store: storePath,
},
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
await rm(storePath, { force: true });
try {
loadConfig.mockReturnValue(config);
createTelegramBot({
token: "tok",
config,
});
const callbackHandler = onSpy.mock.calls.find(
(call) => call[0] === "callback_query",
)?.[1] as (ctx: Record<string, unknown>) => Promise<void>;
if (!callbackHandler) {
throw new Error("Expected Telegram callback_query handler");
}
await callbackHandler({
callbackQuery: {
id: "cbq-model-display-names-1",
data: "mdl_list_openai_1",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 23,
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).not.toHaveBeenCalled();
expect(editMessageTextSpy).toHaveBeenCalledTimes(1);
const params = firstEditMessageTextArg(3);
const inlineKeyboard = (
params as {
reply_markup?: {
inline_keyboard?: Array<Array<{ text?: string; callback_data?: string }>>;
};
}
).reply_markup?.inline_keyboard;
expect(inlineKeyboard).toStrictEqual([
[{ text: "GPT 4.1 Bridge", callback_data: "mdl_sel_openai/gpt-4.1" }],
[{ text: "GPT Five Bridge ✓", callback_data: "mdl_sel_openai/gpt-5" }],
[{ text: "<< Back", callback_data: "mdl_back" }],
]);
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-model-display-names-1");
} finally {
await rm(storePath, { force: true });
loadConfig.mockReturnValue(config);
createTelegramBot({
token: "tok",
config,
});
const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
if (!callbackHandler) {
throw new Error("Expected Telegram callback_query handler");
}
await callbackHandler({
callbackQuery: {
id: "cbq-model-display-names-1",
data: "mdl_list_openai_1",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 23,
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).not.toHaveBeenCalled();
expect(editMessageTextSpy).toHaveBeenCalledTimes(1);
const params = firstEditMessageTextArg(3);
const inlineKeyboard = (
params as {
reply_markup?: {
inline_keyboard?: Array<Array<{ text?: string; callback_data?: string }>>;
};
}
).reply_markup?.inline_keyboard;
expect(inlineKeyboard).toStrictEqual([
[{ text: "GPT 4.1 Bridge", callback_data: "mdl_sel_openai/gpt-4.1" }],
[{ text: "GPT Five Bridge ✓", callback_data: "mdl_sel_openai/gpt-5" }],
[{ text: "<< Back", callback_data: "mdl_back" }],
]);
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-model-display-names-1");
});
it("resets overrides when selecting the configured default model", async () => {

View File

@@ -1444,7 +1444,6 @@
"android:release:archive": "bun apps/android/scripts/build-release-artifacts.ts",
"android:release:auth:check": "bash -lc 'source ./scripts/lib/android-fastlane.sh && cd apps/android && run_android_fastlane android auth_check'",
"android:release:metadata": "bash -lc 'source ./scripts/lib/android-fastlane.sh && cd apps/android && run_android_fastlane android metadata'",
"android:release:preflight": "bash -lc 'source ./scripts/lib/android-fastlane.sh && cd apps/android && run_android_fastlane android release_preflight'",
"android:release:upload": "bash scripts/android-release-upload.sh",
"android:screenshots": "bash scripts/android-screenshots.sh",
"android:test": "node scripts/run-android-gradle.mjs :app:testPlayDebugUnitTest",

18
pnpm-lock.yaml generated
View File

@@ -473,12 +473,6 @@ importers:
specifier: workspace:*
version: link:../../packages/plugin-sdk
extensions/cohere:
devDependencies:
'@openclaw/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
extensions/cerebras:
devDependencies:
'@openclaw/plugin-sdk':
@@ -963,8 +957,8 @@ importers:
extensions/matrix:
dependencies:
'@matrix-org/matrix-sdk-crypto-nodejs':
specifier: 0.6.0
version: 0.6.0
specifier: 0.4.0
version: 0.4.0
'@matrix-org/matrix-sdk-crypto-wasm':
specifier: 18.3.0
version: 18.3.0
@@ -2932,9 +2926,9 @@ packages:
'@lydell/node-pty@1.2.0-beta.12':
resolution: {integrity: sha512-qIK890UwPupoj07osVvgOIa++1mxeHbcGry4PKRHhNVNs81V2SCG34eJr46GybiOmBtc8Sj5PB1/GGM5PL549g==}
'@matrix-org/matrix-sdk-crypto-nodejs@0.6.0':
resolution: {integrity: sha512-AndGryzkDtFbaDyPBAQ2B4pUhaA/q4HJf3wgiGpPa/70DsdY1Z3R5Wn9yp+56CeHOpk61mNHz/8WDPlzrZDSJw==}
engines: {node: '>= 24'}
'@matrix-org/matrix-sdk-crypto-nodejs@0.4.0':
resolution: {integrity: sha512-+qqgpn39XFSbsD0dFjssGO9vHEP7sTyfs8yTpt8vuqWpUpF20QMwpCZi0jpYw7GxjErNTsMshopuo8677DfGEA==}
engines: {node: '>= 22'}
'@matrix-org/matrix-sdk-crypto-wasm@18.3.0':
resolution: {integrity: sha512-9a4feyt8QLysARu7PHKaRWT+wcCd+IYH074LXp9QK5WqfN4zUXueRhiSSMNT18Bm+8q3sBR/4zxDxOSDR0M8Kg==}
@@ -8879,7 +8873,7 @@ snapshots:
'@lydell/node-pty-win32-arm64': 1.2.0-beta.12
'@lydell/node-pty-win32-x64': 1.2.0-beta.12
'@matrix-org/matrix-sdk-crypto-nodejs@0.6.0':
'@matrix-org/matrix-sdk-crypto-nodejs@0.4.0':
dependencies:
https-proxy-agent: 7.0.6
node-downloader-helper: 2.1.11

View File

@@ -30,9 +30,7 @@ const PUBLIC_EXTENSION_CONTRACT_RE =
*/
export const RELEASE_METADATA_PATHS = new Set([
"CHANGELOG.md",
"apps/android/CHANGELOG.md",
"apps/android/Config/Version.properties",
"apps/android/fastlane/metadata/android/en-US/release_notes.txt",
"apps/android/version.json",
"apps/ios/CHANGELOG.md",
"apps/ios/Config/Version.xcconfig",

View File

@@ -46,9 +46,7 @@ const LINTABLE_CORE_PATH_RE = /^(?:src|ui|packages)\/.+\.[cm]?[jt]sx?$/u;
const CORE_LINT_OPTIMIZATION_NEUTRAL_PATH_RE =
/^(?:scripts|test\/scripts)\/|^\.github\/workflows\/ci\.yml$/u;
const ANDROID_VERSION_SYNC_PATHS = new Set([
"apps/android/CHANGELOG.md",
"apps/android/Config/Version.properties",
"apps/android/fastlane/metadata/android/en-US/release_notes.txt",
"apps/android/version.json",
]);
let corepackPnpmShimDir;

View File

@@ -163,15 +163,6 @@ const sourceTestSuffixes = [
"test-utils.ts",
];
const ignoredSourceDirectories = new Set(["node_modules"]);
// Browser bundles written by build-diffs-viewer-runtime and bundle-a2ui are
// minified client payloads, not Node runtime source for this state policy.
const generatedStaticBundlePathSuffixes = [
"/extensions/canvas/src/host/a2ui/a2ui.bundle.js",
"/extensions/diffs/assets/viewer-runtime.js",
"/extensions/diffs-language-pack/assets/viewer-runtime.js",
];
function isAllowedLegacyOwnerPath(relativePath) {
return (
allowedFixturePaths.has(relativePath) ||
@@ -225,12 +216,7 @@ function consumeAllowedCurrentLegacyViolation(
}
function isSourceFile(filePath) {
return (
sourceFileExtensions.has(path.extname(filePath)) &&
!generatedStaticBundlePathSuffixes.some((suffix) =>
filePath.replaceAll(path.sep, "/").endsWith(suffix),
)
);
return sourceFileExtensions.has(path.extname(filePath));
}
function isTestLikeSourceFile(filePath) {
@@ -255,7 +241,7 @@ async function collectSourceFiles(targetPath) {
const entries = await fs.readdir(targetPath, { withFileTypes: true });
const files = [];
for (const entry of entries) {
if (ignoredSourceDirectories.has(entry.name)) {
if (entry.name === "node_modules") {
continue;
}
const entryPath = path.join(targetPath, entry.name);

View File

@@ -4,9 +4,7 @@ import path from "node:path";
import { parseReleaseVersion } from "./npm-publish-plan.mjs";
const ANDROID_VERSION_FILE = "apps/android/version.json";
const ANDROID_CHANGELOG_FILE = "apps/android/CHANGELOG.md";
const ANDROID_VERSION_PROPERTIES_FILE = "apps/android/Config/Version.properties";
const ANDROID_RELEASE_NOTES_FILE = "apps/android/fastlane/metadata/android/en-US/release_notes.txt";
const ANDROID_VERSION_CODE_MAX = 2_100_000_000;
type AndroidVersionManifest = {
@@ -16,8 +14,6 @@ type AndroidVersionManifest = {
export type ResolvedAndroidVersion = {
canonicalVersion: string;
changelogPath: string;
releaseNotesPath: string;
versionCode: number;
versionFilePath: string;
versionPropertiesPath: string;
@@ -165,17 +161,13 @@ export function writeAndroidVersionManifest(
export function resolveAndroidVersion(rootDir = path.resolve(".")): ResolvedAndroidVersion {
const versionFilePath = path.join(rootDir, ANDROID_VERSION_FILE);
const changelogPath = path.join(rootDir, ANDROID_CHANGELOG_FILE);
const versionPropertiesPath = path.join(rootDir, ANDROID_VERSION_PROPERTIES_FILE);
const releaseNotesPath = path.join(rootDir, ANDROID_RELEASE_NOTES_FILE);
const manifest = readAndroidVersionManifest(rootDir);
const canonicalVersion = normalizePinnedAndroidVersion(manifest.version ?? "");
const versionCode = normalizeAndroidVersionCode(manifest.versionCode, canonicalVersion);
return {
canonicalVersion,
changelogPath,
releaseNotesPath,
versionCode,
versionFilePath,
versionPropertiesPath,
@@ -186,51 +178,6 @@ export function renderAndroidVersionProperties(version: ResolvedAndroidVersion):
return `# Shared Android version defaults.\n# Source of truth: apps/android/version.json\n# Generated by scripts/android-sync-versioning.ts.\n\nOPENCLAW_ANDROID_VERSION_NAME=${version.canonicalVersion}\nOPENCLAW_ANDROID_VERSION_CODE=${version.versionCode}\n`;
}
function matchChangelogHeading(line: string, heading: string): boolean {
const normalized = line.trim();
return normalized === `## ${heading}` || normalized.startsWith(`## ${heading} - `);
}
export function extractChangelogSection(content: string, heading: string): string | null {
const lines = content.split(/\r?\n/u);
const startIndex = lines.findIndex((line) => matchChangelogHeading(line, heading));
if (startIndex === -1) {
return null;
}
let endIndex = lines.length;
for (let index = startIndex + 1; index < lines.length; index += 1) {
if (lines[index]?.startsWith("## ")) {
endIndex = index;
break;
}
}
const body = lines
.slice(startIndex + 1, endIndex)
.join("\n")
.trim();
return body || null;
}
export function renderAndroidReleaseNotes(
version: ResolvedAndroidVersion,
changelogContent: string,
): string {
const candidateHeadings = [version.canonicalVersion, "Unreleased"];
for (const heading of candidateHeadings) {
const body = extractChangelogSection(changelogContent, heading);
if (body) {
return `${body}\n`;
}
}
throw new Error(
`Unable to find Android changelog notes for ${version.canonicalVersion}. Add a matching section to ${ANDROID_CHANGELOG_FILE}.`,
);
}
function syncFile(params: {
mode: SyncAndroidVersioningMode;
path: string;
@@ -260,9 +207,7 @@ export function syncAndroidVersioning(params?: {
const mode = params?.mode ?? "write";
const rootDir = path.resolve(params?.rootDir ?? ".");
const version = resolveAndroidVersion(rootDir);
const changelogContent = readFileSync(version.changelogPath, "utf8");
const nextVersionProperties = renderAndroidVersionProperties(version);
const nextReleaseNotes = renderAndroidReleaseNotes(version, changelogContent);
const updatedPaths: string[] = [];
if (
@@ -276,16 +221,5 @@ export function syncAndroidVersioning(params?: {
updatedPaths.push(version.versionPropertiesPath);
}
if (
syncFile({
mode,
path: version.releaseNotesPath,
nextContent: nextReleaseNotes,
label: "Android release notes",
})
) {
updatedPaths.push(version.releaseNotesPath);
}
return { updatedPaths };
}

View File

@@ -5,10 +5,7 @@ run_prepare_push_retry_gates() {
run_quiet_logged "pnpm build (lease-retry)" ".local/lease-retry-build.log" pnpm build
run_quiet_logged "pnpm check (lease-retry)" ".local/lease-retry-check.log" pnpm check
if [ "$docs_only" != "true" ]; then
run_quiet_logged \
"pnpm test:changed (lease-retry)" \
".local/lease-retry-test.log" \
env OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed
run_quiet_logged "pnpm test (lease-retry)" ".local/lease-retry-test.log" pnpm test
fi
}
@@ -79,13 +76,15 @@ prepare_gates() {
local current_head
current_head=$(git rev-parse HEAD)
local previous_last_verified_head=""
local previous_full_gates_head=""
if [ -s .local/gates.env ]; then
# shellcheck disable=SC1091
source .local/gates.env
previous_last_verified_head="${LAST_VERIFIED_HEAD_SHA:-}"
previous_full_gates_head="${FULL_GATES_HEAD_SHA:-}"
fi
local gates_mode="changed"
local gates_mode="full"
local reuse_gates=false
if [ "$docs_only" = "true" ] && [ -n "$previous_last_verified_head" ] && git merge-base --is-ancestor "$previous_last_verified_head" HEAD 2>/dev/null; then
local delta_since_verified
@@ -104,14 +103,20 @@ prepare_gates() {
if [ "$docs_only" = "true" ]; then
gates_mode="docs_only"
echo "Docs-only change detected with high confidence; skipping pnpm test:changed."
echo "Docs-only change detected with high confidence; skipping pnpm test."
else
gates_mode="changed"
echo "Running pnpm test:changed with broad fallback for Vitest coverage."
run_quiet_logged \
"pnpm test:changed" \
".local/gates-test.log" \
env OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed
gates_mode="full"
if [ -n "${OPENCLAW_VITEST_MAX_WORKERS:-}" ]; then
echo "Running pnpm test with OPENCLAW_VITEST_MAX_WORKERS=$OPENCLAW_VITEST_MAX_WORKERS."
run_quiet_logged \
"pnpm test" \
".local/gates-test.log" \
env OPENCLAW_VITEST_MAX_WORKERS="$OPENCLAW_VITEST_MAX_WORKERS" pnpm test
else
echo "Running pnpm test with host-aware scheduling defaults."
run_quiet_logged "pnpm test" ".local/gates-test.log" pnpm test
fi
previous_full_gates_head="$current_head"
fi
fi
@@ -122,6 +127,7 @@ prepare_gates() {
CHANGELOG_REQUIRED "$changelog_required" \
GATES_MODE "$gates_mode" \
LAST_VERIFIED_HEAD_SHA "$current_head" \
FULL_GATES_HEAD_SHA "${previous_full_gates_head:-}" \
GATES_PASSED_AT "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
> .local/gates.env

View File

@@ -4,7 +4,7 @@ import type { Model } from "../../llm/types.js";
import { createSessionManagerRuntimeRegistry } from "./session-manager-runtime-registry.js";
/** Runtime knobs consumed by the compaction safeguard extension. */
type CompactionSafeguardRuntimeValue = {
export type CompactionSafeguardRuntimeValue = {
maxHistoryShare?: number;
contextWindowTokens?: number;
identifierPolicy?: AgentCompactionIdentifierPolicy;

View File

@@ -305,35 +305,8 @@ describe("before_tool_call loop detection behavior", () => {
await expectUnblockedToolExecution(tool, `poll-${i}`, params);
}
await withDiagnosticEvents(async (emitted, flush) => {
const result = await tool.execute(`poll-${CRITICAL_THRESHOLD}`, params, undefined, undefined);
await flush();
expectToolLoopBlockedResult(result, "CRITICAL");
const securityEvent = emitted.find(
(event): event is Extract<DiagnosticEventPayload, { type: "security.event" }> =>
event.type === "security.event",
);
expect(securityEvent).toMatchObject({
type: "security.event",
category: "tool",
action: "tool.execution.blocked",
outcome: "denied",
reason: "tool-loop",
policy: {
id: "tool-loop-detection",
decision: "deny",
reason: "tool-loop",
},
control: {
id: "tool-loop-detection",
family: "authorization",
},
attributes: {
params_kind: "object",
tool_source: "core",
},
});
});
const result = await tool.execute(`poll-${CRITICAL_THRESHOLD}`, params, undefined, undefined);
expectToolLoopBlockedResult(result, "CRITICAL");
});
it("does nothing when loopDetection.enabled is false", async () => {
@@ -851,59 +824,6 @@ describe("before_tool_call loop detection behavior", () => {
});
});
it("emits a security event for intentional hook vetoes", async () => {
hookRunner.hasHooks.mockImplementation((hookName: string) => hookName === "before_tool_call");
hookRunner.runBeforeToolCall.mockResolvedValue({
block: true,
blockReason: "blocked by policy",
});
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "nope" }] });
const tool = wrapToolWithBeforeToolCallHook({ name: "read", execute } as any, {
agentId: "main",
sessionKey: "session-key",
loopDetection: { enabled: false },
});
await withDiagnosticEvents(async (emitted, flush) => {
await tool.execute("tool-call-blocked", { path: "/tmp/file" });
await flush();
const securityEvent = emitted.find(
(event): event is Extract<DiagnosticEventPayload, { type: "security.event" }> =>
event.type === "security.event",
);
expect(securityEvent).toMatchObject({
type: "security.event",
category: "tool",
action: "tool.execution.blocked",
outcome: "denied",
severity: "medium",
reason: "plugin-before-tool-call",
actor: { kind: "agent" },
target: {
kind: "tool",
name: "read",
},
policy: {
id: "plugin-before-tool-call",
decision: "deny",
reason: "plugin-before-tool-call",
},
control: {
id: "before-tool-call",
family: "approval",
},
attributes: {
params_kind: "object",
tool_source: "core",
},
});
expect(securityEvent?.eventId).toBeTypeOf("string");
expect(JSON.stringify(securityEvent)).not.toContain("/tmp/file");
expect(emitted.some((event) => event.type === "tool.execution.blocked")).toBe(true);
});
});
it("does not let hostile thrown values break diagnostic error emission", async () => {
const hostileError = new Proxy(
{},

View File

@@ -15,7 +15,6 @@ import {
import {
emitTrustedDiagnosticEvent,
emitTrustedDiagnosticEventWithPrivateData,
emitTrustedSecurityEvent,
type DiagnosticEventPrivateData,
type DiagnosticToolParamsSummary,
type DiagnosticToolSource,
@@ -567,63 +566,6 @@ function emitSkillUsedDiagnostic(params: {
});
}
function emitToolBlockedSecurityEvent(params: {
ctx?: HookContext;
deniedReason: HookBlockedReason;
toolIdentity: ToolDiagnosticIdentity;
toolName: string;
trace?: DiagnosticTraceContext;
paramsSummary?: DiagnosticToolParamsSummary;
}): void {
const control =
params.deniedReason === "tool-loop"
? ({
policyId: "tool-loop-detection",
controlId: "tool-loop-detection",
family: "authorization",
} as const)
: params.deniedReason === "plugin-approval"
? ({
policyId: "plugin-tool-approval",
controlId: "plugin-tool-approval",
family: "approval",
} as const)
: ({
policyId: "plugin-before-tool-call",
controlId: "before-tool-call",
family: "approval",
} as const);
emitTrustedSecurityEvent({
category: "tool",
action: "tool.execution.blocked",
outcome: "denied",
severity: "medium",
reason: params.deniedReason,
...(params.trace ? { trace: params.trace } : {}),
actor: {
kind: "agent",
},
target: {
kind: "tool",
name: params.toolName,
...(params.toolIdentity.toolOwner ? { owner: params.toolIdentity.toolOwner } : {}),
},
policy: {
id: control.policyId,
decision: "deny",
reason: params.deniedReason,
},
control: {
id: control.controlId,
family: control.family,
},
attributes: {
tool_source: params.toolIdentity.toolSource,
...(params.paramsSummary ? { params_kind: params.paramsSummary.kind } : {}),
},
});
}
function notifyPluginApprovalResolution(
approval: PluginApprovalRequest,
resolution: PluginApprovalResolution,
@@ -1411,14 +1353,6 @@ export function wrapToolWithBeforeToolCallHook(
reason: outcome.reason,
deniedReason: outcome.deniedReason ?? "plugin-before-tool-call",
});
emitToolBlockedSecurityEvent({
ctx,
deniedReason: outcome.deniedReason ?? "plugin-before-tool-call",
toolIdentity: diagnosticIdentity,
toolName: normalizedToolName,
trace,
paramsSummary: eventBase.paramsSummary,
});
}
const blockedResult = buildBlockedToolResult({
reason: outcome.reason,

View File

@@ -6,6 +6,8 @@
import { createSubsystemLogger } from "../../logging/subsystem.js";
export {
AUTH_PROFILE_FILENAME,
AUTH_STATE_FILENAME,
LEGACY_AUTH_FILENAME,
} from "./path-constants.js";
/** Current persisted auth profile store schema version. */

View File

@@ -208,4 +208,7 @@ export function syncPersistedExternalCliAuthProfiles(
return next ?? store;
}
// Compat aliases while file/function naming catches up.
export const overlayExternalOAuthProfiles = overlayExternalAuthProfiles;
export const shouldPersistExternalOAuthProfile = shouldPersistExternalAuthProfile;
export { testing as __testing };

View File

@@ -7,8 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ProviderExternalAuthProfile } from "../../plugins/types.js";
import {
testing,
overlayExternalAuthProfiles,
shouldPersistExternalAuthProfile,
overlayExternalOAuthProfiles,
shouldPersistExternalOAuthProfile,
} from "./external-auth.js";
import { readManagedExternalCliCredential } from "./external-cli-sync.js";
import type { AuthProfileStore, OAuthCredential } from "./types.js";
@@ -78,7 +78,7 @@ describe("auth external oauth helpers", () => {
},
]);
const store = overlayExternalAuthProfiles(createStore());
const store = overlayExternalOAuthProfiles(createStore());
const profile = requireProfile(store, "openai:default");
expect(profile.type).toBe("oauth");
@@ -94,7 +94,7 @@ describe("auth external oauth helpers", () => {
};
readCodexCliCredentialsCachedMock.mockReturnValueOnce(createCredential());
overlayExternalAuthProfiles(createStore(), {
overlayExternalOAuthProfiles(createStore(), {
allowKeychainPrompt: false,
config: cfg,
externalCliProviderIds: ["openai"],
@@ -128,7 +128,7 @@ describe("auth external oauth helpers", () => {
}),
});
const overlaid = overlayExternalAuthProfiles(store);
const overlaid = overlayExternalOAuthProfiles(store);
expect(readCodexCliCredentialsCachedMock).not.toHaveBeenCalled();
expect(overlaid.profiles["openai:work"]).toEqual(store.profiles["openai:work"]);
@@ -143,7 +143,7 @@ describe("auth external oauth helpers", () => {
},
]);
const shouldPersist = shouldPersistExternalAuthProfile({
const shouldPersist = shouldPersistExternalOAuthProfile({
store: createStore({ "openai:default": credential }),
profileId: "openai:default",
credential,
@@ -162,7 +162,7 @@ describe("auth external oauth helpers", () => {
},
]);
const shouldPersist = shouldPersistExternalAuthProfile({
const shouldPersist = shouldPersistExternalOAuthProfile({
store: createStore({ "openai:default": credential }),
profileId: "openai:default",
credential,
@@ -180,7 +180,7 @@ describe("auth external oauth helpers", () => {
},
]);
const shouldPersist = shouldPersistExternalAuthProfile({
const shouldPersist = shouldPersistExternalOAuthProfile({
store: createStore({ "openai:default": credential }),
profileId: "openai:default",
credential,
@@ -199,7 +199,7 @@ describe("auth external oauth helpers", () => {
}),
);
const overlaid = overlayExternalAuthProfiles(
const overlaid = overlayExternalOAuthProfiles(
createStore({
"openai:default": createCredential({
access: "stale-store-access-token",
@@ -231,7 +231,7 @@ describe("auth external oauth helpers", () => {
} as OAuthCredential;
readCodexCliCredentialsCachedMock.mockReturnValue(cliCredential);
const overlaid = overlayExternalAuthProfiles(
const overlaid = overlayExternalOAuthProfiles(
createStore({
"openai:default": tokenlessCredential,
}),
@@ -263,7 +263,7 @@ describe("auth external oauth helpers", () => {
}),
);
const overlaid = overlayExternalAuthProfiles(
const overlaid = overlayExternalOAuthProfiles(
createStore({
"openai:default": createCredential({
access: "healthy-local-access-token",
@@ -287,7 +287,7 @@ describe("auth external oauth helpers", () => {
}),
);
const overlaid = overlayExternalAuthProfiles(
const overlaid = overlayExternalOAuthProfiles(
createStore({
"openai:default": {
type: "api_key",
@@ -313,7 +313,7 @@ describe("auth external oauth helpers", () => {
}),
);
const overlaid = overlayExternalAuthProfiles(
const overlaid = overlayExternalOAuthProfiles(
createStore({
"openai:default": createCredential({
access: "expired-local-access-token",

View File

@@ -6,7 +6,7 @@
import { isRecord } from "@openclaw/normalization-core/record-coerce";
/** Legacy OAuth ref source persisted by older credential stores. */
const LEGACY_OAUTH_REF_SOURCE = "openclaw-credentials";
export const LEGACY_OAUTH_REF_SOURCE = "openclaw-credentials";
/** Legacy OAuth ref provider persisted by older credential stores. */
export const LEGACY_OAUTH_REF_PROVIDER = "openai-codex";

View File

@@ -16,7 +16,7 @@ const EXEC_APPROVAL_FOLLOWUP_IDEMPOTENCY_NONCE_MARKER = ":nonce:";
const EXEC_APPROVAL_FOLLOWUP_RUNTIME_HANDOFF_TTL_MS = 5 * 60 * 1000;
/** Single-use capability payload consumed by a follow-up agent turn. */
type ExecApprovalFollowupRuntimeHandoff = {
export type ExecApprovalFollowupRuntimeHandoff = {
kind: "exec-approval-followup";
approvalId: string;
sessionKey: string;
@@ -25,7 +25,7 @@ type ExecApprovalFollowupRuntimeHandoff = {
};
/** Registration handle returned to the gateway approval callback. */
type ExecApprovalFollowupRuntimeHandoffRegistration = {
export type ExecApprovalFollowupRuntimeHandoffRegistration = {
handoffId: string;
idempotencyKey: string;
};

View File

@@ -42,7 +42,7 @@ function loadExecApprovalCommandSpansRuntime(): Promise<ExecApprovalCommandSpans
}
/** Gateway payload fields used to register or wait for an exec approval decision. */
type RequestExecApprovalDecisionParams = {
export type RequestExecApprovalDecisionParams = {
id: string;
command?: string;
commandArgv?: string[];

View File

@@ -4,11 +4,6 @@
* follow-ups, and gateway approval result routing.
*/
import { beforeAll, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import {
onInternalDiagnosticEvent,
resetDiagnosticEventsForTest,
type DiagnosticSecurityEvent,
} from "../infra/diagnostic-events.js";
import type { ExecApprovalFollowupTarget } from "./bash-tools.exec-host-shared.js";
import type { ExecApprovalFollowupFactory } from "./bash-tools.exec-types.js";
@@ -114,7 +109,6 @@ const runExecProcessMock = vi.hoisted(() => vi.fn());
const sendExecApprovalFollowupResultMock = vi.hoisted(() =>
vi.fn<SendExecApprovalFollowupResult>(async () => undefined),
);
const shouldResolveExecApprovalUnavailableInlineMock = vi.hoisted(() => vi.fn(() => false));
const enforceStrictInlineEvalApprovalBoundaryMock = vi.hoisted(() =>
vi.fn<StrictInlineEvalBoundary>((value) => ({
approvedByAsk: value.approvedByAsk,
@@ -169,7 +163,7 @@ vi.mock("./bash-tools.exec-host-shared.js", () => ({
enforceStrictInlineEvalApprovalBoundary: enforceStrictInlineEvalApprovalBoundaryMock,
resolveApprovalDecisionOrUndefined: resolveApprovalDecisionOrUndefinedMock,
sendExecApprovalFollowupResult: sendExecApprovalFollowupResultMock,
shouldResolveExecApprovalUnavailableInline: shouldResolveExecApprovalUnavailableInlineMock,
shouldResolveExecApprovalUnavailableInline: vi.fn(() => false),
}));
vi.mock("./bash-tools.exec-runtime.js", () => ({
@@ -229,26 +223,12 @@ function requireApprovalFollowupInput(
return call[0];
}
function captureSecurityEvents(): {
events: DiagnosticSecurityEvent[];
stop: () => void;
} {
const events: DiagnosticSecurityEvent[] = [];
const stop = onInternalDiagnosticEvent((event, metadata) => {
if (metadata.trusted && event.type === "security.event") {
events.push(event);
}
});
return { events, stop };
}
describe("processGatewayAllowlist", () => {
beforeAll(async () => {
({ processGatewayAllowlist } = await import("./bash-tools.exec-host-gateway.js"));
});
beforeEach(() => {
resetDiagnosticEventsForTest();
buildExecApprovalPendingToolResultMock.mockReset();
buildExecApprovalFollowupTargetMock.mockReset();
buildExecApprovalFollowupTargetMock.mockReturnValue(null);
@@ -297,8 +277,6 @@ describe("processGatewayAllowlist", () => {
recordAllowlistMatchesUseMock.mockReset();
resolveApprovalDecisionOrUndefinedMock.mockReset();
resolveApprovalDecisionOrUndefinedMock.mockResolvedValue(undefined);
shouldResolveExecApprovalUnavailableInlineMock.mockReset();
shouldResolveExecApprovalUnavailableInlineMock.mockReturnValue(false);
resolveExecHostApprovalContextMock.mockReset();
resolveExecHostApprovalContextMock.mockReturnValue({
approvals: { allowlist: [], file: { version: 1, agents: {} } },
@@ -396,143 +374,6 @@ describe("processGatewayAllowlist", () => {
expect(result.pendingResult?.details.status).toBe("approval-pending");
});
it("emits security events for gateway exec approval requests and denials", async () => {
resolveApprovalDecisionOrUndefinedMock.mockResolvedValue("deny");
createExecApprovalDecisionStateMock.mockReturnValue({
baseDecision: { timedOut: false },
approvedByAsk: false,
deniedReason: "user-denied",
});
const captured = captureSecurityEvents();
let result: Awaited<ReturnType<typeof runGatewayAllowlist>>;
try {
result = await runGatewayAllowlist({
command: "deploy --token raw-secret-value",
turnSourceChannel: "webchat",
agentId: "agent-1",
});
} finally {
captured.stop();
}
expect(result!.deniedResult?.details.status).toBe("failed");
expect(captured.events).toHaveLength(2);
expect(captured.events[0]).toMatchObject({
action: "exec.approval.requested",
outcome: "success",
severity: "low",
category: "approval",
actor: { kind: "agent" },
target: { kind: "tool", name: "system.exec", owner: "gateway" },
policy: { id: "exec.approval", decision: "ask" },
control: { id: "exec.approval", family: "approval" },
attributes: {
host: "gateway",
security: "allowlist",
ask: "off",
segment_count: 1,
has_agent_id: true,
},
});
expect(captured.events[1]).toMatchObject({
action: "exec.approval.denied",
outcome: "denied",
severity: "medium",
reason: "user-denied",
policy: { id: "exec.approval", decision: "deny", reason: "user-denied" },
attributes: {
decision: "deny",
has_agent_id: true,
},
});
const serialized = JSON.stringify(captured.events);
expect(serialized).not.toContain("deploy");
expect(serialized).not.toContain("raw-secret-value");
expect(serialized).not.toContain("agent-1");
});
it("emits a denied security event for inline unavailable approval denials", async () => {
shouldResolveExecApprovalUnavailableInlineMock.mockReturnValue(true);
createExecApprovalDecisionStateMock.mockReturnValue({
baseDecision: { timedOut: false },
approvedByAsk: false,
deniedReason: "user-denied",
});
enforceStrictInlineEvalApprovalBoundaryMock.mockReturnValue({
approvedByAsk: false,
deniedReason: "user-denied",
});
const captured = captureSecurityEvents();
try {
await expect(
runGatewayAllowlist({
command: "deploy --token raw-secret-value",
agentId: "agent-1",
}),
).rejects.toThrow("denied");
} finally {
captured.stop();
}
expect(captured.events).toHaveLength(2);
expect(captured.events[1]).toMatchObject({
action: "exec.approval.denied",
outcome: "denied",
severity: "medium",
reason: "user-denied",
policy: { id: "exec.approval", decision: "deny", reason: "user-denied" },
attributes: {
has_agent_id: true,
},
});
const serialized = JSON.stringify(captured.events);
expect(serialized).not.toContain("deploy");
expect(serialized).not.toContain("raw-secret-value");
expect(serialized).not.toContain("agent-1");
});
it("emits an approved security event for inline unavailable approval approvals", async () => {
shouldResolveExecApprovalUnavailableInlineMock.mockReturnValue(true);
createExecApprovalDecisionStateMock.mockReturnValue({
baseDecision: { timedOut: false },
approvedByAsk: true,
deniedReason: null,
});
enforceStrictInlineEvalApprovalBoundaryMock.mockReturnValue({
approvedByAsk: true,
deniedReason: null,
});
const captured = captureSecurityEvents();
let result: Awaited<ReturnType<typeof runGatewayAllowlist>>;
try {
result = await runGatewayAllowlist({
command: "echo ok",
agentId: "agent-1",
});
} finally {
captured.stop();
}
expect(result!).toEqual({
execCommandOverride: undefined,
allowWithoutEnforcedCommand: true,
});
expect(captured.events).toHaveLength(2);
expect(captured.events[1]).toMatchObject({
action: "exec.approval.approved",
outcome: "success",
severity: "medium",
policy: { id: "exec.approval", decision: "allow" },
attributes: {
has_agent_id: true,
},
});
expect(JSON.stringify(captured.events)).not.toContain("agent-1");
});
it("auto-reviews simple read-only approval misses without prompting", async () => {
requiresExecApprovalMock.mockReturnValue(true);
evaluateShellAllowlistMock.mockReturnValue({

View File

@@ -7,7 +7,6 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
import { describeInterpreterInlineEval } from "../infra/command-analysis/inline-eval.js";
import { detectPolicyInlineEval } from "../infra/command-analysis/policy.js";
import { emitTrustedSecurityEvent } from "../infra/diagnostic-events.js";
import {
addDurableCommandApproval,
commandRequiresSecurityAuditSuppressionApproval,
@@ -63,7 +62,7 @@ import type {
import type { AgentToolResult } from "./runtime/index.js";
/** Full input bundle for gateway-host allowlist and approval processing. */
type ProcessGatewayAllowlistParams = {
export type ProcessGatewayAllowlistParams = {
command: string;
workdir: string;
env: Record<string, string>;
@@ -105,7 +104,7 @@ type ProcessGatewayAllowlistParams = {
};
/** Gateway allowlist outcome before command execution continues. */
type ProcessGatewayAllowlistResult = {
export type ProcessGatewayAllowlistResult = {
execCommandOverride?: string;
allowWithoutEnforcedCommand?: boolean;
pendingResult?: AgentToolResult<ExecToolDetails>;
@@ -251,59 +250,6 @@ function formatDiagnosticsExportSuccess(aggregated: string): string {
}
}
function emitGatewayExecApprovalSecurityEvent(params: {
action: "exec.approval.requested" | "exec.approval.approved" | "exec.approval.denied";
outcome: "success" | "denied" | "error";
severity: "low" | "medium" | "high";
agentId?: string | null;
reason?: string;
hostSecurity: ExecSecurity;
hostAsk: ExecAsk;
host: "gateway";
segmentCount: number;
trigger?: string;
decision?: string | null;
}) {
emitTrustedSecurityEvent({
category: "approval",
action: params.action,
outcome: params.outcome,
severity: params.severity,
actor: {
kind: "agent",
},
target: {
kind: "tool",
name: "system.exec",
owner: params.host,
},
policy: {
id: "exec.approval",
decision:
params.action === "exec.approval.requested"
? "ask"
: params.outcome === "success"
? "allow"
: "deny",
...(params.reason ? { reason: params.reason } : {}),
},
control: {
id: "exec.approval",
family: "approval",
},
...(params.reason ? { reason: params.reason } : {}),
attributes: {
host: params.host,
security: params.hostSecurity,
ask: params.hostAsk,
segment_count: params.segmentCount,
has_agent_id: Boolean(params.agentId?.trim()),
...(params.trigger ? { trigger: params.trigger } : {}),
...(params.decision ? { decision: params.decision } : {}),
},
});
}
function formatDiagnosticsExportFailure(params: {
outcome: { status: string; reason?: string; aggregated: string };
exitLabel: string;
@@ -613,17 +559,6 @@ export async function processGatewayAllowlist(
...requestArgs,
register: registerGatewayApproval,
});
emitGatewayExecApprovalSecurityEvent({
action: "exec.approval.requested",
outcome: "success",
severity: "low",
agentId: params.agentId,
hostSecurity,
hostAsk,
host: "gateway",
segmentCount: allowlistEval.segments.length,
trigger: params.trigger,
});
if (
shouldResolveExecApprovalUnavailableInline({
trigger: params.trigger,
@@ -644,20 +579,6 @@ export async function processGatewayAllowlist(
});
if (strictInlineEvalDecision.deniedReason || !strictInlineEvalDecision.approvedByAsk) {
const inlineDeniedReason = strictInlineEvalDecision.deniedReason ?? "approval-required";
emitGatewayExecApprovalSecurityEvent({
action: "exec.approval.denied",
outcome: "denied",
severity: "medium",
agentId: params.agentId,
reason: inlineDeniedReason,
hostSecurity,
hostAsk,
host: "gateway",
segmentCount: allowlistEval.segments.length,
trigger: params.trigger,
decision: preResolvedDecision,
});
throw new Error(
buildHeadlessExecApprovalDeniedMessage({
trigger: params.trigger,
@@ -669,18 +590,6 @@ export async function processGatewayAllowlist(
);
}
emitGatewayExecApprovalSecurityEvent({
action: "exec.approval.approved",
outcome: "success",
severity: "medium",
agentId: params.agentId,
hostSecurity,
hostAsk,
host: "gateway",
segmentCount: allowlistEval.segments.length,
trigger: params.trigger,
decision: preResolvedDecision,
});
recordMatchedAllowlistUse(
resolveApprovalAuditTrustPath(
allowlistEval.segments[0]?.resolution ?? null,
@@ -703,18 +612,6 @@ export async function processGatewayAllowlist(
onFailure,
});
if (decision === undefined) {
emitGatewayExecApprovalSecurityEvent({
action: "exec.approval.denied",
outcome: "error",
severity: "high",
agentId: params.agentId,
reason: "approval-request-failed",
hostSecurity,
hostAsk,
host: "gateway",
segmentCount: allowlistEval.segments.length,
trigger: params.trigger,
});
return { deniedReason: "approval-request-failed", requestFailed: true };
}
@@ -781,19 +678,6 @@ export async function processGatewayAllowlist(
deniedReason = deniedReason ?? "allowlist-miss";
}
emitGatewayExecApprovalSecurityEvent({
action: deniedReason ? "exec.approval.denied" : "exec.approval.approved",
outcome: deniedReason ? "denied" : "success",
severity: "medium",
agentId: params.agentId,
reason: deniedReason ?? undefined,
hostSecurity,
hostAsk,
host: "gateway",
segmentCount: allowlistEval.segments.length,
trigger: params.trigger,
decision,
});
return { deniedReason, requestFailed: false };
};

View File

@@ -37,6 +37,8 @@ import type { ExecToolDetails } from "./bash-tools.exec-types.js";
import type { AgentToolResult } from "./runtime/index.js";
import { callGatewayTool } from "./tools/gateway.js";
export type { ExecuteNodeHostCommandParams } from "./bash-tools.exec-host-node.types.js";
const APPROVED_NODE_INVOKE_SCOPES = [WRITE_SCOPE, APPROVALS_SCOPE];
function resolveNodeAutoReviewReason(params: {

View File

@@ -28,13 +28,13 @@ import type {
} from "./agent-cache-store.js";
/** Options for an agent/scope-scoped SQLite runtime cache. */
type SqliteAgentCacheStoreOptions = OpenClawAgentDatabaseOptions & {
export type SqliteAgentCacheStoreOptions = OpenClawAgentDatabaseOptions & {
scope: string;
now?: () => number;
};
/** Options for writing a single SQLite agent cache entry. */
type WriteSqliteAgentCacheEntryOptions = SqliteAgentCacheStoreOptions &
export type WriteSqliteAgentCacheEntryOptions = SqliteAgentCacheStoreOptions &
AgentRuntimeCacheWriteOptions;
type CacheEntriesTable = OpenClawAgentKyselyDatabase["cache_entries"];
@@ -297,7 +297,7 @@ export function clearExpiredSqliteAgentCacheEntries(
}
/** Agent runtime cache store implementation backed by OpenClaw's SQLite DB. */
class SqliteAgentCacheStore implements AgentRuntimeCacheStore {
export class SqliteAgentCacheStore implements AgentRuntimeCacheStore {
readonly #options: SqliteAgentCacheStoreOptions;
constructor(options: SqliteAgentCacheStoreOptions) {
@@ -349,6 +349,6 @@ class SqliteAgentCacheStore implements AgentRuntimeCacheStore {
/** Create a SQLite-backed agent runtime cache store. */
export function createSqliteAgentCacheStore(
options: SqliteAgentCacheStoreOptions,
): AgentRuntimeCacheStore {
): SqliteAgentCacheStore {
return new SqliteAgentCacheStore(options);
}

View File

@@ -12,7 +12,7 @@ export type AgentAttemptLifecycleState = {
};
/** Event shape emitted by runtimes during an agent attempt. */
type AgentAttemptLifecycleEvent = {
export type AgentAttemptLifecycleEvent = {
stream: string;
data?: Record<string, unknown>;
sessionKey?: string;

View File

@@ -15,7 +15,7 @@ import {
import type { AgentCommandOpts } from "./types.js";
/** Parameters for merging and persisting a session entry update. */
type PersistSessionEntryParams = {
export type PersistSessionEntryParams = {
sessionStore: Record<string, SessionEntry>;
sessionKey: string;
storePath: string;

View File

@@ -66,10 +66,10 @@ function createRestartOnlyAbortSignal(source: AbortSignal | undefined): {
}
/** Per-payload durable delivery status. */
type AgentCommandDeliveryPayloadStatus = "sent" | "suppressed" | "failed";
export type AgentCommandDeliveryPayloadStatus = "sent" | "suppressed" | "failed";
/** Delivery outcome for one normalized outbound payload. */
type AgentCommandDeliveryPayloadOutcome = {
export type AgentCommandDeliveryPayloadOutcome = {
index: number;
status: AgentCommandDeliveryPayloadStatus;
reason?: string;
@@ -84,7 +84,7 @@ type AgentCommandDeliveryPayloadOutcome = {
};
/** Aggregate delivery status for an agent command result. */
type AgentCommandDeliveryStatus = {
export type AgentCommandDeliveryStatus = {
requested: true;
attempted: boolean;
status: "sent" | "suppressed" | "partial_failed" | "failed";
@@ -100,7 +100,7 @@ type AgentCommandDeliveryStatus = {
};
/** Agent command result after payload normalization and optional delivery. */
type AgentCommandDeliveryResult = {
export type AgentCommandDeliveryResult = {
payloads: ReturnType<typeof projectOutboundPayloadPlanForJson>;
meta: EmbeddedAgentRunMeta & AgentCommandResultMetaOverrides;
didSendViaMessagingTool?: boolean;

View File

@@ -41,7 +41,7 @@ import { clearBootstrapSnapshotOnSessionRollover } from "../bootstrap-cache.js";
import { clearAllCliSessions } from "../cli-session.js";
/** Resolved command session identity plus backing store metadata. */
type SessionResolution = {
export type SessionResolution = {
sessionId: string;
sessionKey?: string;
sessionEntry?: SessionEntry;

View File

@@ -29,7 +29,7 @@ export type AgentCommandResultMetaOverrides = {
};
/** ACP turn source markers accepted by trusted command callsites. */
type AcpTurnSource = "manual_spawn";
export type AcpTurnSource = "manual_spawn";
/** Channel/account/thread context carried into an agent run. */
export type AgentRunContext = {

View File

@@ -12,7 +12,7 @@ import type { FailoverReason } from "../../embedded-agent-helpers.js";
import { log } from "../logger.js";
/** Structured fields emitted whenever embedded run failover chooses an action. */
type FailoverDecisionLoggerInput = {
export type FailoverDecisionLoggerInput = {
stage: "prompt" | "assistant";
decision: "rotate_profile" | "fallback_model" | "surface_error";
runId?: string;
@@ -31,7 +31,7 @@ type FailoverDecisionLoggerInput = {
};
/** Stable context captured before a concrete failover decision is known. */
type FailoverDecisionLoggerBase = Omit<FailoverDecisionLoggerInput, "decision" | "status">;
export type FailoverDecisionLoggerBase = Omit<FailoverDecisionLoggerInput, "decision" | "status">;
/**
* Derives timeout failure reasons for logs that were built from timeout state

View File

@@ -84,7 +84,7 @@ export type TraceAttempt = {
status?: number;
};
type ExecutionTrace = {
export type ExecutionTrace = {
winnerProvider?: string;
winnerModel?: string;
attempts?: TraceAttempt[];
@@ -92,7 +92,7 @@ type ExecutionTrace = {
runner?: "embedded" | "cli";
};
type RequestShapingTrace = {
export type RequestShapingTrace = {
authMode?: string;
thinking?: string;
reasoning?: string;
@@ -102,7 +102,7 @@ type RequestShapingTrace = {
blockStreaming?: string;
};
type PromptSegmentTrace = {
export type PromptSegmentTrace = {
key: string;
chars: number;
};
@@ -114,13 +114,13 @@ export type ToolSummaryTrace = {
totalToolTimeMs?: number;
};
type CompletionTrace = {
export type CompletionTrace = {
finishReason?: string;
stopReason?: string;
refusal?: boolean;
};
type ContextManagementTrace = {
export type ContextManagementTrace = {
sessionCompactions?: number;
lastTurnCompactions?: number;
preflightCompactionApplied?: boolean;

View File

@@ -145,7 +145,7 @@ export async function awaitAgentHarnessAgentEndHook(params: {
}
/** Normalized before-finalize hook decision consumed by harness loops. */
type AgentHarnessBeforeAgentFinalizeOutcome =
export type AgentHarnessBeforeAgentFinalizeOutcome =
| { action: "continue" }
| { action: "revise"; reason: string }
| { action: "finalize"; reason?: string };

View File

@@ -18,7 +18,7 @@ import { buildAgentHookContext, type AgentHarnessHookContext } from "./hook-cont
const log = createSubsystemLogger("agents/harness");
/** Prompt/developer-instruction pair after harness prompt-build hooks run. */
type AgentHarnessPromptBuildResult = {
export type AgentHarnessPromptBuildResult = {
prompt: string;
developerInstructions: string;
};

View File

@@ -83,7 +83,7 @@ export type AgentHarnessDeliveryDefaults = {
sourceVisibleReplies?: "automatic" | "message_tool";
};
type AgentHarnessRunCapability = {
export type AgentHarnessRunCapability = {
id: string;
label: string;
pluginId?: string;
@@ -98,22 +98,22 @@ type AgentHarnessRunCapability = {
runAttempt(params: AgentHarnessAttemptParams): Promise<AgentHarnessAttemptResult>;
};
type AgentHarnessSideQuestionCapability = {
export type AgentHarnessSideQuestionCapability = {
runSideQuestion?(params: AgentHarnessSideQuestionParams): Promise<AgentHarnessSideQuestionResult>;
};
type AgentHarnessClassificationCapability = {
export type AgentHarnessClassificationCapability = {
classify?(
result: AgentHarnessAttemptResult,
ctx: AgentHarnessAttemptParams,
): AgentHarnessResultClassification | undefined;
};
type AgentHarnessCompactionCapability = {
export type AgentHarnessCompactionCapability = {
compact?(params: AgentHarnessCompactParams): Promise<AgentHarnessCompactResult | undefined>;
};
type AgentHarnessSessionLifecycleCapability = {
export type AgentHarnessSessionLifecycleCapability = {
reset?(params: AgentHarnessResetParams): Promise<void> | void;
dispose?(): Promise<void> | void;
};

View File

@@ -15,7 +15,7 @@ import {
import { isAnthropicBillingError, isApiKeyRateLimitError } from "./live-auth-keys.js";
import { isModelNotFoundErrorMessage } from "./live-model-errors.js";
type LiveProviderDriftReason =
export type LiveProviderDriftReason =
| "auth"
| "billing"
| "model-not-found"
@@ -24,13 +24,13 @@ type LiveProviderDriftReason =
| "timeout";
/** A normalized reason for skipping or soft-failing live provider drift. */
type LiveProviderDriftDecision = {
export type LiveProviderDriftDecision = {
label: string;
reason: LiveProviderDriftReason;
};
/** Classifier options that control which live-provider drift reasons are allowed. */
type LiveProviderDriftOptions = {
export type LiveProviderDriftOptions = {
allowAuth?: boolean;
allowBilling?: boolean;
allowModelNotFound?: boolean;
@@ -41,7 +41,7 @@ type LiveProviderDriftOptions = {
};
/** Converts arbitrary thrown values into text for provider drift matchers. */
function liveProviderErrorText(error: unknown): string {
export function liveProviderErrorText(error: unknown): string {
return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
}
@@ -69,12 +69,12 @@ export function isLiveRateLimitDrift(error: unknown): boolean {
}
/** Returns whether an error is expected live timeout drift. */
function isLiveTimeoutDrift(error: unknown): boolean {
export function isLiveTimeoutDrift(error: unknown): boolean {
return isTimeoutErrorMessage(liveProviderErrorText(error));
}
/** Returns whether an error is expected live missing-model drift. */
function isLiveModelNotFoundDrift(error: unknown): boolean {
export function isLiveModelNotFoundDrift(error: unknown): boolean {
return isModelNotFoundErrorMessage(liveProviderErrorText(error));
}

View File

@@ -141,6 +141,19 @@ function findPersistedTaskForRecentMediaGenerationStart(params: {
});
}
/** Returns whether a task is an active session-scoped media generation task. */
export function isActiveMediaGenerationTask(params: {
task: TaskRecord;
taskKind: string;
}): boolean {
return (
params.task.runtime === "cli" &&
params.task.scopeKind === "session" &&
params.task.taskKind === params.taskKind &&
(params.task.status === "queued" || params.task.status === "running")
);
}
/** Records a just-started media task so duplicate guards work before persistence. */
export function recordRecentMediaGenerationTaskStartForSession(params: {
sessionKey?: string;
@@ -281,7 +294,7 @@ export function resetRecentMediaGenerationDuplicateGuardsForTests() {
}
/** Extracts a provider id from a media task source id with the given prefix. */
function getMediaGenerationTaskProviderId(
export function getMediaGenerationTaskProviderId(
task: TaskRecord,
sourcePrefix: string,
): string | undefined {

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