Compare commits

..

15 Commits

Author SHA1 Message Date
dependabot[bot]
6f0a8e5949 build(deps): bump the actions group across 1 directory with 5 updates
Bumps the actions group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [actions/create-github-app-token](https://github.com/actions/create-github-app-token) | `3.1.1` | `3.2.0` |
| [actions/setup-python](https://github.com/actions/setup-python) | `6.2.0` | `6.3.0` |
| [actions/setup-java](https://github.com/actions/setup-java) | `5.2.0` | `5.4.0` |
| [openai/codex-action](https://github.com/openai/codex-action) | `1.8` | `1.9` |
| [actions/setup-go](https://github.com/actions/setup-go) | `6.4.0` | `6.5.0` |



Updates `actions/create-github-app-token` from 3.1.1 to 3.2.0
- [Release notes](https://github.com/actions/create-github-app-token/releases)
- [Changelog](https://github.com/actions/create-github-app-token/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/create-github-app-token/compare/v3.1.1...bcd2ba49218906704ab6c1aa796996da409d3eb1)

Updates `actions/setup-python` from 6.2.0 to 6.3.0
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](a309ff8b42...ece7cb06ca)

Updates `actions/setup-java` from 5.2.0 to 5.4.0
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v5.2.0...1bcf9fb12cf4aa7d266a90ae39939e61372fe520)

Updates `openai/codex-action` from 1.8 to 1.9
- [Changelog](https://github.com/openai/codex-action/blob/main/CHANGELOG.md)
- [Commits](e0fdf01220...10cb888d2e)

Updates `actions/setup-go` from 6.4.0 to 6.5.0
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](4a3601121d...924ae3a1cd)

---
updated-dependencies:
- dependency-name: actions/create-github-app-token
  dependency-version: 3.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: actions/setup-go
  dependency-version: 6.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: actions/setup-java
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: actions/setup-python
  dependency-version: 6.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: openai/codex-action
  dependency-version: '1.9'
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-26 08:46:20 +00:00
Liu Wenyu
9a735bea03 fix(outbound): ignore empty delivery receipts (#79811) 2026-06-26 09:40:46 +02:00
Peter Steinberger
81e53202f2 fix(scripts): bypass gh wrapper shims 2026-06-26 07:51:07 +01:00
xingzhou
e9f9a68d68 fix(weixin): startAccount preserves session routing (#93686)
* fix(channels): resolve manifest account config by normalized id

* fix(routing): ignore blocked keys during normalized account lookup

* fix(routing): block normalized unsafe account keys

* test(routing): type normalized account lookup case

* trigger CI

* fix account lookup invalid key fallback

* fix(weixin): startAccount preserves session routing (#93686) (thanks @zhangguiping-xydt)

---------

Co-authored-by: sliverp <870080352@qq.com>
2026-06-26 14:36:49 +08:00
Josh Avant
db255b1154 Fix Telegram spooled claim refresh (#96962) 2026-06-26 01:30:57 -05:00
Gio Della-Libera
4fc504d321 Doctor: add lint --all (#96471)
* fix(doctor): keep audit scrub lint opt-in

* fix(doctor): keep audit lint defaults internal

* feat(doctor): add lint profiles
2026-06-25 22:26:42 -07:00
Dallin Romney
751a6c23f0 fix(signal): avoid duplicate cli missing note (#96932) 2026-06-25 21:30:44 -07:00
Dallin Romney
899f65097b ci: park timing summary collection (#96930) 2026-06-25 21:20:44 -07:00
Kevin Lin
a6a4652c70 fix(codex): expose plugin apps after delayed inventory load (#96872)
* fix(codex): refresh missing plugin app inventory

* fix(codex): honor OpenClaw app enablement overrides
2026-06-25 21:10:02 -07:00
Dallin Romney
3b292ba9d4 fix(signal): use brew for macos signal-cli install (#96909) 2026-06-25 21:08:54 -07:00
Jesse Merhi
0fdfc9f65f fix(exec): harden backend sandbox exec cleanup (#96926) 2026-06-26 13:51:52 +10:00
Josh Avant
448b7c75b6 Stabilize Google Meet chrome-node launch config (#96908) 2026-06-25 22:11:57 -05:00
joshavant
6830aa39ea fix(signal): bind approval reactions from structured deliveries 2026-06-25 20:15:58 -05:00
brokemac79
a0b397748f fix(status): restore Codex synthetic usage in status 2026-06-25 17:37:30 -07:00
brokemac79
dd0e4f6e61 fix: avoid plugin update range fallback after metadata failure (#96143)
Co-authored-by: Dallin Romney <dallinromney@gmail.com>
2026-06-25 17:15:50 -07:00
78 changed files with 3597 additions and 554 deletions

View File

@@ -1843,7 +1843,7 @@ jobs:
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
- name: Setup Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.12"
@@ -2324,7 +2324,7 @@ jobs:
exit 1
- name: Setup Java
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5
with:
distribution: temurin
# Keep sdkmanager on the stable JDK path for Linux CI runners.
@@ -2419,7 +2419,8 @@ jobs:
- macos-swift
- ios-build
- android
if: ${{ !cancelled() && always() && github.event_name != 'push' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
# Re-enable this job when we want to collect CI timing data for timing optimization.
if: ${{ false && !cancelled() && always() && github.event_name != 'push' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:

View File

@@ -73,7 +73,7 @@ jobs:
- name: Create ClawSweeper dispatch token
id: token
if: ${{ env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }}
private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}
@@ -102,7 +102,7 @@ jobs:
steps.comment_filter.outputs.is_command == 'true' &&
env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true'
}}
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }}
private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}

View File

@@ -29,7 +29,7 @@ jobs:
submodules: false
- name: Setup Java
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5
with:
distribution: temurin
java-version: "21"

View File

@@ -57,7 +57,7 @@ jobs:
- name: Create autoscrub app token
id: app-token
continue-on-error: true
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
@@ -69,7 +69,7 @@ jobs:
id: app-token-fallback
continue-on-error: true
if: steps.app-token.outcome == 'failure'
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: "2971289"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}

View File

@@ -149,7 +149,7 @@ jobs:
- name: Run Codex docs agent
if: steps.gate.outputs.run_agent == 'true'
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
uses: openai/codex-action@10cb888d2ed3b99867f7e7ccff174a861a75aeb6
env:
DOCS_AGENT_BASE_SHA: ${{ steps.gate.outputs.review_base_sha }}
DOCS_AGENT_HEAD_SHA: ${{ steps.gate.outputs.review_head_sha }}

View File

@@ -260,7 +260,7 @@ jobs:
run: pnpm build
- name: Setup Go for Crabbox CLI
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version: "1.26.x"
cache: false

View File

@@ -250,7 +250,7 @@ jobs:
run: pnpm build
- name: Setup Go for Crabbox CLI
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version: "1.26.x"
cache: false

View File

@@ -190,7 +190,7 @@ jobs:
mantis-slack-pnpm-${{ runner.os }}-${{ env.NODE_VERSION }}-
- name: Setup Go for Crabbox CLI
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version: "1.26.x"
cache: false

View File

@@ -362,7 +362,7 @@ jobs:
install-bun: "true"
- name: Setup Go for Crabbox CLI
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version: "1.26.x"
cache: false
@@ -445,7 +445,7 @@ jobs:
sudo chown -R codex:codex "$GITHUB_WORKSPACE"
- name: Run Codex Mantis Telegram agent
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
uses: openai/codex-action@10cb888d2ed3b99867f7e7ccff174a861a75aeb6
env:
BASELINE_REF: ${{ needs.resolve_request.outputs.baseline_ref }}
BASELINE_SHA: ${{ needs.validate_refs.outputs.baseline_revision }}

View File

@@ -337,7 +337,7 @@ jobs:
mantis-telegram-pnpm-${{ runner.os }}-${{ env.NODE_VERSION }}-
- name: Setup Go for Crabbox CLI
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version: "1.26.x"
cache: false

View File

@@ -275,7 +275,7 @@ jobs:
fi
- name: Run Codex maturity scorecard agent
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
uses: openai/codex-action@10cb888d2ed3b99867f7e7ccff174a861a75aeb6
env:
MATURITY_EVIDENCE_DIR: .artifacts/maturity-evidence
MATURITY_SCORES_PATH: qa/maturity-scores.yaml

View File

@@ -129,7 +129,7 @@ jobs:
- name: Run Codex test performance agent
if: steps.gate.outputs.run_agent == 'true'
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
uses: openai/codex-action@10cb888d2ed3b99867f7e7ccff174a861a75aeb6
with:
openai-api-key: ${{ secrets.OPENCLAW_TEST_PERF_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
prompt-file: .github/codex/prompts/test-performance-agent.md

View File

@@ -115,7 +115,7 @@ jobs:
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
- name: Setup Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.12"

View File

@@ -2,6 +2,12 @@
Docs: https://docs.openclaw.ai
## Unreleased
### Fixes
- **WeChat account routing:** `startAccount` preserves session routing by resolving manifest channel account config from raw account keys with opaque provider ids, while still ignoring manifest account keys that normalize to blocked object keys. (#93686) Thanks @zhangguiping-xydt.
## 2026.6.10
### Highlights

View File

@@ -40,6 +40,7 @@ openclaw doctor
openclaw doctor --lint
openclaw doctor --lint --json
openclaw doctor --lint --severity-min warning
openclaw doctor --lint --all
openclaw doctor --lint --allow-exec
openclaw doctor --deep
openclaw doctor --fix
@@ -73,6 +74,7 @@ The targeted Discord capabilities probe reports the bot's effective channel perm
- `--post-upgrade`: run post-upgrade plugin compatibility probes; emits findings to stdout; exits with code 1 if any error-level findings are present
- `--json`: with `--lint`, emit JSON findings instead of human output; with `--post-upgrade`, emit a machine-readable JSON envelope (`{ probesRun, findings }`)
- `--severity-min <level>`: with `--lint`, drop findings below `info`, `warning`, or `error`
- `--all`: with `--lint`, run all registered checks, including opt-in checks excluded from the default automation set
- `--skip <id>`: with `--lint`, skip a check id; repeat to skip more than one
- `--only <id>`: with `--lint`, run only a check id; repeat to run a small selected set
@@ -82,13 +84,14 @@ The targeted Discord capabilities probe reports the bot's effective channel perm
It uses the structured health-check path, does not prompt, and does not repair
or rewrite config/state. Use it in CI, preflight scripts, and review workflows
when you want machine-readable findings instead of guided repair prompts.
Lint-output options such as `--json`, `--severity-min`, `--only`, and `--skip`
Lint-output options such as `--json`, `--severity-min`, `--all`, `--only`, and `--skip`
are only accepted with `--lint`.
```bash
openclaw doctor --lint
openclaw doctor --lint --severity-min warning
openclaw doctor --lint --json
openclaw doctor --lint --all
openclaw doctor --lint --allow-exec
openclaw doctor --lint --only core/doctor/gateway-config --json
```
@@ -130,6 +133,13 @@ Exit behavior:
example, `openclaw doctor --lint --severity-min error` can print no findings and
exit `0` even when lower-severity `info` or `warning` findings exist.
`--all` controls which checks are selected before severity filtering. The
default lint run is the stable automation gate and excludes checks that are
intentionally opt-in because they are deep, historical, or more likely to
surface repairable legacy residue. Use `--all` when you want the complete lint
inventory without listing each check id. `--only <id>` remains the most precise
selector and can run any registered check by id.
## Structured Health Checks
Modern doctor checks use a small structured contract:
@@ -186,6 +196,7 @@ Use `--only` and `--skip` when a workflow wants a focused gate:
```bash
openclaw doctor --lint --only core/doctor/gateway-config --json
openclaw doctor --lint --skip core/doctor/skills-readiness
openclaw doctor --lint --all --skip core/doctor/session-locks
```
`--only` and `--skip` accept full check ids and may be repeated. If an `--only`

View File

@@ -104,6 +104,7 @@ Examples:
openclaw doctor --lint
openclaw doctor --lint --severity-min warning
openclaw doctor --lint --json
openclaw doctor --lint --all
openclaw doctor --lint --only core/doctor/gateway-config --json
```
@@ -111,7 +112,7 @@ JSON output includes:
- `ok`: whether any visible finding met the selected severity threshold
- `checksRun`: number of health checks executed
- `checksSkipped`: checks skipped by `--only` or `--skip`
- `checksSkipped`: checks skipped by the selected profile, `--only`, or `--skip`
- `findings`: structured diagnostics with `checkId`, `severity`, `message`, and
optional `path`, `line`, `column`, `ocPath`, and `fixHint`
@@ -122,11 +123,13 @@ Exit codes:
- `2`: command/runtime failure before lint findings could be emitted
Use `--severity-min info|warning|error` to control both what is printed and what
causes a non-zero lint exit. Use `--only <id>` for narrow preflight gates and
causes a non-zero lint exit. Use `--all` to run the complete lint inventory,
including deeper opt-in checks excluded from the default automation set. Use `--only <id>` for narrow preflight gates and
`--skip <id>` to temporarily exclude a noisy check while keeping the rest of the
lint run active.
Lint-output options such as `--json`, `--severity-min`, `--only`, and `--skip`
must be paired with `--lint`; regular doctor and repair runs reject them.
Lint-output options such as `--json`, `--severity-min`, `--all`, `--only`, and
`--skip` must be paired with `--lint`; regular doctor and repair runs reject
them.
## What it does (summary)

View File

@@ -155,9 +155,13 @@ shorthand before OpenClaw builds app-server start options, and unresolved
structured SecretRefs fail before any token or header is sent. When native Codex
plugins are configured, OpenClaw uses the connected app-server's plugin control
plane to install or refresh those plugins and then refreshes app inventory so
plugin-owned apps are visible to the Codex thread. Only connect OpenClaw to
remote app-servers that are trusted to accept OpenClaw-managed plugin installs
and app inventory refreshes.
plugin-owned apps are visible to the Codex thread. `app/list` is still the
authoritative inventory and metadata source, but OpenClaw policy decides whether
`thread/start` sends `config.apps[appId].enabled = true` for a listed accessible
app even if Codex currently marks it disabled. Unknown or missing app ids remain
fail-closed; this path only activates marketplace plugins via `plugin/install`
and refreshes inventory. Only connect OpenClaw to remote app-servers that are
trusted to accept OpenClaw-managed plugin installs and app inventory refreshes.
## Approval and sandbox modes

View File

@@ -465,7 +465,13 @@ do not receive Gateway env API-key fallback; use an explicit auth profile or the
remote app-server's own account.
When native Codex plugins are configured, OpenClaw installs or refreshes those
plugins through the connected app-server before exposing plugin-owned apps to
the Codex thread.
the Codex thread. `app/list` remains the source of truth for app ids,
accessibility, and metadata, but OpenClaw owns the per-thread enablement
decision: if policy allows a listed accessible app, OpenClaw sends
`thread/start.config.apps[appId].enabled = true` even when `app/list` currently
reports that app disabled. This path does not invent app installation for
unknown ids; OpenClaw only activates marketplace plugins with `plugin/install`
and then refreshes inventory.
If a subscription profile hits a Codex usage limit, OpenClaw records the reset
time when Codex reports one and tries the next ordered auth profile for the same

View File

@@ -254,7 +254,7 @@ describe("Codex plugin thread config", () => {
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "app/list") {
appListParams.push(params as v2.AppsListParams);
return { data: [appInfo("google-calendar-app", true)], nextCursor: null };
return { data: [appInfo("google-calendar-app", true, false)], nextCursor: null };
}
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
@@ -317,6 +317,117 @@ describe("Codex plugin thread config", () => {
]);
});
it("re-enables an OpenClaw-allowed app even when app/list reports it disabled", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () => ({
data: [appInfo("google-calendar-app", true, false)],
nextCursor: null,
}),
});
const config = await buildCodexPluginThreadConfig({
pluginConfig: {
codexPlugins: {
enabled: true,
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
nowMs: 1,
request: async (method) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
}
throw new Error(`unexpected request ${method}`);
},
});
expect(config.inventory?.records[0]?.apps).toStrictEqual([
{
id: "google-calendar-app",
name: "google-calendar-app",
accessible: true,
enabled: false,
needsAuth: false,
},
]);
expect(config.configPatch?.apps).toMatchObject({
"google-calendar-app": {
enabled: true,
},
});
expect(config.diagnostics).toStrictEqual([]);
});
it("refreshes missing app inventory when plugin activation becomes unnecessary", async () => {
const appCache = new CodexAppInventoryCache();
const appListParams: v2.AppsListParams[] = [];
let pluginListCalls = 0;
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "plugin/list") {
pluginListCalls += 1;
const active = pluginListCalls > 1;
return pluginList([
pluginSummary("google-calendar", { installed: active, enabled: active }),
]);
}
if (method === "plugin/read") {
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
}
if (method === "app/list") {
appListParams.push(params as v2.AppsListParams);
return {
data: [appInfo("google-calendar-app", true)],
nextCursor: null,
} satisfies v2.AppsListResponse;
}
throw new Error(`unexpected request ${method}`);
});
const config = await buildCodexPluginThreadConfig({
pluginConfig: {
codexPlugins: {
enabled: true,
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
request,
});
expect(config.configPatch?.apps).toMatchObject({
"google-calendar-app": {
enabled: true,
},
});
expect(request.mock.calls.map(([method]) => method)).not.toContain("plugin/install");
expect(appListParams).toEqual([
{
cursor: undefined,
limit: 100,
forceRefetch: true,
},
]);
});
it("does not expose plugin apps missing from the app inventory snapshot", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
@@ -375,11 +486,59 @@ describe("Codex plugin thread config", () => {
allowDestructiveActions: true,
destructiveApprovalMode: "allow",
},
message: "google-calendar-app is not accessible or enabled for google-calendar.",
message: "google-calendar-app is not accessible for google-calendar.",
},
]);
});
it("does not expose apps for plugins that OpenClaw policy leaves disabled", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () => ({
data: [appInfo("google-calendar-app", true)],
nextCursor: null,
}),
});
const config = await buildCodexPluginThreadConfig({
pluginConfig: {
codexPlugins: {
enabled: true,
plugins: {
"google-calendar": {
enabled: false,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
nowMs: 1,
request: async (method) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
throw new Error(`unexpected request ${method}`);
},
});
expect(config.configPatch).toEqual({
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
},
});
expect(config.policyContext.apps).toStrictEqual({});
expect(config.diagnostics).toStrictEqual([]);
});
it("force-refreshes app inventory when proven plugin apps are not ready", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
@@ -572,9 +731,7 @@ describe("Codex plugin thread config", () => {
let installed = false;
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "plugin/list") {
return pluginList([
pluginSummary("google-calendar", { installed, enabled: installed }),
]);
return pluginList([pluginSummary("google-calendar", { installed, enabled: installed })]);
}
if (method === "plugin/read") {
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
@@ -738,6 +895,70 @@ describe("Codex plugin thread config", () => {
]);
});
it("fails closed when app inventory entries are malformed", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () =>
({
data: [{ ...appInfo("google-calendar-app", true), id: "" }] as unknown as v2.AppInfo[],
nextCursor: null,
}) satisfies v2.AppsListResponse,
});
const config = await buildCodexPluginThreadConfig({
pluginConfig: {
codexPlugins: {
enabled: true,
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
nowMs: 1,
request: async (method) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
}
throw new Error(`unexpected request ${method}`);
},
});
expect(config.configPatch).toEqual({
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
},
});
expect(config.policyContext.apps).toStrictEqual({});
expect(config.diagnostics).toStrictEqual([
{
code: "app_not_ready",
plugin: {
configKey: "google-calendar",
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "allow",
},
message: "google-calendar-app is not accessible for google-calendar.",
},
]);
});
it("uses durable policy and app cache key in the cheap input fingerprint", async () => {
const appCache = new CodexAppInventoryCache();
const first = buildCodexPluginThreadConfigInputFingerprint({

View File

@@ -125,6 +125,9 @@ export async function buildCodexPluginThreadConfig(
nowMs: params.nowMs,
suppressAppInventoryRefresh: true,
});
const appInventoryRefreshDeferredForActivation =
inventory.records.some((record) => record.activationRequired) &&
shouldRefreshMissingAppInventory(params, policy, inventory);
if (shouldWaitForInitialAppInventory(params, policy, inventory)) {
await refreshAppInventoryNow(params, appCache, {
forceRefetch: true,
@@ -166,10 +169,19 @@ export async function buildCodexPluginThreadConfig(
});
}
}
if (activationResults.some((activation) => activation.ok && activation.installAttempted)) {
const postInstallRefreshRequired = activationResults.some(
(activation) => activation.ok && activation.installAttempted,
);
// Activation can become unnecessary or fail before it refreshes apps. Rebuild the
// deferred missing snapshot so unrelated active plugin apps are not silently erased.
const deferredMissingRefreshRequired =
appInventoryRefreshDeferredForActivation &&
!postInstallRefreshRequired &&
shouldRefreshMissingAppInventory(params, policy, inventory);
if (postInstallRefreshRequired || deferredMissingRefreshRequired) {
await refreshAppInventoryNow(params, appCache, {
forceRefetch: true,
reason: "post_install",
reason: postInstallRefreshRequired ? "post_install" : "deferred_missing",
targetAppIds: collectInventoryOwnedAppIds(inventory),
});
inventory = await readCodexPluginInventory({
@@ -219,24 +231,22 @@ export async function buildCodexPluginThreadConfig(
const policyApps: Record<string, PluginAppPolicyContextEntry> = {};
const pluginAppIds: Record<string, string[]> = {};
for (const record of inventory.records) {
if (record.activationRequired) {
const activation = activationResults.find(
(item) => item.identity.configKey === record.policy.configKey,
);
if (!activation?.ok) {
continue;
}
const activation = activationResults.find(
(item) => item.identity.configKey === record.policy.configKey,
);
if (activation?.ok === false || (record.activationRequired && !activation?.ok)) {
continue;
}
if (record.appOwnership !== "proven") {
continue;
}
pluginAppIds[record.policy.configKey] = [...record.ownedAppIds].toSorted();
for (const app of resolveThreadConfigAppsForRecord({ record, inventory })) {
if (!app.accessible || !app.enabled) {
if (!isPluginAppReadyForThreadStart(app)) {
diagnostics.push({
code: "app_not_ready",
plugin: record.policy,
message: `${app.id} is not accessible or enabled for ${record.policy.pluginName}.`,
message: `${app.id} is not accessible for ${record.policy.pluginName}.`,
});
continue;
}
@@ -362,9 +372,18 @@ function shouldWaitForInitialAppInventory(
policy: ResolvedCodexPluginsPolicy,
inventory: CodexPluginInventory,
): boolean {
// Install/enable first so the initial app/list can observe newly activated plugin apps.
if (inventory.records.some((record) => record.activationRequired)) {
return false;
}
return shouldRefreshMissingAppInventory(params, policy, inventory);
}
function shouldRefreshMissingAppInventory(
params: BuildCodexPluginThreadConfigParams,
policy: ResolvedCodexPluginsPolicy,
inventory: CodexPluginInventory,
): boolean {
return Boolean(
params.appCacheKey &&
policy.pluginPolicies.some((plugin) => plugin.enabled) &&
@@ -419,6 +438,13 @@ function resolveThreadConfigAppsForRecord(params: {
return params.record.apps;
}
function isPluginAppReadyForThreadStart(app: CodexPluginOwnedApp): boolean {
// `app/list` is the source of truth for inventory and access posture, but
// OpenClaw owns the per-thread enablement decision. A listed app that is
// accessible can be re-enabled for this thread via `config.apps[app.id]`.
return app.accessible;
}
function shouldForceRefreshForNotReadyPluginApps(
params: BuildCodexPluginThreadConfigParams,
policy: ResolvedCodexPluginsPolicy,
@@ -434,7 +460,7 @@ function shouldForceRefreshForNotReadyPluginApps(
(record) =>
record.appOwnership === "proven" &&
record.ownedAppIds.length > 0 &&
(record.apps.length === 0 || record.apps.some((app) => !app.accessible || !app.enabled)),
(record.apps.length === 0 || record.apps.some((app) => !app.accessible)),
);
}

View File

@@ -4416,6 +4416,131 @@ describe("runCodexAppServerAttempt", () => {
expect(requests.map((entry) => entry.method)).not.toContain("app/list");
});
it("sends a thread/start app enable override when app/list cached the app as disabled", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const agentDir = path.join(tempDir, "agent");
const pluginConfig = {
codexPlugins: {
enabled: true,
plugins: {
"google-calendar": {
marketplaceName: "openai-curated",
pluginName: "google-calendar",
},
},
},
};
const appServer = resolveCodexAppServerRuntimeOptions({
pluginConfig: readCodexPluginConfig(pluginConfig),
});
defaultCodexAppInventoryCache.clear();
await defaultCodexAppInventoryCache.refreshNow({
key: buildCodexPluginAppCacheKey({
appServer,
agentDir,
runtimeIdentity: getMockRuntimeIdentity(),
}),
request: async () => ({
data: [
{
id: "google-calendar-app",
name: "Google Calendar",
description: null,
logoUrl: null,
logoUrlDark: null,
distributionChannel: null,
branding: null,
appMetadata: null,
labels: null,
installUrl: null,
isAccessible: true,
isEnabled: false,
pluginDisplayNames: [],
},
],
nextCursor: null,
}),
});
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness(async (method) => {
if (method === "plugin/list") {
return {
marketplaces: [
{
name: "openai-curated",
path: "/marketplaces/openai-curated",
interface: null,
plugins: [
{
id: "google-calendar",
name: "google-calendar",
source: { type: "remote" },
installed: true,
enabled: true,
installPolicy: "AVAILABLE",
authPolicy: "ON_USE",
availability: "AVAILABLE",
interface: null,
},
],
},
],
marketplaceLoadErrors: [],
featuredPluginIds: [],
};
}
if (method === "plugin/read") {
return {
plugin: {
marketplaceName: "openai-curated",
marketplacePath: "/marketplaces/openai-curated",
summary: {
id: "google-calendar",
name: "google-calendar",
source: { type: "remote" },
installed: true,
enabled: true,
installPolicy: "AVAILABLE",
authPolicy: "ON_USE",
availability: "AVAILABLE",
interface: null,
},
description: null,
skills: [],
apps: [
{
id: "google-calendar-app",
name: "Google Calendar",
description: null,
installUrl: null,
needsAuth: false,
},
],
mcpServers: ["google-calendar"],
},
};
}
if (method === "app/list") {
throw new Error("app/list should use the cached inventory entry");
}
return undefined;
});
const params = createParams(sessionFile, workspaceDir);
params.agentDir = agentDir;
const run = runCodexAppServerAttempt(params, { pluginConfig });
await waitForMethod("turn/start");
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
const threadStart = requests.find((entry) => entry.method === "thread/start");
const threadStartParams = threadStart?.params as
| { config?: { apps?: Record<string, { enabled?: boolean }> } }
| undefined;
expect(threadStartParams?.config?.apps?.["google-calendar-app"]?.enabled).toBe(true);
expect(requests.map((entry) => entry.method)).not.toContain("app/list");
});
it("keys plugin app inventory by inherited API key fallback credentials", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");

View File

@@ -855,7 +855,7 @@ describe("google-meet plugin", () => {
});
it("registers the node-host command used by chrome-node transport", () => {
const { nodeHostCommands } = setup();
const { nodeHostCommands, nodeInvokePolicies } = setup();
const command = nodeHostCommands.find(
(entry): entry is Record<string, unknown> =>
@@ -865,7 +865,13 @@ describe("google-meet plugin", () => {
throw new Error("expected googlemeet.chrome node host command");
}
expect(command.cap).toBe("google-meet");
expect(command.dangerous).toBe(true);
expect(typeof command.handle).toBe("function");
expect(nodeInvokePolicies).toHaveLength(1);
expect(nodeInvokePolicies[0]).toMatchObject({
commands: ["googlemeet.chrome"],
dangerous: true,
});
});
it("keeps the agent tool visible on non-macOS hosts but blocks local Chrome talk-back joins", async () => {
@@ -2239,6 +2245,9 @@ describe("google-meet plugin", () => {
try {
const { methods, runCommandWithTimeout } = setup({
defaultMode: "transcribe",
chrome: {
browserProfile: "meet-devtools",
},
});
const callGatewayFromCli = mockLocalMeetBrowserRequest({
inCall: true,
@@ -3428,7 +3437,12 @@ describe("google-meet plugin", () => {
},
);
chromeTransportTesting.setDepsForTest({ callGatewayFromCli });
const { tools, nodesInvoke } = setup({ defaultTransport: "chrome" });
const { tools, nodesInvoke } = setup({
defaultTransport: "chrome",
chrome: {
browserProfile: "meet-devtools",
},
});
const tool = tools[0] as {
execute: (
id: string,
@@ -3458,6 +3472,7 @@ describe("google-meet plugin", () => {
expect(focusCall[0]).toBe("browser.request");
expect(requireRecord(focusCall[2], "focus request").method).toBe("POST");
expect(requireRecord(focusCall[2], "focus request").path).toBe("/tabs/focus");
expect(requireRecord(focusCall[2], "focus request").query).toBeUndefined();
expect(focusCall[3]).toEqual({ progress: false });
expect(nodesInvoke).not.toHaveBeenCalled();
});

View File

@@ -35,6 +35,10 @@ import {
fetchGoogleMeetSpace,
} from "./src/meet.js";
import { handleGoogleMeetNodeHostCommand } from "./src/node-host.js";
import {
createGoogleMeetChromeNodeInvokePolicy,
GOOGLE_MEET_CHROME_NODE_COMMAND,
} from "./src/node-invoke-policy.js";
import { GoogleMeetRuntime } from "./src/runtime.js";
import { isGoogleMeetBrowserManualActionError } from "./src/transports/chrome-create.js";
@@ -1196,10 +1200,12 @@ export default definePluginEntry({
);
api.registerNodeHostCommand({
command: "googlemeet.chrome",
command: GOOGLE_MEET_CHROME_NODE_COMMAND,
cap: "google-meet",
dangerous: true,
handle: handleGoogleMeetNodeHostCommand,
});
api.registerNodeInvokePolicy(createGoogleMeetChromeNodeInvokePolicy(config));
api.registerCli(
async ({ program }) => {

View File

@@ -91,6 +91,41 @@ describe("google-meet node host bridge sessions", () => {
}
});
it("passes the Meet URL before Chrome profile args when launching a profiled browser", async () => {
const originalPlatform = process.platform;
children.length = 0;
vi.mocked(spawnSync).mockClear();
Object.defineProperty(process, "platform", { configurable: true, value: "darwin" });
try {
const start = JSON.parse(
await handleGoogleMeetNodeHostCommand(
JSON.stringify({
action: "start",
url: "https://meet.google.com/xyz-abcd-uvw",
mode: "transcribe",
browserProfile: "Profile 2",
}),
),
);
expect(start.launched).toBe(true);
expect(spawnSync).toHaveBeenCalledWith(
"open",
[
"-a",
"Google Chrome",
"https://meet.google.com/xyz-abcd-uvw",
"--args",
"--profile-directory=Profile 2",
],
expect.objectContaining({ encoding: "utf8" }),
);
} finally {
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
}
});
it("clears output playback without closing the active bridge when the old output exits", async () => {
const originalPlatform = process.platform;
children.length = 0;

View File

@@ -332,12 +332,11 @@ function startChrome(params: Record<string, unknown>) {
}
if (params.launch !== false) {
const argv = ["open", "-a", "Google Chrome"];
const argv = ["open", "-a", "Google Chrome", url];
const browserProfile = readString(params.browserProfile);
if (browserProfile) {
argv.push("--args", `--profile-directory=${browserProfile}`);
}
argv.push(url);
const result = runCommandWithTimeout(argv, timeoutMs);
if (result.code !== 0) {
if (bridgeId) {

View File

@@ -0,0 +1,134 @@
// Google Meet node.invoke policy tests cover caller-controlled command sanitization.
import type { OpenClawPluginNodeInvokePolicyContext } from "openclaw/plugin-sdk/plugin-entry";
import { describe, expect, it, vi } from "vitest";
import { resolveGoogleMeetConfig } from "./config.js";
import {
createGoogleMeetChromeNodeInvokePolicy,
GOOGLE_MEET_CHROME_NODE_COMMAND,
} from "./node-invoke-policy.js";
function createContext(params: unknown, pluginConfig: Record<string, unknown> = {}) {
const invokeNode = vi.fn<OpenClawPluginNodeInvokePolicyContext["invokeNode"]>(async () => ({
ok: true,
payload: { ok: true },
}));
const ctx: OpenClawPluginNodeInvokePolicyContext = {
nodeId: "node-1",
command: GOOGLE_MEET_CHROME_NODE_COMMAND,
params,
config: {} as never,
pluginConfig,
invokeNode,
};
return { ctx, invokeNode };
}
describe("Google Meet node invoke policy", () => {
it("rewrites start executable fields from trusted config", async () => {
const policy = createGoogleMeetChromeNodeInvokePolicy(
resolveGoogleMeetConfig({
chrome: {
launch: false,
browserProfile: "Trusted Profile",
joinTimeoutMs: 45_000,
audioInputCommand: ["trusted-capture", "--raw"],
audioOutputCommand: ["trusted-play", "--raw"],
},
}),
);
const { ctx, invokeNode } = createContext({
action: "start",
url: "https://meet.google.com/abc-defg-hij",
mode: "bidi",
launch: true,
browserProfile: "Attacker Profile",
joinTimeoutMs: 1,
audioBridgeCommand: ["node", "-e", "process.exit(99)"],
audioBridgeHealthCommand: ["node", "-e", "process.exit(98)"],
audioInputCommand: ["malicious-capture"],
audioOutputCommand: ["malicious-play"],
});
await expect(policy.handle(ctx)).resolves.toEqual({ ok: true, payload: { ok: true } });
expect(invokeNode).toHaveBeenCalledTimes(1);
expect(invokeNode).toHaveBeenCalledWith({
params: {
action: "start",
url: "https://meet.google.com/abc-defg-hij",
mode: "bidi",
launch: false,
browserProfile: "Trusted Profile",
joinTimeoutMs: 45_000,
audioInputCommand: ["trusted-capture", "--raw"],
audioOutputCommand: ["trusted-play", "--raw"],
},
});
});
it("uses trusted configured external bridge commands for start", async () => {
const policy = createGoogleMeetChromeNodeInvokePolicy(
resolveGoogleMeetConfig({
chrome: {
audioBridgeHealthCommand: ["trusted-bridge", "status"],
audioBridgeCommand: ["trusted-bridge", "start"],
},
}),
);
const { ctx, invokeNode } = createContext({
action: "start",
url: "https://meet.google.com/abc-defg-hij",
mode: "bidi",
audioBridgeHealthCommand: ["node", "-e", "process.exit(98)"],
audioBridgeCommand: ["node", "-e", "process.exit(99)"],
});
await policy.handle(ctx);
const call = invokeNode.mock.calls[0]?.[0];
expect(call?.params).toMatchObject({
action: "start",
audioBridgeHealthCommand: ["trusted-bridge", "status"],
audioBridgeCommand: ["trusted-bridge", "start"],
});
});
it("rejects direct start for non-Meet URLs before node dispatch", async () => {
const policy = createGoogleMeetChromeNodeInvokePolicy(resolveGoogleMeetConfig({}));
const { ctx, invokeNode } = createContext({
action: "start",
url: "https://example.com/private",
mode: "bidi",
});
await expect(policy.handle(ctx)).resolves.toMatchObject({
ok: false,
code: "GOOGLE_MEET_NODE_POLICY_DENIED",
message: "url must be an explicit https://meet.google.com/... URL",
});
expect(invokeNode).not.toHaveBeenCalled();
});
it("keeps direct setup diagnostics but strips extra fields", async () => {
const policy = createGoogleMeetChromeNodeInvokePolicy(resolveGoogleMeetConfig({}));
const { ctx, invokeNode } = createContext({
action: "setup",
audioBridgeCommand: ["node", "-e", "process.exit(99)"],
});
await policy.handle(ctx);
expect(invokeNode).toHaveBeenCalledWith({ params: { action: "setup" } });
});
it("rejects unsupported googlemeet.chrome actions before node dispatch", async () => {
const policy = createGoogleMeetChromeNodeInvokePolicy(resolveGoogleMeetConfig({}));
const { ctx, invokeNode } = createContext({ action: "exec", command: ["id"] });
await expect(policy.handle(ctx)).resolves.toMatchObject({
ok: false,
code: "GOOGLE_MEET_NODE_POLICY_DENIED",
});
expect(invokeNode).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,192 @@
import type {
OpenClawPluginNodeInvokePolicy,
OpenClawPluginNodeInvokePolicyContext,
OpenClawPluginNodeInvokePolicyResult,
} from "openclaw/plugin-sdk/plugin-entry";
import type { GoogleMeetConfig } from "./config.js";
import { normalizeMeetUrl } from "./runtime.js";
export const GOOGLE_MEET_CHROME_NODE_COMMAND = "googlemeet.chrome";
const START_MODES = new Set(["agent", "bidi", "realtime", "transcribe"]);
type PolicyDecision =
| { approved: true; params: Record<string, unknown> }
| { approved: false; result: OpenClawPluginNodeInvokePolicyResult };
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function readPositiveNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
}
function copyCommand(command: string[] | undefined): string[] | undefined {
return command && command.length > 0 ? [...command] : undefined;
}
function denied(message: string, code = "GOOGLE_MEET_NODE_POLICY_DENIED") {
return { ok: false as const, code, message };
}
function approved(params: Record<string, unknown>): PolicyDecision {
return { approved: true, params };
}
function buildStartParams(
params: Record<string, unknown>,
config: GoogleMeetConfig,
): PolicyDecision {
let url: string;
try {
url = normalizeMeetUrl(params.url);
} catch (error) {
return {
approved: false,
result: denied(
error instanceof Error ? error.message : "googlemeet.chrome start requires url",
),
};
}
const mode = readString(params.mode);
if (mode && !START_MODES.has(mode)) {
return {
approved: false,
result: denied(`googlemeet.chrome start mode is unsupported: ${mode}`),
};
}
const startParams: Record<string, unknown> = {
action: "start",
url,
launch: params.launch === false ? false : config.chrome.launch,
browserProfile: config.chrome.browserProfile,
joinTimeoutMs: config.chrome.joinTimeoutMs,
};
if (mode) {
startParams.mode = mode;
}
const audioInputCommand = copyCommand(config.chrome.audioInputCommand);
if (audioInputCommand) {
startParams.audioInputCommand = audioInputCommand;
}
const audioOutputCommand = copyCommand(config.chrome.audioOutputCommand);
if (audioOutputCommand) {
startParams.audioOutputCommand = audioOutputCommand;
}
const audioBridgeCommand = copyCommand(config.chrome.audioBridgeCommand);
if (audioBridgeCommand) {
startParams.audioBridgeCommand = audioBridgeCommand;
}
const audioBridgeHealthCommand = copyCommand(config.chrome.audioBridgeHealthCommand);
if (audioBridgeHealthCommand) {
startParams.audioBridgeHealthCommand = audioBridgeHealthCommand;
}
return approved(startParams);
}
function buildForwardParams(params: Record<string, unknown>): Record<string, unknown> | null {
const action = readString(params.action);
switch (action) {
case "setup":
return { action };
case "status": {
const bridgeId = readString(params.bridgeId);
return bridgeId ? { action, bridgeId } : { action };
}
case "list": {
const forwarded: Record<string, unknown> = { action };
const url = readString(params.url);
const mode = readString(params.mode);
if (url) {
forwarded.url = url;
}
if (mode) {
forwarded.mode = mode;
}
return forwarded;
}
case "stopByUrl": {
const forwarded: Record<string, unknown> = { action };
const url = readString(params.url);
const mode = readString(params.mode);
const exceptBridgeId = readString(params.exceptBridgeId);
if (url) {
forwarded.url = url;
}
if (mode) {
forwarded.mode = mode;
}
if (exceptBridgeId) {
forwarded.exceptBridgeId = exceptBridgeId;
}
return forwarded;
}
case "pullAudio": {
const forwarded: Record<string, unknown> = { action };
const bridgeId = readString(params.bridgeId);
const timeoutMs = readPositiveNumber(params.timeoutMs);
if (bridgeId) {
forwarded.bridgeId = bridgeId;
}
if (timeoutMs) {
forwarded.timeoutMs = timeoutMs;
}
return forwarded;
}
case "pushAudio": {
const forwarded: Record<string, unknown> = { action };
const bridgeId = readString(params.bridgeId);
const base64 = readString(params.base64);
if (bridgeId) {
forwarded.bridgeId = bridgeId;
}
if (base64) {
forwarded.base64 = base64;
}
return forwarded;
}
case "clearAudio":
case "stop": {
const bridgeId = readString(params.bridgeId);
return bridgeId ? { action, bridgeId } : { action };
}
default:
return null;
}
}
export function createGoogleMeetChromeNodeInvokePolicy(
config: GoogleMeetConfig,
): OpenClawPluginNodeInvokePolicy {
return {
commands: [GOOGLE_MEET_CHROME_NODE_COMMAND],
dangerous: true,
async handle(ctx: OpenClawPluginNodeInvokePolicyContext) {
if (ctx.command !== GOOGLE_MEET_CHROME_NODE_COMMAND) {
return denied(`unsupported Google Meet node command: ${ctx.command}`);
}
const params = asRecord(ctx.params);
const action = readString(params.action);
let decision: PolicyDecision;
if (action === "start") {
decision = buildStartParams(params, config);
} else {
const forwardParams = buildForwardParams(params);
decision = forwardParams
? approved(forwardParams)
: { approved: false, result: denied("unsupported googlemeet.chrome action") };
}
if (!decision.approved) {
return decision.result;
}
return await ctx.invokeNode({ params: decision.params });
},
};
}

View File

@@ -69,6 +69,7 @@ export function setupGoogleMeetPlugin(
const tools: unknown[] = [];
const cliRegistrations: unknown[] = [];
const nodeHostCommands: unknown[] = [];
const nodeInvokePolicies: unknown[] = [];
const nodesList = vi.fn(
async () =>
options.nodesListResult ?? {
@@ -165,6 +166,7 @@ export function setupGoogleMeetPlugin(
},
registerCli: (_registrar: unknown, opts: unknown) => cliRegistrations.push(opts),
registerNodeHostCommand: (command: unknown) => nodeHostCommands.push(command),
registerNodeInvokePolicy: (policy: unknown) => nodeInvokePolicies.push(policy),
});
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", {
@@ -184,6 +186,7 @@ export function setupGoogleMeetPlugin(
nodesList,
nodesInvoke,
nodeHostCommands,
nodeInvokePolicies,
};
}

View File

@@ -197,6 +197,7 @@ export const signalApprovalNativeRuntime = createChannelApprovalNativeRuntimeAda
conversationKey: entry.conversationKey,
messageId: entry.messageId,
approvalId: request.id,
approvalKind: view.approvalKind,
allowedDecisions: pendingPayload.reactionPayload.allowedDecisions,
targetAuthorKeys: entry.targetAuthorKeys,
route: {

View File

@@ -1,12 +1,16 @@
import {
buildExecApprovalPendingReplyPayload,
buildPluginApprovalPendingReplyPayload,
} from "openclaw/plugin-sdk/approval-reply-runtime";
// Signal tests cover approval reactions plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
addSignalApprovalReactionHintToText,
appendSignalApprovalReactionHintForOutboundMessage,
addSignalApprovalReactionHintToStructuredPayload,
buildSignalApprovalReactionHint,
clearSignalApprovalReactionTargetsForTest,
maybeResolveSignalApprovalReaction,
registerSignalApprovalReactionTargetForOutboundMessage,
registerSignalApprovalReactionTargetForDeliveredPayload,
registerSignalApprovalReactionTarget,
resolveSignalApprovalReactionTargetWithPersistence,
} from "./approval-reactions.js";
@@ -78,7 +82,220 @@ describe("Signal approval reactions", () => {
).toBe(prompt);
});
it("registers target-mode outbound approval prompts for reactions", async () => {
it("registers delivered structured approval payloads for reactions", async () => {
const cfg = {
channels: {
signal: {
allowFrom: ["+15551230000"],
},
},
approvals: {
exec: {
enabled: true,
mode: "targets" as const,
targets: [{ channel: "signal", to: "+15551230000" }],
},
},
};
const payload = buildExecApprovalPendingReplyPayload({
approvalId: "exec-structured-approval",
approvalSlug: "exec-str",
allowedDecisions: ["allow-once", "deny"],
command: "printf test",
host: "gateway",
agentId: "main",
sessionKey: "agent:main:signal:direct:+15551230000",
});
const deliveredPayload = addSignalApprovalReactionHintToStructuredPayload({
cfg,
accountId: "default",
to: "+15551230000",
payload,
targetAuthor: "+15550009999",
});
expect(
registerSignalApprovalReactionTargetForDeliveredPayload({
cfg,
target: {
channel: "signal",
to: "+15551230000",
accountId: "default",
},
payload: deliveredPayload!,
results: [
{
channel: "signal",
messageId: "1700000000012",
toJid: "+15551230000",
},
],
targetAuthor: "+15550009999",
}),
).toBe(true);
await expect(
resolveSignalApprovalReactionTargetWithPersistence({
accountId: "default",
conversationKey: "+15551230000",
messageId: "1700000000012",
reactionKey: "👍",
targetAuthor: "+15550009999",
}),
).resolves.toEqual({
approvalId: "exec-structured-approval",
approvalKind: "exec",
decision: "allow-once",
route: {
deliveryMode: "target",
to: "+15551230000",
accountId: "default",
agentId: "main",
sessionKey: "agent:main:signal:direct:+15551230000",
},
});
});
it("does not register metadata-only approval payloads without visible reaction hints", async () => {
const cfg = {
channels: {
signal: {
allowFrom: ["+15551230000"],
},
},
approvals: {
exec: {
enabled: true,
mode: "targets" as const,
targets: [{ channel: "signal", to: "+15551230000" }],
},
},
};
const payload = buildExecApprovalPendingReplyPayload({
approvalId: "exec-hidden-reaction",
approvalSlug: "exec-hid",
allowedDecisions: ["allow-once", "deny"],
command: "printf hidden",
host: "gateway",
agentId: "main",
sessionKey: "agent:main:signal:direct:+15551230000",
});
expect(
registerSignalApprovalReactionTargetForDeliveredPayload({
cfg,
target: {
channel: "signal",
to: "+15551230000",
accountId: "default",
},
payload,
results: [
{
channel: "signal",
messageId: "1700000000015",
},
],
targetAuthor: "+15550009999",
}),
).toBe(false);
await expect(
resolveSignalApprovalReactionTargetWithPersistence({
accountId: "default",
conversationKey: "+15551230000",
messageId: "1700000000015",
reactionKey: "👍",
targetAuthor: "+15550009999",
}),
).resolves.toBeNull();
});
it("registers only delivered chunks that contain visible reaction hints", async () => {
const cfg = {
channels: {
signal: {
allowFrom: ["+15551230000"],
},
},
approvals: {
exec: {
enabled: true,
mode: "targets" as const,
targets: [{ channel: "signal", to: "+15551230000" }],
},
},
};
const payload = buildExecApprovalPendingReplyPayload({
approvalId: "exec-chunked-reaction",
approvalSlug: "exec-ch",
allowedDecisions: ["allow-once", "deny"],
command: "printf chunked",
host: "gateway",
agentId: "main",
sessionKey: "agent:main:signal:direct:+15551230000",
});
const deliveredPayload = addSignalApprovalReactionHintToStructuredPayload({
cfg,
accountId: "default",
to: "+15551230000",
payload,
targetAuthor: "+15550009999",
});
expect(
registerSignalApprovalReactionTargetForDeliveredPayload({
cfg,
target: {
channel: "signal",
to: "+15551230000",
accountId: "default",
},
payload: deliveredPayload!,
results: [
{
channel: "signal",
messageId: "1700000000016",
meta: {
signalVisibleText: "Exec approval required\n\nReact with:\n\n👍 Allow Once\n👎 Deny",
},
},
{
channel: "signal",
messageId: "1700000000017",
meta: {
signalVisibleText: "Continuation chunk without controls",
},
},
],
targetAuthor: "+15550009999",
}),
).toBe(true);
await expect(
resolveSignalApprovalReactionTargetWithPersistence({
accountId: "default",
conversationKey: "+15551230000",
messageId: "1700000000016",
reactionKey: "👍",
targetAuthor: "+15550009999",
}),
).resolves.toMatchObject({
approvalId: "exec-chunked-reaction",
decision: "allow-once",
});
await expect(
resolveSignalApprovalReactionTargetWithPersistence({
accountId: "default",
conversationKey: "+15551230000",
messageId: "1700000000017",
reactionKey: "👍",
targetAuthor: "+15550009999",
}),
).resolves.toBeNull();
});
it("registers delivered structured plugin approval payloads using metadata kind", async () => {
const cfg = {
channels: {
signal: {
@@ -93,70 +310,106 @@ describe("Signal approval reactions", () => {
},
},
};
const text =
"Plugin approval required\nID: plugin:abc\n\nReply with: /approve plugin:abc allow-once|deny";
const textWithHint = appendSignalApprovalReactionHintForOutboundMessage({
const payload = buildPluginApprovalPendingReplyPayload({
request: {
id: "plugin-structured-approval",
request: {
title: "Sensitive plugin action",
description: "Needs approval",
allowedDecisions: ["allow-once", "deny"],
},
createdAtMs: 1_000,
expiresAtMs: 61_000,
},
nowMs: 1_000,
});
const deliveredPayload = addSignalApprovalReactionHintToStructuredPayload({
cfg,
accountId: "default",
to: "+15551230000",
text,
payload,
targetAuthor: "+15550009999",
});
expect(textWithHint).toContain("React with:\n\n👍 Allow Once\n👎 Deny");
expect(
registerSignalApprovalReactionTargetForOutboundMessage({
registerSignalApprovalReactionTargetForDeliveredPayload({
cfg,
accountId: "default",
to: "+15551230000",
messageId: "1700000000009",
text: textWithHint,
target: {
channel: "signal",
to: "+15551230000",
accountId: "default",
},
payload: deliveredPayload!,
results: [
{
channel: "signal",
messageId: "1700000000013",
},
],
targetAuthor: "+15550009999",
}),
).toBe(true);
const handled = await maybeResolveSignalApprovalReaction({
cfg,
accountId: "default",
conversationKey: "+15551230000",
messageId: "1700000000009",
reactionKey: "👍",
actorId: "+15551230000",
targetAuthor: "+15550009999",
});
expect(handled).toBe(true);
expect(resolverMocks.resolveSignalApproval).toHaveBeenCalledWith({
cfg,
approvalId: "plugin:abc",
await expect(
resolveSignalApprovalReactionTargetWithPersistence({
accountId: "default",
conversationKey: "+15551230000",
messageId: "1700000000013",
reactionKey: "👍",
targetAuthor: "+15550009999",
}),
).resolves.toMatchObject({
approvalId: "plugin-structured-approval",
approvalKind: "plugin",
decision: "allow-once",
senderId: "+15551230000",
gatewayUrl: undefined,
});
});
it("keeps target-mode outbound prompts manual when the target route is disabled", () => {
const text =
"Plugin approval required\nID: plugin:abc\n\nReply with: /approve plugin:abc allow-once|deny";
it("does not register delivered structured approval payloads without explicit approvers", () => {
const payload = buildExecApprovalPendingReplyPayload({
approvalId: "exec-no-approvers",
approvalSlug: "exec-no",
allowedDecisions: ["allow-once", "deny"],
command: "printf test",
host: "gateway",
});
const deliveredPayload = {
...payload,
text: addSignalApprovalReactionHintToText({
text: payload.text ?? "",
allowedDecisions: ["allow-once", "deny"],
}),
};
expect(
appendSignalApprovalReactionHintForOutboundMessage({
registerSignalApprovalReactionTargetForDeliveredPayload({
cfg: {
channels: { signal: { allowFrom: ["+15551230000"] } },
channels: {
signal: {},
},
approvals: {
plugin: {
enabled: false,
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "signal", to: "+15551230000" }],
},
},
},
accountId: "default",
to: "+15551230000",
text,
target: {
channel: "signal",
to: "+15551230000",
accountId: "default",
},
payload: deliveredPayload,
results: [
{
channel: "signal",
messageId: "1700000000014",
},
],
targetAuthor: "+15550009999",
}),
).toBe(text);
).toBe(false);
});
it("registers reaction state when only allow-always is available", async () => {

View File

@@ -8,8 +8,12 @@ import {
type ApprovalReactionDecisionBinding,
type ApprovalReactionTargetRecord,
} from "openclaw/plugin-sdk/approval-reaction-runtime";
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-reply-runtime";
import {
getExecApprovalReplyMetadata,
type ExecApprovalReplyDecision,
} from "openclaw/plugin-sdk/approval-reply-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import {
normalizeLowercaseStringOrEmpty,
@@ -21,7 +25,7 @@ import { looksLikeUuid } from "./identity.js";
import { normalizeSignalMessagingTarget } from "./normalize.js";
import { getOptionalSignalRuntime } from "./runtime.js";
const PERSISTENT_NAMESPACE = "signal.approval-reactions";
const PERSISTENT_NAMESPACE = "signal.approval-reactions.v2";
const PERSISTENT_MAX_ENTRIES = 1000;
const DEFAULT_REACTION_TARGET_TTL_MS = 24 * 60 * 60 * 1000;
@@ -58,6 +62,19 @@ type SignalApprovalReactionTarget = ApprovalReactionTargetRecord<SignalApprovalR
route: SignalApprovalReactionRoute;
};
type SignalApprovalDeliveryTarget = {
channel: string;
to: string;
accountId?: string | null;
};
type SignalApprovalDeliveryResult = {
channel?: string;
messageId?: string | null;
toJid?: string;
meta?: Record<string, unknown>;
};
let resolverRuntimePromise: Promise<typeof import("./approval-resolver.js")> | undefined;
const signalApprovalReactionTargets =
@@ -320,7 +337,7 @@ export function addSignalApprovalReactionHintToText(params: {
text: string;
allowedDecisions: readonly ExecApprovalReplyDecision[];
}): string {
if (/(^|\n)React with:\s*(\n|$)/i.test(params.text)) {
if (hasSignalApprovalReactionHintText(params.text)) {
return params.text;
}
const hint = buildSignalApprovalReactionHint(params.allowedDecisions);
@@ -329,40 +346,8 @@ export function addSignalApprovalReactionHintToText(params: {
: params.text;
}
function normalizeApprovalDecision(value: string): ExecApprovalReplyDecision | null {
const normalized = value.trim().toLowerCase();
if (normalized === "always") {
return "allow-always";
}
if (normalized === "allow-once" || normalized === "allow-always" || normalized === "deny") {
return normalized;
}
return null;
}
export function extractSignalApprovalPromptBinding(text: string): {
approvalId: string;
allowedDecisions: ExecApprovalReplyDecision[];
} | null {
const allowedDecisions: ExecApprovalReplyDecision[] = [];
let approvalId = "";
for (const line of text.split(/\r?\n/)) {
const match = line.match(/\/approve(?:@[^\s]+)?\s+([A-Za-z0-9][A-Za-z0-9._:-]*)\s+(.+)$/i);
if (!match) {
continue;
}
if (approvalId && match[1] !== approvalId) {
continue;
}
approvalId ||= match[1];
for (const decisionText of match[2].split(/[\s|,]+/)) {
const decision = normalizeApprovalDecision(decisionText);
if (decision && !allowedDecisions.includes(decision)) {
allowedDecisions.push(decision);
}
}
}
return approvalId && allowedDecisions.length > 0 ? { approvalId, allowedDecisions } : null;
function hasSignalApprovalReactionHintText(text?: string | null): boolean {
return /(^|\n)React with:\s*(\n|$)/i.test(text ?? "");
}
function buildTargetRoute(params: {
@@ -370,6 +355,7 @@ function buildTargetRoute(params: {
accountId?: string | null;
to: string;
approvalId: string;
approvalKind?: ApprovalKind;
agentId?: string | null;
sessionKey?: string | null;
}): Extract<SignalApprovalReactionRoute, { deliveryMode: "target" }> | null {
@@ -393,7 +379,7 @@ function buildTargetRoute(params: {
return isSignalApprovalReactionRouteStillEnabled({
cfg: params.cfg,
target: {
approvalKind: resolveApprovalKindFromId(params.approvalId),
approvalKind: params.approvalKind ?? resolveApprovalKindFromId(params.approvalId),
route,
},
})
@@ -401,64 +387,6 @@ function buildTargetRoute(params: {
: null;
}
export function shouldAppendSignalApprovalReactionHintForOutboundMessage(params: {
cfg: OpenClawConfig;
accountId?: string | null;
to: string;
text: string;
targetAuthor?: string | null;
targetAuthorUuid?: string | null;
agentId?: string | null;
sessionKey?: string | null;
}): boolean {
const binding = extractSignalApprovalPromptBinding(params.text);
if (!binding) {
return false;
}
if (resolveSignalApprovalTargetAuthorKeys(params).length === 0) {
return false;
}
if (!hasSignalApprovalReactionApprovers({ cfg: params.cfg, accountId: params.accountId })) {
return false;
}
return Boolean(
buildTargetRoute({
cfg: params.cfg,
accountId: params.accountId,
to: params.to,
approvalId: binding.approvalId,
agentId: params.agentId,
sessionKey: params.sessionKey,
}),
);
}
export function appendSignalApprovalReactionHintForOutboundMessage(params: {
cfg: OpenClawConfig;
accountId?: string | null;
to: string;
text: string;
targetAuthor?: string | null;
targetAuthorUuid?: string | null;
agentId?: string | null;
sessionKey?: string | null;
}): string {
const binding = extractSignalApprovalPromptBinding(params.text);
if (
!binding ||
!shouldAppendSignalApprovalReactionHintForOutboundMessage({
...params,
text: params.text,
})
) {
return params.text;
}
return addSignalApprovalReactionHintToText({
text: params.text,
allowedDecisions: binding.allowedDecisions,
});
}
export function hasSignalApprovalReactionApprovers(params: {
cfg: OpenClawConfig;
accountId?: string | null;
@@ -471,6 +399,7 @@ export function registerSignalApprovalReactionTarget(params: {
conversationKey: string;
messageId: string;
approvalId: string;
approvalKind?: ApprovalKind;
allowedDecisions: readonly ExecApprovalReplyDecision[];
targetAuthorKeys: readonly string[];
route: SignalApprovalReactionRoute;
@@ -521,7 +450,7 @@ export function registerSignalApprovalReactionTarget(params: {
} satisfies SignalApprovalReactionRoute);
const target: SignalApprovalReactionTarget = {
approvalId,
approvalKind: resolveApprovalKindFromId(approvalId),
approvalKind: params.approvalKind ?? resolveApprovalKindFromId(approvalId),
allowedDecisions,
targetAuthorKeys,
route,
@@ -530,50 +459,142 @@ export function registerSignalApprovalReactionTarget(params: {
return target;
}
export function registerSignalApprovalReactionTargetForOutboundMessage(params: {
export function addSignalApprovalReactionHintToStructuredPayload(params: {
cfg: OpenClawConfig;
accountId: string;
accountId?: string | null;
to: string;
messageId: string;
text: string;
payload: ReplyPayload;
targetAuthor?: string | null;
targetAuthorUuid?: string | null;
agentId?: string | null;
sessionKey?: string | null;
ttlMs?: number;
}): boolean {
const binding = extractSignalApprovalPromptBinding(params.text);
if (!binding) {
return false;
}): ReplyPayload | null {
const metadata = getExecApprovalReplyMetadata(params.payload);
if (!metadata?.allowedDecisions || metadata.allowedDecisions.length === 0) {
return null;
}
const conversationKey = resolveSignalApprovalConversationKey(params.to);
if (!conversationKey) {
return false;
if (resolveSignalApprovalTargetAuthorKeys(params).length === 0) {
return null;
}
if (!hasSignalApprovalReactionApprovers({ cfg: params.cfg, accountId: params.accountId })) {
return null;
}
const route = buildTargetRoute({
cfg: params.cfg,
accountId: params.accountId,
to: params.to,
approvalId: binding.approvalId,
agentId: params.agentId,
sessionKey: params.sessionKey,
approvalId: metadata.approvalId,
approvalKind: metadata.approvalKind,
agentId: metadata.agentId,
sessionKey: metadata.sessionKey,
});
if (!route || !params.payload.text) {
return null;
}
return {
...params.payload,
text: addSignalApprovalReactionHintToText({
text: params.payload.text,
allowedDecisions: metadata.allowedDecisions,
}),
};
}
function readSignalDeliveryVisibleText(result: SignalApprovalDeliveryResult): string | null {
const meta = result.meta;
const visibleText = meta?.signalVisibleText ?? meta?.visibleText;
return typeof visibleText === "string" ? visibleText : null;
}
function listDeliveredSignalMessageIdsWithVisibleHint(params: {
payload: ReplyPayload;
results: readonly SignalApprovalDeliveryResult[];
}): string[] {
const signalResults = params.results.filter(
(result) => !result.channel || normalizeLowercaseStringOrEmpty(result.channel) === "signal",
);
const resultsWithVisibleText = signalResults.filter(
(result) => readSignalDeliveryVisibleText(result) !== null,
);
const candidates = resultsWithVisibleText.length > 0 ? resultsWithVisibleText : signalResults;
if (resultsWithVisibleText.length === 0 && candidates.length !== 1) {
return [];
}
const ids = candidates
.filter((result) =>
resultsWithVisibleText.length > 0
? hasSignalApprovalReactionHintText(readSignalDeliveryVisibleText(result))
: hasSignalApprovalReactionHintText(params.payload.text),
)
.map((result) => normalizeOptionalString(result.messageId))
.filter((messageId): messageId is string => Boolean(messageId && messageId !== "unknown"));
return Array.from(new Set(ids));
}
export function registerSignalApprovalReactionTargetForDeliveredPayload(params: {
cfg: OpenClawConfig;
target: SignalApprovalDeliveryTarget;
payload: ReplyPayload;
results: readonly SignalApprovalDeliveryResult[];
targetAuthor?: string | null;
targetAuthorUuid?: string | null;
ttlMs?: number;
}): boolean {
if (normalizeLowercaseStringOrEmpty(params.target.channel) !== "signal") {
return false;
}
const metadata = getExecApprovalReplyMetadata(params.payload);
if (!metadata?.allowedDecisions || metadata.allowedDecisions.length === 0) {
return false;
}
if (!hasSignalApprovalReactionHintText(params.payload.text)) {
return false;
}
if (
!hasSignalApprovalReactionApprovers({ cfg: params.cfg, accountId: params.target.accountId })
) {
return false;
}
const conversationKey = resolveSignalApprovalConversationKey(params.target.to);
if (!conversationKey) {
return false;
}
const route = buildTargetRoute({
cfg: params.cfg,
accountId: params.target.accountId,
to: params.target.to,
approvalId: metadata.approvalId,
approvalKind: metadata.approvalKind,
agentId: metadata.agentId,
sessionKey: metadata.sessionKey,
});
if (!route) {
return false;
}
return Boolean(
registerSignalApprovalReactionTarget({
accountId: params.accountId,
conversationKey,
messageId: params.messageId,
approvalId: binding.approvalId,
allowedDecisions: binding.allowedDecisions,
targetAuthorKeys: resolveSignalApprovalTargetAuthorKeys(params),
route,
routeAllowed: true,
ttlMs: params.ttlMs,
}),
);
const targetAuthorKeys = resolveSignalApprovalTargetAuthorKeys(params);
if (targetAuthorKeys.length === 0) {
return false;
}
let registered = false;
for (const messageId of listDeliveredSignalMessageIdsWithVisibleHint({
payload: params.payload,
results: params.results,
})) {
registered =
Boolean(
registerSignalApprovalReactionTarget({
accountId: normalizeAccountId(params.target.accountId ?? undefined),
conversationKey,
messageId,
approvalId: metadata.approvalId,
approvalKind: metadata.approvalKind,
allowedDecisions: metadata.allowedDecisions,
targetAuthorKeys,
route,
routeAllowed: true,
ttlMs: params.ttlMs,
}),
) || registered;
}
return registered;
}
export function unregisterSignalApprovalReactionTarget(params: {

View File

@@ -1,6 +1,7 @@
// Signal plugin module implements channel behavior.
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit";
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-contract";
import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { defineChannelMessageAdapter } from "openclaw/plugin-sdk/channel-outbound";
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-outbound";
@@ -40,10 +41,12 @@ import {
} from "./shared.js";
type SignalSendFn = typeof import("./send.runtime.js").sendMessageSignal;
type SignalProbe = import("./probe.js").SignalProbe;
type SignalApprovalReactionsModule = typeof import("./approval-reactions.js");
let signalMonitorModulePromise: Promise<typeof import("./monitor.js")> | null = null;
let signalProbeModulePromise: Promise<typeof import("./probe.js")> | null = null;
let signalSendRuntimePromise: Promise<typeof import("./send.runtime.js")> | null = null;
let signalApprovalReactionsModulePromise: Promise<SignalApprovalReactionsModule> | null = null;
async function loadSignalMonitorModule() {
signalMonitorModulePromise ??= import("./monitor.js");
@@ -60,6 +63,11 @@ async function loadSignalSendRuntime() {
return await signalSendRuntimePromise;
}
async function loadSignalApprovalReactionsModule() {
signalApprovalReactionsModulePromise ??= import("./approval-reactions.js");
return await signalApprovalReactionsModulePromise;
}
async function resolveSignalSendContext(params: {
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
accountId?: string;
@@ -102,6 +110,20 @@ type SignalMessageContextExtras = {
deps?: { [channelId: string]: unknown };
};
function attachSignalVisibleText<T extends object>(result: T, visibleText: string) {
const meta =
"meta" in result && result.meta && typeof result.meta === "object"
? (result.meta as Record<string, unknown>)
: {};
return {
...result,
meta: {
...meta,
signalVisibleText: visibleText,
},
};
}
const signalMessageAdapter = defineChannelMessageAdapter({
id: "signal",
durableFinal: {
@@ -224,7 +246,7 @@ async function sendFormattedSignalText(ctx: {
textMode: "plain",
textStyles: chunk.styles,
});
results.push(result);
results.push(attachSignalVisibleText(result, chunk.text));
}
return attachChannelToResults("signal", results);
}
@@ -267,7 +289,49 @@ async function sendFormattedSignalMedia(ctx: {
textMode: "plain",
textStyles: formatted.styles,
});
return attachChannelToResult("signal", result);
return attachChannelToResult("signal", attachSignalVisibleText(result, formatted.text));
}
async function registerDeliveredSignalApprovalPayloadForReactions(
params: Parameters<NonNullable<ChannelOutboundAdapter["afterDeliverPayload"]>>[0],
) {
const account = resolveSignalAccount({
cfg: params.cfg,
accountId: params.target.accountId ?? undefined,
});
if (!account.config.account) {
return;
}
const { registerSignalApprovalReactionTargetForDeliveredPayload } =
await loadSignalApprovalReactionsModule();
registerSignalApprovalReactionTargetForDeliveredPayload({
cfg: params.cfg,
target: params.target,
payload: params.payload,
results: params.results,
targetAuthor: account.config.account,
});
}
async function renderSignalApprovalPayloadForReactions(
params: Parameters<NonNullable<ChannelOutboundAdapter["renderPresentation"]>>[0],
) {
const account = resolveSignalAccount({
cfg: params.ctx.cfg,
accountId: params.ctx.accountId ?? undefined,
});
if (!account.config.account) {
return null;
}
const { addSignalApprovalReactionHintToStructuredPayload } =
await loadSignalApprovalReactionsModule();
return addSignalApprovalReactionHintToStructuredPayload({
cfg: params.ctx.cfg,
accountId: params.ctx.accountId ?? undefined,
to: params.ctx.to,
payload: params.payload,
targetAuthor: account.config.account,
});
}
export const signalPlugin: ChannelPlugin<ResolvedSignalAccount, SignalProbe> =
@@ -404,6 +468,9 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount, SignalProbe> =
payload,
hint,
}),
afterDeliverPayload: async (params) =>
await registerDeliveredSignalApprovalPayloadForReactions(params),
renderPresentation: async (params) => await renderSignalApprovalPayloadForReactions(params),
sendFormattedText: async ({ cfg, to, text, accountId, deps, abortSignal }) =>
await sendFormattedSignalText({
cfg,

View File

@@ -1,3 +1,4 @@
import { buildExecApprovalPendingReplyPayload } from "openclaw/plugin-sdk/approval-reply-runtime";
// Signal tests cover core plugin behavior.
import {
createMessageReceiptFromOutboundResults,
@@ -6,6 +7,10 @@ import {
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { createPluginSetupWizardStatus } from "openclaw/plugin-sdk/plugin-test-runtime";
import { describe, expect, it, vi } from "vitest";
import {
clearSignalApprovalReactionTargetsForTest,
resolveSignalApprovalReactionTargetWithPersistence,
} from "./approval-reactions.js";
import { signalPlugin } from "./channel.js";
import * as clientModule from "./client-adapter.js";
import { classifySignalCliLogLine } from "./daemon.js";
@@ -18,6 +23,7 @@ import {
import { probeSignal } from "./probe.js";
import { clearSignalRuntime } from "./runtime.js";
import {
createSignalCliPathTextInput,
normalizeSignalAccountInput,
parseSignalAllowFromEntries,
signalDmPolicy,
@@ -209,6 +215,13 @@ describe("probeSignal", () => {
expect(status.configured).toBe(true);
});
it("does not show a second missing-binary note before the cliPath prompt", () => {
const input = createSignalCliPathTextInput(async () => true);
expect(input.helpLines).toBeUndefined();
expect(input.helpTitle).toBeUndefined();
});
});
describe("signal outbound", () => {
@@ -264,6 +277,143 @@ describe("signal outbound", () => {
).toBe(true);
});
it("registers structured approval payloads for reactions after delivery", async () => {
clearSignalApprovalReactionTargetsForTest();
const cfg = {
channels: {
signal: {
account: "+15550009999",
allowFrom: ["+15551230000"],
},
},
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "signal", to: "+15551230000" }],
},
},
} as OpenClawConfig;
const payload = buildExecApprovalPendingReplyPayload({
approvalId: "exec-after-delivery",
approvalSlug: "exec-aft",
allowedDecisions: ["allow-once", "deny"],
command: "printf test",
host: "gateway",
agentId: "main",
sessionKey: "agent:main:signal:direct:+15551230000",
});
const rendered = await signalPlugin.outbound?.renderPresentation?.({
payload,
presentation: payload.presentation!,
ctx: {
cfg,
to: "+15551230000",
text: payload.text ?? "",
accountId: "default",
payload,
},
});
expect(rendered?.text).toContain("React with:\n\n👍 Allow Once\n👎 Deny");
await signalPlugin.outbound?.afterDeliverPayload?.({
cfg,
target: {
channel: "signal",
to: "+15551230000",
accountId: "default",
},
payload: rendered!,
results: [
{
channel: "signal",
messageId: "1700000000099",
},
],
});
await expect(
resolveSignalApprovalReactionTargetWithPersistence({
accountId: "default",
conversationKey: "+15551230000",
messageId: "1700000000099",
reactionKey: "👍",
targetAuthor: "+15550009999",
}),
).resolves.toEqual({
approvalId: "exec-after-delivery",
approvalKind: "exec",
decision: "allow-once",
route: {
deliveryMode: "target",
to: "+15551230000",
accountId: "default",
agentId: "main",
sessionKey: "agent:main:signal:direct:+15551230000",
},
});
});
it("renders reaction hints only from structured approval payloads", async () => {
const cfg = {
channels: {
signal: {
account: "+15550009999",
allowFrom: ["+15551230000"],
},
},
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "signal", to: "+15551230000" }],
},
},
} as OpenClawConfig;
const payload = buildExecApprovalPendingReplyPayload({
approvalId: "exec-rendered-approval",
approvalSlug: "exec-ren",
allowedDecisions: ["allow-once", "deny"],
command: "printf test",
host: "gateway",
});
const rendered = await signalPlugin.outbound?.renderPresentation?.({
payload,
presentation: payload.presentation!,
ctx: {
cfg,
to: "+15551230000",
text: payload.text ?? "",
accountId: "default",
payload,
},
});
expect(rendered?.text).toContain("React with:\n\n👍 Allow Once\n👎 Deny");
expect(
await signalPlugin.outbound?.renderPresentation?.({
payload: {
text: [
"The docs show this example:",
"Exec approval required",
"ID: exec-rendered-approval",
"",
"Reply with: /approve exec-rendered-approval allow-once|deny",
].join("\n"),
presentation: payload.presentation,
},
presentation: payload.presentation!,
ctx: {
cfg,
to: "+15551230000",
text: payload.text ?? "",
accountId: "default",
payload,
},
}),
).toBeNull();
});
it("declares message adapter durable text and media with receipt proofs", async () => {
const send = vi.fn(async (_to: string, _text: string, opts: { mediaUrl?: string } = {}) => {
const messageId = opts.mediaUrl ? "signal-media-1" : "signal-text-1";

View File

@@ -5,20 +5,36 @@ import path from "node:path";
import JSZip from "jszip";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import * as tar from "tar";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ReleaseAsset } from "./install-signal-cli.js";
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
fetchWithSsrFGuardMock: vi.fn(),
}));
const { fetchWithSsrFGuardMock, resolveBrewExecutableMock, runPluginCommandWithTimeoutMock } =
vi.hoisted(() => ({
fetchWithSsrFGuardMock: vi.fn(),
resolveBrewExecutableMock: vi.fn(),
runPluginCommandWithTimeoutMock: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
}));
vi.mock("openclaw/plugin-sdk/setup-tools", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/setup-tools")>();
return {
...actual,
resolveBrewExecutable: resolveBrewExecutableMock,
};
});
vi.mock("openclaw/plugin-sdk/run-command", () => ({
runPluginCommandWithTimeout: runPluginCommandWithTimeoutMock,
}));
const {
downloadToFile,
extractSignalCliArchive,
installSignalCli,
installSignalCliFromRelease,
looksLikeArchive,
pickAsset,
@@ -74,6 +90,8 @@ async function withTempFile(run: (filePath: string) => Promise<void>) {
beforeEach(() => {
fetchWithSsrFGuardMock.mockReset();
resolveBrewExecutableMock.mockReset();
runPluginCommandWithTimeoutMock.mockReset();
});
function requireAsset(asset: ReleaseAsset | undefined, label: string): ReleaseAsset {
@@ -143,6 +161,25 @@ describe("pickAsset", () => {
const result = requireAsset(pickAsset(SAMPLE_ASSETS, "darwin", "x64"), "darwin x64");
expect(result.name).toContain("macOS-native");
});
it("does not fall back to Linux client archives when macOS assets are absent", () => {
const currentUpstreamAssets: ReleaseAsset[] = [
{
name: "signal-cli-0.14.5-Linux-client.tar.gz",
browser_download_url: "https://example.com/linux-client.tar.gz",
},
{
name: "signal-cli-0.14.5-Linux-native.tar.gz",
browser_download_url: "https://example.com/linux-native.tar.gz",
},
{
name: "signal-cli-0.14.5.tar.gz",
browser_download_url: "https://example.com/jvm.tar.gz",
},
];
expect(pickAsset(currentUpstreamAssets, "darwin", "arm64")).toBeUndefined();
});
});
describe("win32", () => {
@@ -305,6 +342,46 @@ describe("installSignalCliFromRelease", () => {
});
});
describe("installSignalCli", () => {
const originalPlatform = process.platform;
const originalArch = process.arch;
function setProcessPlatform(platform: NodeJS.Platform, arch: string) {
Object.defineProperty(process, "platform", { configurable: true, value: platform });
Object.defineProperty(process, "arch", { configurable: true, value: arch });
}
afterEach(() => {
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
Object.defineProperty(process, "arch", { configurable: true, value: originalArch });
});
it("uses Homebrew on macOS instead of downloading the first GitHub release archive", async () => {
setProcessPlatform("darwin", "arm64");
const brewPrefix = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-signal-brew-"));
await fs.mkdir(path.join(brewPrefix, "bin"), { recursive: true });
await fs.writeFile(path.join(brewPrefix, "bin", "signal-cli"), "");
resolveBrewExecutableMock.mockReturnValue("/opt/homebrew/bin/brew");
runPluginCommandWithTimeoutMock
.mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" })
.mockResolvedValueOnce({ code: 0, stdout: `${brewPrefix}\n`, stderr: "" })
.mockResolvedValueOnce({ code: 0, stdout: "signal-cli 0.14.5\n", stderr: "" });
try {
const result = await installSignalCli({ log: vi.fn() } as unknown as RuntimeEnv);
expect(result).toEqual({
ok: true,
cliPath: path.join(brewPrefix, "bin", "signal-cli"),
version: "0.14.5",
});
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
} finally {
await fs.rm(brewPrefix, { recursive: true, force: true });
}
});
});
describe("extractSignalCliArchive", () => {
async function withArchiveWorkspace(run: (workDir: string) => Promise<void>) {
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-signal-install-"));

View File

@@ -105,7 +105,7 @@ export function pickAsset(
}
if (platform === "darwin") {
return byName(/macos|osx|darwin/) || archives[0];
return byName(/macos|osx|darwin/);
}
if (platform === "win32") {
@@ -228,7 +228,7 @@ async function installSignalCliViaBrew(runtime: RuntimeEnv): Promise<SignalInsta
return {
ok: false,
error:
`No native signal-cli build is available for ${process.arch}. ` +
`No native signal-cli build is available for ${process.platform}/${process.arch}. ` +
"Install Homebrew (https://brew.sh) and try again, or install signal-cli manually.",
};
}
@@ -372,9 +372,9 @@ export async function installSignalCli(runtime: RuntimeEnv): Promise<SignalInsta
}
// The official signal-cli GitHub releases only ship a native binary for
// x86-64 Linux. On other architectures (arm64, armv7, etc.) we delegate
// to Homebrew which builds from source and bundles the JRE automatically.
const hasNativeRelease = process.platform !== "linux" || process.arch === "x64";
// x86-64 Linux. Other platforms use Homebrew instead of guessing from
// unrelated release archives.
const hasNativeRelease = process.platform === "linux" && process.arch === "x64";
if (hasNativeRelease) {
return installSignalCliFromRelease(runtime);

View File

@@ -0,0 +1,127 @@
import { buildExecApprovalPendingReplyPayload } from "openclaw/plugin-sdk/approval-reply-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
clearSignalApprovalReactionTargetsForTest,
resolveSignalApprovalReactionTargetWithPersistence,
} from "./approval-reactions.js";
const sendMocks = vi.hoisted(() => ({
sendMessageSignal: vi.fn(),
}));
vi.mock("./send.js", async () => {
const actual = await vi.importActual<typeof import("./send.js")>("./send.js");
return {
...actual,
sendMessageSignal: sendMocks.sendMessageSignal,
};
});
const { deliverReplies } = await import("./monitor.js");
const botAccount = "+15550009999";
const approver = "+15551230000";
const cfg = {
channels: {
signal: {
account: botAccount,
allowFrom: [approver],
},
},
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "signal", to: approver }],
},
},
} as OpenClawConfig;
async function deliverReplyPayload(payload: ReplyPayload) {
await deliverReplies({
cfg,
replies: [payload],
target: approver,
baseUrl: "http://127.0.0.1:8080",
account: botAccount,
accountId: "default",
runtime: { log: vi.fn() } as never,
maxBytes: 8 * 1024 * 1024,
textLimit: 4000,
chunkMode: "length",
});
}
describe("Signal monitor approval reply delivery", () => {
beforeEach(() => {
clearSignalApprovalReactionTargetsForTest();
sendMocks.sendMessageSignal.mockReset().mockResolvedValue({
messageId: "1700000000200",
});
});
it("adds reaction hints and registers structured approval replies delivered by the monitor", async () => {
const payload = buildExecApprovalPendingReplyPayload({
approvalId: "exec-monitor-structured",
approvalSlug: "exec-mon",
allowedDecisions: ["allow-once", "deny"],
command: "printf monitor",
host: "gateway",
agentId: "main",
sessionKey: "agent:main:signal:direct:+15551230000",
});
await deliverReplyPayload(payload);
const sentText = String(sendMocks.sendMessageSignal.mock.calls[0]?.[1] ?? "");
expect(sentText).toContain("React with:\n\n👍 Allow Once\n👎 Deny");
await expect(
resolveSignalApprovalReactionTargetWithPersistence({
accountId: "default",
conversationKey: approver,
messageId: "1700000000200",
reactionKey: "👍",
targetAuthor: botAccount,
}),
).resolves.toEqual({
approvalId: "exec-monitor-structured",
approvalKind: "exec",
decision: "allow-once",
route: {
deliveryMode: "target",
to: approver,
accountId: "default",
agentId: "main",
sessionKey: "agent:main:signal:direct:+15551230000",
},
});
});
it("does not bind ordinary monitor replies that quote approval commands", async () => {
const payload = {
text: [
"The docs show this example:",
"Exec approval required",
"ID: exec-monitor-quoted",
"",
"Reply with: /approve exec-monitor-quoted allow-once|deny",
].join("\n"),
};
await deliverReplyPayload(payload);
const sentText = String(sendMocks.sendMessageSignal.mock.calls[0]?.[1] ?? "");
expect(sentText).not.toContain("React with:");
await expect(
resolveSignalApprovalReactionTargetWithPersistence({
accountId: "default",
conversationKey: approver,
messageId: "1700000000200",
reactionKey: "👍",
targetAuthor: botAccount,
}),
).resolves.toBeNull();
});
});

View File

@@ -39,6 +39,10 @@ import { normalizeE164 } from "openclaw/plugin-sdk/text-utility-runtime";
import { waitForTransportReady } from "openclaw/plugin-sdk/transport-ready-runtime";
import { resolveSignalAccount } from "./accounts.js";
import { isSignalNativeApprovalHandlerConfigured } from "./approval-native.js";
import {
addSignalApprovalReactionHintToStructuredPayload,
registerSignalApprovalReactionTargetForDeliveredPayload,
} from "./approval-reactions.js";
import { signalRpcRequest, signalCheck } from "./client-adapter.js";
import { formatSignalDaemonExit, spawnSignalDaemon, type SignalDaemonHandle } from "./daemon.js";
import { isSignalSenderAllowed, type resolveSignalSender } from "./identity.js";
@@ -354,7 +358,7 @@ async function fetchAttachment(params: {
return { path: saved.path, contentType: saved.contentType };
}
async function deliverReplies(params: {
export async function deliverReplies(params: {
cfg: OpenClawConfig;
replies: ReplyPayload[];
target: string;
@@ -369,32 +373,79 @@ async function deliverReplies(params: {
const { replies, target, baseUrl, account, accountId, runtime, maxBytes, textLimit, chunkMode } =
params;
for (const payload of replies) {
const reply = resolveSendableOutboundReplyParts(payload);
const deliveryResults: Array<{
channel: "signal";
messageId: string;
meta: { signalVisibleText: string };
}> = [];
const deliveredPayload =
addSignalApprovalReactionHintToStructuredPayload({
cfg: params.cfg,
accountId,
to: target,
payload,
targetAuthor: account,
}) ?? payload;
const reply = resolveSendableOutboundReplyParts(deliveredPayload);
const recordDeliveryResult = (
result: Awaited<ReturnType<typeof sendMessageSignal>>,
visibleText: string,
) => {
const messageId =
typeof result?.messageId === "string" && result.messageId.trim()
? result.messageId.trim()
: null;
if (messageId) {
deliveryResults.push({
channel: "signal",
messageId,
meta: { signalVisibleText: visibleText },
});
}
};
const delivered = await deliverTextOrMediaReply({
payload,
payload: deliveredPayload,
text: reply.text,
chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode),
sendText: async (chunk) => {
await sendMessageSignal(target, chunk, {
cfg: params.cfg,
baseUrl,
account,
maxBytes,
accountId,
});
recordDeliveryResult(
await sendMessageSignal(target, chunk, {
cfg: params.cfg,
baseUrl,
account,
maxBytes,
accountId,
}),
chunk,
);
},
sendMedia: async ({ mediaUrl, caption }) => {
await sendMessageSignal(target, caption ?? "", {
cfg: params.cfg,
baseUrl,
account,
mediaUrl,
maxBytes,
accountId,
});
const visibleText = caption ?? "";
recordDeliveryResult(
await sendMessageSignal(target, visibleText, {
cfg: params.cfg,
baseUrl,
account,
mediaUrl,
maxBytes,
accountId,
}),
visibleText,
);
},
});
if (delivered !== "empty") {
registerSignalApprovalReactionTargetForDeliveredPayload({
cfg: params.cfg,
target: {
channel: "signal",
to: target,
accountId,
},
payload: deliveredPayload,
results: deliveryResults,
targetAuthor: account,
});
runtime.log?.(`delivered reply to ${target}`);
}
}

View File

@@ -129,4 +129,73 @@ describe("sendMessageSignal receipts", () => {
expect(result.messageId).toBe("unknown");
expect(result.receipt.platformMessageIds).toStrictEqual([]);
});
it("does not add approval reactions to ordinary outbound approval-looking text", async () => {
signalRpcRequestMock.mockResolvedValueOnce({ timestamp: 1234567892 });
const text = [
"Here is the command you asked about:",
"/approve exec-live-approval allow-once|deny",
].join("\n");
await sendMessageSignal("+15551234567", text, {
cfg: {
...SIGNAL_TEST_CFG,
channels: {
signal: {
...SIGNAL_TEST_CFG.channels.signal,
allowFrom: ["+15551234567"],
},
},
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "signal", to: "+15551234567" }],
},
},
},
});
expect(signalRpcRequestMock).toHaveBeenCalledWith(
"send",
expect.objectContaining({ message: text }),
expect.any(Object),
);
});
it("does not add approval reactions to ordinary outbound text quoting a full prompt", async () => {
signalRpcRequestMock.mockResolvedValueOnce({ timestamp: 1234567893 });
const text = [
"The docs show this example:",
"Exec approval required",
"ID: exec-live-approval",
"",
"Reply with: /approve exec-live-approval allow-once|deny",
].join("\n");
await sendMessageSignal("+15551234567", text, {
cfg: {
...SIGNAL_TEST_CFG,
channels: {
signal: {
...SIGNAL_TEST_CFG.channels.signal,
allowFrom: ["+15551234567"],
},
},
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "signal", to: "+15551234567" }],
},
},
},
});
expect(signalRpcRequestMock).toHaveBeenCalledWith(
"send",
expect.objectContaining({ message: text }),
expect.any(Object),
);
});
});

View File

@@ -12,10 +12,6 @@ import { resolveOutboundAttachmentFromUrl } from "openclaw/plugin-sdk/media-runt
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveSignalAccount } from "./accounts.js";
import {
appendSignalApprovalReactionHintForOutboundMessage,
registerSignalApprovalReactionTargetForOutboundMessage,
} from "./approval-reactions.js";
import { signalRpcRequest } from "./client-adapter.js";
import { markdownToSignalText, type SignalTextStyleRange } from "./format.js";
import { resolveSignalRpcContext } from "./rpc-context.js";
@@ -184,14 +180,7 @@ export async function sendMessageSignal(
});
const { baseUrl, account } = resolveSignalRpcContext(opts, accountInfo);
const target = parseTarget(to);
const outboundText = appendSignalApprovalReactionHintForOutboundMessage({
cfg,
accountId: accountInfo.accountId,
to,
text: text ?? "",
targetAuthor: account,
});
let message = outboundText;
let message = text ?? "";
let messageFromPlaceholder = false;
let textStyles: SignalTextStyleRange[] = [];
const textMode = opts.textMode ?? "markdown";
@@ -273,14 +262,6 @@ export async function sendMessageSignal(
});
const timestamp = result?.timestamp;
const messageId = timestamp ? String(timestamp) : "unknown";
registerSignalApprovalReactionTargetForOutboundMessage({
cfg,
accountId: accountInfo.accountId,
to,
messageId,
text: outboundText,
targetAuthor: account,
});
return {
messageId,
timestamp,

View File

@@ -193,10 +193,6 @@ export function createSignalCliPathTextInput(
resolvePath: ({ cfg, accountId, credentialValues }) =>
resolveSignalCliPath({ cfg, accountId, credentialValues }),
shouldPrompt,
helpTitle: "Signal",
helpLines: [
"signal-cli not found. Install it, then rerun this step or set channels.signal.cliPath.",
],
});
}

View File

@@ -486,6 +486,49 @@ async function pendingUpdateIds(spoolDir: string, limit: number | "all" = 100):
return (await listTelegramSpooledUpdates({ spoolDir, limit })).map((update) => update.updateId);
}
async function claimedAtForUpdate(spoolDir: string, updateId: number): Promise<number> {
const claim = (await listTelegramSpooledUpdateClaims({ spoolDir })).find(
(entry) => entry.updateId === updateId,
);
if (!claim?.claim) {
throw new Error(`Expected claimed spooled update ${updateId}`);
}
return claim.claim.claimedAt;
}
function installSpooledClaimRefreshHarness(): {
restore: () => void;
triggerRefresh: () => void;
} {
let refresh: (() => void) | undefined;
const realSetInterval = globalThis.setInterval.bind(globalThis);
const setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation(((
handler: Parameters<typeof setInterval>[0],
timeout?: number,
) => {
if (timeout === pollingSessionTesting.spooledClaimRefreshIntervalMs) {
refresh = () => {
if (typeof handler === "function") {
handler();
}
};
const timer = realSetInterval(() => undefined, 2_147_483_647);
timer.unref?.();
return timer;
}
return realSetInterval(handler, timeout);
}) as typeof setInterval);
return {
restore: () => setIntervalSpy.mockRestore(),
triggerRefresh: () => {
if (!refresh) {
throw new Error("Expected spooled claim refresh interval to be registered");
}
refresh();
},
};
}
function normalizeTelegramTestAccountId(spoolDir: string): string {
const trimmed = path.basename(spoolDir).trim();
return trimmed ? trimmed.replace(/[^a-z0-9._-]+/gi, "_") : "default";
@@ -1575,6 +1618,49 @@ describe("TelegramPollingSession", () => {
});
});
it("refreshes active spooled claims while the handler is still running", async () => {
const refreshHarness = installSpooledClaimRefreshHarness();
await withTempSpool(async (tempDir) => {
const abort = new AbortController();
const events: string[] = [];
let releaseHandler: (() => void) | undefined;
const handlerDone = new Promise<void>((resolve) => {
releaseHandler = resolve;
});
await writeSpooledTestUpdates(tempDir, [topicUpdate(42, 10, "long topic 10 turn")]);
const { runPromise, stopWorker } = startIsolatedIngressSession({
abort,
spoolDir: tempDir,
handleUpdate: async (update) => {
events.push(`topic10:${update.update_id}`);
await handlerDone;
},
});
try {
await vi.waitFor(() => expect(events).toEqual(["topic10:42"]));
const before = await claimedAtForUpdate(tempDir, 42);
refreshHarness.triggerRefresh();
await vi.waitFor(async () =>
expect(await claimedAtForUpdate(tempDir, 42)).toBeGreaterThan(before),
);
releaseHandler?.();
await vi.waitFor(async () =>
expect(await listTelegramSpooledUpdateClaims({ spoolDir: tempDir })).toEqual([]),
);
} finally {
releaseHandler?.();
abort.abort();
stopWorker();
refreshHarness.restore();
await runPromise;
}
});
});
it("holds buffered spooled claims until deferred processing settles without blocking same-lane buffering", async () => {
await withTempSpool(async (tempDir) => {
const abort = new AbortController();
@@ -1625,6 +1711,50 @@ describe("TelegramPollingSession", () => {
});
});
it("refreshes deferred spooled claims after the active handler hands off", async () => {
const refreshHarness = installSpooledClaimRefreshHarness();
await withTempSpool(async (tempDir) => {
const abort = new AbortController();
const participants: TelegramSpooledReplayDeferredParticipant[] = [];
await writeSpooledTestUpdates(tempDir, [topicUpdate(42, 10, "buffered topic 10 turn")]);
const { runPromise, stopWorker } = startIsolatedIngressSession({
abort,
spoolDir: tempDir,
handleUpdate: async (update) => {
const participant = createTelegramSpooledReplayDeferredParticipant(
`test-buffer:${update.update_id}`,
);
if (!participant) {
throw new Error("expected spooled replay participant");
}
participants.push(participant);
},
});
try {
await vi.waitFor(() => expect(participants).toHaveLength(1));
const before = await claimedAtForUpdate(tempDir, 42);
refreshHarness.triggerRefresh();
await vi.waitFor(async () =>
expect(await claimedAtForUpdate(tempDir, 42)).toBeGreaterThan(before),
);
participants[0]?.settle({ kind: "completed" });
await vi.waitFor(async () =>
expect(await listTelegramSpooledUpdateClaims({ spoolDir: tempDir })).toEqual([]),
);
} finally {
participants[0]?.settle({ kind: "completed" });
abort.abort();
stopWorker();
refreshHarness.restore();
await runPromise;
}
});
});
it("releases buffered spooled claims for retry when deferred processing fails", async () => {
await withTempSpool(async (tempDir) => {
const abort = new AbortController();
@@ -3585,6 +3715,106 @@ describe("TelegramPollingSession", () => {
}
});
it("marks isolated ingress unhealthy when a spooled backlog stalls before handler timeout", async () => {
vi.useFakeTimers({ now: 1_000, shouldAdvanceTime: true });
const abort = new AbortController();
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-spool-"));
const setStatus = vi.fn();
let releaseRegularTurn: (() => void) | undefined;
const regularTurnDone = new Promise<void>((resolve) => {
releaseRegularTurn = resolve;
});
const handleUpdate = vi.fn(async () => {
await regularTurnDone;
});
createTelegramBotMock.mockReturnValueOnce({
api: {
deleteWebhook: vi.fn(async () => true),
config: { use: vi.fn() },
},
init: vi.fn(async () => undefined),
handleUpdate,
stop: vi.fn(async () => undefined),
});
await writeSpooledTestUpdates(tempDir, [
topicUpdate(42, 10, "active topic 10 turn"),
topicUpdate(43, 10, "later topic 10 turn"),
]);
const workerListeners: WorkerMessageListener[] = [];
let stopWorker: (() => void) | undefined;
const workerDone = new Promise<void>((resolve) => {
stopWorker = resolve;
});
const createWorker = vi.fn(() => ({
onMessage: vi.fn((listener: WorkerMessageListener) => {
workerListeners.push(listener);
return () => undefined;
}),
stop: vi.fn(async () => {
stopWorker?.();
}),
task: vi.fn(async () => {
await workerDone;
}),
}));
try {
const session = createPollingSession({
abortSignal: abort.signal,
setStatus,
isolatedIngress: {
enabled: true,
spoolDir: tempDir,
createWorker,
drainIntervalMs: pollingSessionTesting.isolatedIngressBacklogStallMs * 2,
spooledUpdateHandlerTimeoutMs: pollingSessionTesting.isolatedIngressBacklogStallMs * 2,
},
});
const runPromise = session.runUntilAbort();
await vi.waitFor(() => expect(handleUpdate).toHaveBeenCalledTimes(1));
workerListeners[0]?.({
type: "poll-success",
offset: null,
count: 0,
finishedAt: Date.now(),
});
expect(statusPatches(setStatus).some((patch) => patch.connected === true)).toBe(true);
vi.setSystemTime(1_000 + pollingSessionTesting.isolatedIngressBacklogStallMs + 1);
workerListeners[0]?.({ type: "spooled", updateId: 43, queued: 1 });
await vi.waitFor(() =>
expect(
statusPatches(setStatus).some(
(patch) =>
patch.connected === false &&
String(patch.lastError).includes("isolated polling spool backlog stalled"),
),
).toBe(true),
);
expect(await failedUpdateIds(tempDir)).toEqual([]);
expect(await pendingUpdateIds(tempDir, "all")).toEqual([43]);
expect(
(await listTelegramSpooledUpdateClaims({ spoolDir: tempDir })).map(
(claim) => claim.updateId,
),
).toEqual([42]);
releaseRegularTurn?.();
abort.abort();
stopWorker?.();
await vi.advanceTimersByTimeAsync(20_000);
await runPromise;
} finally {
releaseRegularTurn?.();
abort.abort();
stopWorker?.();
vi.useRealTimers();
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("marks isolated ingress unhealthy when a spooled backlog handler times out", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const abort = new AbortController();

View File

@@ -41,6 +41,7 @@ import {
listTelegramSpooledUpdateClaims,
listTelegramSpooledUpdates,
recoverStaleTelegramSpooledUpdateClaims,
refreshTelegramSpooledUpdateClaim,
releaseTelegramSpooledUpdateClaim,
resolveTelegramIngressSpoolDir,
writeTelegramSpooledUpdate,
@@ -131,6 +132,7 @@ const TELEGRAM_SPOOLED_HANDLER_ABORT_GRACE_MS = 5_000;
const TELEGRAM_SPOOLED_HANDLER_TIMEOUT_ENV = "OPENCLAW_TELEGRAM_SPOOLED_HANDLER_TIMEOUT_MS";
const TELEGRAM_SPOOLED_DRAIN_START_LIMIT = 100;
const TELEGRAM_SPOOLED_DRAIN_SCAN_LIMIT = TELEGRAM_SPOOLED_DRAIN_START_LIMIT * 10;
const TELEGRAM_SPOOLED_CLAIM_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
const TELEGRAM_SPOOLED_SESSION_INIT_CONFLICT_RETRY_BASE_MS = 5_000;
const TELEGRAM_SPOOLED_SESSION_INIT_CONFLICT_RETRY_MAX_MS = 60_000;
const TELEGRAM_POLLING_CLIENT_TIMEOUT_FLOOR_SECONDS = Math.ceil(
@@ -291,6 +293,8 @@ type SpooledUpdateHandlerState = {
update: ClaimedTelegramSpooledUpdate;
updateId: number;
startedAt: number;
stopClaimRefresh: () => void;
backlogStatusMessage?: string;
timedOutAt?: number;
timeoutMessage?: string;
};
@@ -303,6 +307,7 @@ type DeferredSpooledUpdateClaimState = {
timedOutMessage?: string;
update: ClaimedTelegramSpooledUpdate;
updateId: number;
stopClaimRefresh: () => void;
};
const deferredSpooledUpdateClaimsByKey = new Map<string, DeferredSpooledUpdateClaimState>();
@@ -572,8 +577,46 @@ export class TelegramPollingSession {
}
}
#startSpooledUpdateClaimRefresh(update: ClaimedTelegramSpooledUpdate): () => void {
// Refresh only while this process still owns useful work for this claim token.
// Stopping before release/fail/delete lets stale recovery take over if work stalls.
let stopped = false;
let refreshing = false;
const refresh = async (): Promise<void> => {
if (stopped || refreshing) {
return;
}
refreshing = true;
try {
const refreshed = await refreshTelegramSpooledUpdateClaim(update);
if (!refreshed && !stopped) {
stopped = true;
clearInterval(timer);
}
} catch (err) {
this.opts.log(
`[telegram][diag] spooled update ${update.updateId} claim refresh failed: ${formatErrorMessage(err)}`,
);
} finally {
refreshing = false;
}
};
const timer = setInterval(() => {
void refresh();
}, TELEGRAM_SPOOLED_CLAIM_REFRESH_INTERVAL_MS);
timer.unref?.();
return () => {
if (stopped) {
return;
}
stopped = true;
clearInterval(timer);
};
}
async #handleClaimedSpooledUpdate(params: {
bot: TelegramBot;
stopClaimRefresh: () => void;
update: ClaimedTelegramSpooledUpdate;
}): Promise<boolean> {
let replay: { deferredWork?: TelegramSpooledReplayDeferredParticipant };
@@ -583,6 +626,7 @@ export class TelegramPollingSession {
await params.bot.handleUpdate(update);
});
} catch (err) {
params.stopClaimRefresh();
await this.#releaseFailedSpooledUpdate({
err,
update: params.update,
@@ -593,11 +637,13 @@ export class TelegramPollingSession {
this.#registerDeferredSpooledUpdate({
deferredWork: replay.deferredWork,
laneKey: this.#spooledUpdateLaneKey(params.update),
stopClaimRefresh: params.stopClaimRefresh,
update: params.update,
});
return true;
}
try {
params.stopClaimRefresh();
await deleteTelegramSpooledUpdate(params.update);
return true;
} catch (err) {
@@ -611,6 +657,7 @@ export class TelegramPollingSession {
#registerDeferredSpooledUpdate(params: {
deferredWork: TelegramSpooledReplayDeferredParticipant;
laneKey: string;
stopClaimRefresh: () => void;
update: ClaimedTelegramSpooledUpdate;
}): void {
const claimKey = buildDeferredSpooledUpdateClaimKey(params.update);
@@ -619,6 +666,7 @@ export class TelegramPollingSession {
if (previous.timer) {
clearTimeout(previous.timer);
}
previous.stopClaimRefresh();
deferredSpooledUpdateClaimsByKey.delete(claimKey);
}
let settled = false;
@@ -630,6 +678,7 @@ export class TelegramPollingSession {
if (state.timer) {
clearTimeout(state.timer);
}
state.stopClaimRefresh();
if (deferredSpooledUpdateClaimsByKey.get(claimKey) === state) {
deferredSpooledUpdateClaimsByKey.delete(claimKey);
}
@@ -661,10 +710,12 @@ export class TelegramPollingSession {
}),
update: params.update,
updateId: params.update.updateId,
stopClaimRefresh: params.stopClaimRefresh,
};
state.timer = setTimeout(() => {
const age = formatDurationPrecise(this.#spooledUpdateHandlerTimeoutMs);
state.timedOutMessage = `Telegram isolated polling spool buffered processing timed out behind update ${params.update.updateId} on lane ${params.laneKey} after ${age}; marking the update failed, aborting active reply work, and keeping the claim out of retry while the buffered task settles.`;
state.stopClaimRefresh();
params.deferredWork.settle({
kind: "failed-retryable",
error: new Error(state.timedOutMessage),
@@ -905,8 +956,10 @@ export class TelegramPollingSession {
claimedLaneKeys.add(laneKey);
continue;
}
const stopClaimRefresh = this.#startSpooledUpdateClaimRefresh(claimedUpdate);
const handler = this.#handleClaimedSpooledUpdate({
bot: params.bot,
stopClaimRefresh,
update: claimedUpdate,
});
const state: SpooledUpdateHandlerState = {
@@ -916,11 +969,17 @@ export class TelegramPollingSession {
update: claimedUpdate,
updateId: update.updateId,
startedAt: Date.now(),
stopClaimRefresh,
};
activeSpooledUpdateHandlersByLane.set(handlerKey, state);
this.#spooledUpdateHandlerKeys.add(handlerKey);
claimedLaneKeys.add(laneKey);
void handler.finally(() => {
if (
!deferredSpooledUpdateClaimsByKey.has(buildDeferredSpooledUpdateClaimKey(claimedUpdate))
) {
state.stopClaimRefresh();
}
if (activeSpooledUpdateHandlersByLane.get(handlerKey) === state) {
activeSpooledUpdateHandlersByLane.delete(handlerKey);
}
@@ -969,6 +1028,7 @@ export class TelegramPollingSession {
}
const age = formatDurationPrecise(timedOutHandler.ageMs);
activeHandler.timedOutAt = Date.now();
activeHandler.stopClaimRefresh();
const message = `Telegram isolated polling spool handler timed out behind update ${handler.updateId} on lane ${handler.laneKey} after ${age}; marking the update failed, aborting active reply work, and restarting isolated ingress so later updates can drain.`;
activeHandler.timeoutMessage = message;
try {
@@ -1025,6 +1085,27 @@ export class TelegramPollingSession {
return { handlerKey: handler.handlerKey, restart: true };
}
#noteSpooledBacklogStalls(blockedHandlerKeys: Set<string>): Set<string> {
const stalled = new Set<string>();
const now = Date.now();
for (const handlerKey of blockedHandlerKeys) {
const handler = activeSpooledUpdateHandlersByLane.get(handlerKey);
if (!handler || handler.timedOutAt !== undefined) {
continue;
}
const ageMs = now - handler.startedAt;
if (ageMs < ISOLATED_INGRESS_BACKLOG_STALL_MS) {
continue;
}
stalled.add(handlerKey);
if (!handler.backlogStatusMessage) {
handler.backlogStatusMessage = `Telegram isolated polling spool backlog stalled behind update ${handler.updateId} on lane ${handler.laneKey} for ${formatDurationPrecise(ageMs)}; marking polling unhealthy until the backlog drains.`;
this.#status.notePollingError(handler.backlogStatusMessage);
}
}
return stalled;
}
async #runIsolatedIngressCycle(bot: TelegramBot): Promise<"continue" | "exit"> {
const ingress = this.opts.isolatedIngress;
if (!ingress?.enabled) {
@@ -1222,6 +1303,9 @@ export class TelegramPollingSession {
this.#status.notePollingError(handler.timeoutMessage);
}
}
for (const handlerKey of this.#noteSpooledBacklogStalls(drain.blockedByLane)) {
stalledBacklogKeys.add(handlerKey);
}
// Active handlers can outlive their owning session after shutdown grace.
// Recover every handler for this spool, including lone handlers with no backlog.
const timeoutCandidateHandlerKeys = this.#activeSpooledUpdateHandlerKeysForSpool(spoolDir);
@@ -1561,6 +1645,8 @@ export const testing = {
resetTelegramRestartBackoffState,
resolveTelegramRestartDelayMs,
resolveSpooledUpdateRetryDelayMs,
isolatedIngressBacklogStallMs: ISOLATED_INGRESS_BACKLOG_STALL_MS,
spooledClaimRefreshIntervalMs: TELEGRAM_SPOOLED_CLAIM_REFRESH_INTERVAL_MS,
resolveSpooledUpdateHandlerAbortGraceMs: (valueMs: unknown): number =>
resolvePositiveTimerTimeoutMs(valueMs, TELEGRAM_SPOOLED_HANDLER_ABORT_GRACE_MS),
};

View File

@@ -17,6 +17,7 @@ import {
listTelegramSpooledUpdateClaims,
listTelegramSpooledUpdates,
recoverStaleTelegramSpooledUpdateClaims,
refreshTelegramSpooledUpdateClaim,
releaseTelegramSpooledUpdateClaim,
TELEGRAM_SPOOLED_UPDATE_PROCESSING_STALE_MS,
writeTelegramSpooledUpdate,
@@ -140,6 +141,32 @@ describe("Telegram ingress spool", () => {
});
});
it("refreshes active claim timestamps through the Telegram spool queue", async () => {
await withTempSpool(async (spoolDir) => {
await writeTelegramSpooledUpdate({
spoolDir,
update: { update_id: 31, message: { text: "refresh me" } },
});
const update = (await listTelegramSpooledUpdates({ spoolDir }))[0];
if (!update) {
throw new Error("Expected a spooled update");
}
const claimed = await claimTelegramSpooledUpdate(update);
if (!claimed) {
throw new Error("Expected a claimed update");
}
await expect(refreshTelegramSpooledUpdateClaim(claimed, { refreshedAt: 123 })).resolves.toBe(
true,
);
const claims = await listTelegramSpooledUpdateClaims({ spoolDir });
expect(claims).toHaveLength(1);
expect(claims[0]?.updateId).toBe(31);
expect(claims[0]?.claim?.claimedAt).toBe(123);
});
});
it("marks timed out claims failed without requeueing them", async () => {
await withTempSpool(async (spoolDir) => {
await writeTelegramSpooledUpdate({

View File

@@ -281,6 +281,23 @@ export async function releaseTelegramSpooledUpdateClaim(
);
}
export async function refreshTelegramSpooledUpdateClaim(
update: ClaimedTelegramSpooledUpdate,
options?: { refreshedAt?: number },
): Promise<boolean> {
const claimToken = update.claim?.claimToken;
if (!claimToken) {
return false;
}
const queue = createTelegramIngressQueue(path.dirname(update.pendingPath));
return (
(await queue.refreshClaim?.(
{ id: queueEventId(update.updateId), claim: { token: claimToken } },
options,
)) ?? false
);
}
export async function failTelegramSpooledUpdateClaim(params: {
update: ClaimedTelegramSpooledUpdate;
reason: string;

View File

@@ -3,6 +3,14 @@ import fs from "node:fs";
import path from "node:path";
export const PLAIN_GH_MAX_BUFFER_BYTES = 32 * 1024 * 1024;
export const PLAIN_GH_SYSTEM_CANDIDATES = [
// Prefer package-manager opt paths: bin/gh may intentionally be an Octopool shim.
"/opt/homebrew/opt/gh/bin/gh",
"/usr/local/opt/gh/bin/gh",
"/home/linuxbrew/.linuxbrew/opt/gh/bin/gh",
"/opt/homebrew/bin/gh",
"/usr/local/bin/gh",
];
function isExecutable(filePath) {
try {
@@ -32,7 +40,10 @@ export function plainGhEnv(env = process.env) {
return next;
}
export function resolvePlainGhBin(env = process.env) {
export function resolvePlainGhBin(
env = process.env,
systemCandidates = PLAIN_GH_SYSTEM_CANDIDATES,
) {
if (env.OPENCLAW_GH_BIN) {
if (isExecutable(env.OPENCLAW_GH_BIN)) {
return env.OPENCLAW_GH_BIN;
@@ -40,7 +51,7 @@ export function resolvePlainGhBin(env = process.env) {
throw new Error(`OPENCLAW_GH_BIN is not executable: ${env.OPENCLAW_GH_BIN}`);
}
for (const candidate of ["/opt/homebrew/bin/gh", "/usr/local/bin/gh"]) {
for (const candidate of systemCandidates) {
if (isExecutable(candidate)) {
return candidate;
}

View File

@@ -24,12 +24,12 @@ resolve_plain_gh_bin() {
fi
local candidate
for candidate in /opt/homebrew/bin/gh /usr/local/bin/gh; do
while IFS= read -r candidate; do
if [ -x "$candidate" ]; then
printf '%s\n' "$candidate"
return 0
fi
done
done < <(plain_gh_system_candidates)
if candidate=$(PATH="$(plain_gh_search_path)" type -P gh 2>/dev/null); then
printf '%s\n' "$candidate"
@@ -39,6 +39,16 @@ resolve_plain_gh_bin() {
type -P gh 2>/dev/null
}
plain_gh_system_candidates() {
# bin/gh may intentionally be an Octopool shim; prefer package-manager opt paths.
printf '%s\n' \
/opt/homebrew/opt/gh/bin/gh \
/usr/local/opt/gh/bin/gh \
/home/linuxbrew/.linuxbrew/opt/gh/bin/gh \
/opt/homebrew/bin/gh \
/usr/local/bin/gh
}
plain_gh_search_path() {
local path_value="${PATH:-}"
local home_bin="${HOME:-}/bin"

View File

@@ -189,6 +189,38 @@ describe("agent tool definition adapter", () => {
});
});
it("does not throw WeakMap errors when preparing malformed backend sandbox exec params", async () => {
const validateWorkdir = vi.fn(async (workdir: string) => workdir);
const tool = createExecTool({
host: "sandbox",
security: "full",
ask: "off",
sandbox: {
containerName: "remote-sandbox-workdir-test",
workspaceDir: process.cwd(),
containerWorkdir: "/remote/workspace",
workdirValidation: "backend",
validateWorkdir,
},
});
const [definition] = toToolDefinitions([tool]);
const result = await definition.execute(
"call-malformed-backend-sandbox-exec-params",
"not-an-object",
undefined,
undefined,
extensionContext,
);
expect(result.details).toMatchObject({
status: "error",
error: "Provide a command to start.",
});
expect(JSON.stringify(result)).not.toContain("WeakMap");
expect(validateWorkdir).not.toHaveBeenCalled();
});
it("reports malformed exec params when elevated logging is enabled", async () => {
const tool = createExecTool({
security: "full",

View File

@@ -367,6 +367,62 @@ describe("exec foreground failures", () => {
}
});
it("finalizes backend sandbox exec tokens when process spawn fails", async () => {
const workspaceDir = tempDirs.make("openclaw-sandbox-workdir-");
const finalizeToken = { session: "remote-session" };
const buildExecSpec = vi.fn<NonNullable<BashSandboxConfig["buildExecSpec"]>>(
async (params) => ({
argv: ["remote-shell", params.command],
env: {},
stdinMode: "pipe-open" as const,
finalizeToken,
}),
);
const finalizeExec = vi.fn<NonNullable<BashSandboxConfig["finalizeExec"]>>(async () => {});
const validateWorkdir = vi.fn<NonNullable<BashSandboxConfig["validateWorkdir"]>>(
async (workdir) => workdir,
);
supervisorMock.spawn.mockRejectedValueOnce(new Error("spawn failed"));
const tool = createExecTool({
host: "sandbox",
security: "full",
ask: "off",
allowBackground: false,
sandbox: {
containerName: "remote-sandbox-workdir-test",
workspaceDir,
containerWorkdir: "/remote/workspace",
workdirValidation: "backend",
validateWorkdir,
buildExecSpec,
finalizeExec,
},
});
try {
await expect(
tool.execute("call-remote-sandbox-spawn-failure", {
command: "echo ok",
workdir: "/remote/workspace/generated",
}),
).rejects.toThrow("spawn failed");
expect(validateWorkdir).toHaveBeenCalledWith("/remote/workspace/generated");
expect(buildExecSpec).toHaveBeenCalledOnce();
expect(supervisorMock.spawn).toHaveBeenCalledOnce();
expect(finalizeExec).toHaveBeenCalledOnce();
expect(finalizeExec).toHaveBeenCalledWith({
status: "failed",
exitCode: null,
timedOut: false,
token: finalizeToken,
});
} finally {
fs.rmSync(workspaceDir, { recursive: true, force: true });
}
});
it("rejects unsafe commands before backend workdir validation", async () => {
const workspaceDir = tempDirs.make("openclaw-sandbox-workdir-");
const buildExecSpec = vi.fn<NonNullable<BashSandboxConfig["buildExecSpec"]>>(

View File

@@ -715,6 +715,21 @@ export async function runExecProcess(opts: {
const timeoutMs = resolveExecTimeoutMs(opts.timeoutSec);
let sandboxFinalizeToken: unknown;
let sandboxFinalized = false;
const finalizeSandboxExec = async (params: {
status: "completed" | "failed";
exitCode: number | null;
timedOut: boolean;
}) => {
if (sandboxFinalized || !opts.sandbox?.finalizeExec) {
return;
}
sandboxFinalized = true;
await opts.sandbox.finalizeExec({
...params,
token: sandboxFinalizeToken,
});
};
const spawnSpec:
| {
@@ -861,6 +876,13 @@ export async function runExecProcess(opts: {
} catch (retryErr) {
markExited(session, null, null, "failed");
maybeNotifyOnExit(session, "failed");
await finalizeSandboxExec({
status: "failed",
exitCode: null,
timedOut: false,
}).catch((finalizeErr: unknown) => {
logWarn(`exec: sandbox finalize after spawn failure failed (${String(finalizeErr)}).`);
});
emitExecProcessCompleted({
command: opts.command,
mode: "child",
@@ -877,6 +899,13 @@ export async function runExecProcess(opts: {
} else {
markExited(session, null, null, "failed");
maybeNotifyOnExit(session, "failed");
await finalizeSandboxExec({
status: "failed",
exitCode: null,
timedOut: false,
}).catch((finalizeErr: unknown) => {
logWarn(`exec: sandbox finalize after spawn failure failed (${String(finalizeErr)}).`);
});
emitExecProcessCompleted({
command: opts.command,
mode: spawnSpec.mode,
@@ -915,14 +944,11 @@ export async function runExecProcess(opts: {
if (!session.child && session.stdin) {
session.stdin.destroyed = true;
}
if (opts.sandbox?.finalizeExec) {
await opts.sandbox.finalizeExec({
status: outcome.status,
exitCode: exit.exitCode ?? null,
timedOut: exit.timedOut,
token: sandboxFinalizeToken,
});
}
await finalizeSandboxExec({
status: outcome.status,
exitCode: exit.exitCode ?? null,
timedOut: exit.timedOut,
});
emitExecProcessCompleted({
command: opts.command,
mode: usingPty ? "pty" : "child",

View File

@@ -6,6 +6,7 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { OPENCLAW_CLI_ENV_VALUE } from "../infra/openclaw-exec-env.js";
import type { ExecuteNodeHostCommandParams } from "./bash-tools.exec-host-node.types.js";
import type { BashSandboxConfig } from "./bash-tools.shared.js";
import type { ExtensionContext } from "./sessions/index.js";
declare module "../plugins/hook-types.js" {
@@ -516,6 +517,71 @@ describe("exec resolve_exec_env hook wiring", () => {
expect(mocks.spawnInputs).toHaveLength(0);
});
it("preserves hook context when backend sandbox env resolution is deferred", async () => {
const validateWorkdir = vi.fn(async (workdir: string) => workdir);
const buildExecSpec = vi.fn<NonNullable<BashSandboxConfig["buildExecSpec"]>>(
async (params) => ({
argv: ["remote-shell", params.command],
env: {},
stdinMode: "pipe-open" as const,
}),
);
mocks.hookRunner = {
hasHooks: vi.fn(
(hookName: string) => hookName === "resolve_exec_env" || hookName === "before_tool_call",
),
runResolveExecEnv: vi.fn(async () => ({ PLUGIN_SAFE: "yes" })),
runBeforeToolCall: vi.fn(async () => undefined),
};
const tool = createExecTool({
host: "sandbox",
security: "full",
ask: "off",
sandbox: {
containerName: "remote-sandbox-workdir-test",
workspaceDir: process.cwd(),
containerWorkdir: "/remote/workspace",
workdirValidation: "backend",
validateWorkdir,
buildExecSpec,
},
});
const [definition] = toToolDefinitions([tool], {
agentId: "ctx-agent",
sessionKey: "agent:ctx-agent:telegram:chat-2",
channelId: "ctx-channel",
});
const result = await definition.execute(
"call-backend-deferred-env-context",
{
command: "echo ok",
workdir: "/remote/workspace/generated",
},
undefined,
undefined,
testExtensionContext,
);
expect((result.details as { status?: unknown } | undefined)?.status).toBe("completed");
expect(validateWorkdir).toHaveBeenCalledWith("/remote/workspace/generated");
expect(mocks.hookRunner.runBeforeToolCall!).toHaveBeenCalledOnce();
expect(mocks.hookRunner.runResolveExecEnv!).toHaveBeenCalledOnce();
expect(mocks.hookRunner.runResolveExecEnv!.mock.calls[0]?.[0]).toMatchObject({
sessionKey: "agent:ctx-agent:telegram:chat-2",
toolName: "exec",
host: "sandbox",
});
expect(mocks.hookRunner.runResolveExecEnv!.mock.calls[0]?.[1]).toMatchObject({
agentId: "ctx-agent",
sessionKey: "agent:ctx-agent:telegram:chat-2",
channelId: "ctx-channel",
});
expect(buildExecSpec.mock.calls[0]?.[0]?.env).toMatchObject({
PLUGIN_SAFE: "yes",
});
});
it("lets lazy before_tool_call see invalid workdirs before failing unchanged params", async () => {
mocks.hookRunner = {
hasHooks: vi.fn(

View File

@@ -143,6 +143,13 @@ type ResolvedExecEnvPreparedState = {
pluginEnv?: Record<string, string>;
};
const resolvedExecEnvPreparedStates = new WeakMap<ExecToolArgs, ResolvedExecEnvPreparedState>();
type DeferredResolveExecEnvPreparedState = {
hookContext?: HookContext;
};
const deferredResolveExecEnvPreparedStates = new WeakMap<
ExecToolArgs,
DeferredResolveExecEnvPreparedState
>();
type ResolvedExecWorkdirPreparedState = {
host: ExecHost;
inputWorkdir?: string;
@@ -162,6 +169,10 @@ const XML_ARG_VALUE_EXEC_PARAM_KEYS = [
"node",
] as const;
function isExecToolArgsObject(value: unknown): value is ExecToolArgs {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function filterPluginExecEnv(rawEnv: Record<string, string>): Record<string, string> | undefined {
const env: Record<string, string> = {};
for (const [rawKey, value] of Object.entries(rawEnv)) {
@@ -201,6 +212,20 @@ function isResolveExecEnvPrepared(params: ExecToolArgs): boolean {
return Boolean(getResolvedExecEnvPreparedState(params));
}
function markDeferredResolveExecEnvPrepared<T extends ExecToolArgs>(
params: T,
state: DeferredResolveExecEnvPreparedState,
): T {
deferredResolveExecEnvPreparedStates.set(params, state);
return params;
}
function getDeferredResolveExecEnvPreparedState(
params: ExecToolArgs,
): DeferredResolveExecEnvPreparedState | undefined {
return deferredResolveExecEnvPreparedStates.get(params);
}
function markResolvedExecWorkdirPrepared<T extends ExecToolArgs>(
params: T,
state: ResolvedExecWorkdirPreparedState,
@@ -1475,20 +1500,31 @@ export function createExecTool(
if (workdirState?.resolution.kind === "unavailable") {
return params;
}
if (shouldDeferResolveExecEnvUntilWorkdirValidated(params)) {
if (!isExecToolArgsObject(params)) {
return params;
}
if (shouldDeferResolveExecEnvUntilWorkdirValidated(params)) {
return markDeferredResolveExecEnvPrepared(params, {
hookContext: context.hookContext as HookContext | undefined,
});
}
return prepareParamsWithResolvedExecEnv(params, {
hookContext: context.hookContext as HookContext | undefined,
});
},
finalizeBeforeToolCallParams: (params, preparedParams) => {
const execParams = params as ExecToolArgs;
const envState = getResolvedExecEnvPreparedState(preparedParams as ExecToolArgs);
const deferredEnvState = getDeferredResolveExecEnvPreparedState(
preparedParams as ExecToolArgs,
);
const workdirState = getResolvedExecWorkdirPreparedState(preparedParams as ExecToolArgs);
if (!envState && !workdirState) {
if (!envState && !deferredEnvState && !workdirState) {
return params;
}
if (!isExecToolArgsObject(params)) {
return params;
}
const execParams = params;
let host: ExecHost | undefined;
const resolveFinalHost = () => {
host ??= resolveHostForParams(execParams);
@@ -1511,6 +1547,9 @@ export function createExecTool(
if (envState) {
markResolveExecEnvPrepared(execParams, envState);
}
if (deferredEnvState) {
markDeferredResolveExecEnvPrepared(execParams, deferredEnvState);
}
if (workdirState) {
markResolvedExecWorkdirPrepared(execParams, workdirState);
}
@@ -1522,6 +1561,7 @@ export function createExecTool(
XML_ARG_VALUE_EXEC_PARAM_KEYS,
);
const resolveExecEnvPrepared = isResolveExecEnvPrepared(args as ExecToolArgs);
const deferredResolveExecEnvState = getDeferredResolveExecEnvPreparedState(params);
const preparedWorkdirState = getResolvedExecWorkdirPreparedState(params);
const maxOutput = DEFAULT_MAX_OUTPUT;
@@ -1724,7 +1764,9 @@ export function createExecTool(
logInfo(`exec: elevated command ${truncateMiddle(params.command, 120)}`);
}
if (!resolveExecEnvPrepared) {
params = await prepareParamsWithResolvedExecEnv(params);
params = await prepareParamsWithResolvedExecEnv(params, {
hookContext: deferredResolveExecEnvState?.hookContext,
});
}
const inheritedBaseEnv = coerceEnv(process.env);

View File

@@ -53,7 +53,6 @@ export {
runSshSandboxCommand,
shellEscape,
uploadDirectoryToSshTarget,
VALIDATE_REMOTE_WORKDIR_SCRIPT,
} from "./sandbox/ssh.js";
export { sanitizeEnvVars } from "./sandbox/sanitize-env-vars.js";
export { createRemoteShellSandboxFsBridge } from "./sandbox/remote-fs-bridge.js";

View File

@@ -957,6 +957,8 @@ describe("buildStatusReply subagent summary", () => {
OPENAI_API_KEY: undefined,
OPENAI_OAUTH_TOKEN: undefined,
},
skipSessionCleanup: true,
skipHomeCleanup: true,
},
);
});
@@ -1042,6 +1044,8 @@ describe("buildStatusReply subagent summary", () => {
OPENAI_API_KEY: undefined,
OPENAI_OAUTH_TOKEN: undefined,
},
skipSessionCleanup: true,
skipHomeCleanup: true,
},
);
});
@@ -1066,66 +1070,69 @@ describe("buildStatusReply subagent summary", () => {
],
});
await withTempHome(async (dir) => {
saveStatusTestAuthProfile({ dir, profileId: "work", provider: "openai" });
await withTempHome(
async (dir) => {
saveStatusTestAuthProfile({ dir, profileId: "work", provider: "openai" });
const text = await buildStatusText({
cfg: {
...baseCfg,
agents: {
defaults: {
agentRuntime: { id: "codex" },
const text = await buildStatusText({
cfg: {
...baseCfg,
agents: {
defaults: {
agentRuntime: { id: "codex" },
},
},
},
},
sessionEntry: {
sessionId: "sess-status-codex-synthetic-usage",
updatedAt: 0,
authProfileOverride: "work",
},
sessionKey: "agent:main:main",
parentSessionKey: "agent:main:main",
sessionScope: "per-sender",
statusChannel: "mobilechat",
provider: "openai",
model: "gpt-5.5",
contextTokens: 32_000,
resolvedFastMode: false,
resolvedVerboseLevel: "off",
resolvedReasoningLevel: "off",
resolveDefaultThinkingLevel: async () => undefined,
isGroup: false,
defaultGroupActivation: () => "mention",
modelAuthOverride: "oauth",
activeModelAuthOverride: "oauth",
});
const normalized = normalizeTestText(text);
expect(normalized).toContain("Model: openai/gpt-5.5");
expect(normalized).toContain("Runtime: OpenAI Codex");
expect(normalized).toContain("Usage: 5h 91% left");
const providerUsageCall = providerUsageMock.loadProviderUsageSummary.mock.calls.find(
([params]) => params?.providers?.includes("openai"),
);
if (!providerUsageCall) {
throw new Error("expected provider usage summary call for synthetic Codex auth");
}
expect(providerUsageCall[0]).toMatchObject({
timeoutMs: 8000,
providers: ["openai"],
auth: [
{
...expectedCodexRuntimeUsageAuth[0],
authProfileId: "work",
sessionEntry: {
sessionId: "sess-status-codex-synthetic-usage",
updatedAt: 0,
authProfileOverride: "work",
},
],
config: expect.objectContaining({
agents: expect.objectContaining({
defaults: expect.objectContaining({ agentRuntime: { id: "codex" } }),
sessionKey: "agent:main:main",
parentSessionKey: "agent:main:main",
sessionScope: "per-sender",
statusChannel: "mobilechat",
provider: "openai",
model: "gpt-5.5",
contextTokens: 32_000,
resolvedFastMode: false,
resolvedVerboseLevel: "off",
resolvedReasoningLevel: "off",
resolveDefaultThinkingLevel: async () => undefined,
isGroup: false,
defaultGroupActivation: () => "mention",
modelAuthOverride: "oauth",
activeModelAuthOverride: "oauth",
});
const normalized = normalizeTestText(text);
expect(normalized).toContain("Model: openai/gpt-5.5");
expect(normalized).toContain("Runtime: OpenAI Codex");
expect(normalized).toContain("Usage: 5h 91% left");
const providerUsageCall = providerUsageMock.loadProviderUsageSummary.mock.calls.find(
([params]) => params?.providers?.includes("openai"),
);
if (!providerUsageCall) {
throw new Error("expected provider usage summary call for synthetic Codex auth");
}
expect(providerUsageCall[0]).toMatchObject({
timeoutMs: 8000,
providers: ["openai"],
auth: [
{
...expectedCodexRuntimeUsageAuth[0],
authProfileId: "work",
},
],
config: expect.objectContaining({
agents: expect.objectContaining({
defaults: expect.objectContaining({ agentRuntime: { id: "codex" } }),
}),
}),
}),
});
});
});
},
{ skipSessionCleanup: true, skipHomeCleanup: true },
);
});
it("forwards legacy Codex profile providers to Codex synthetic usage", async () => {
@@ -1141,14 +1148,74 @@ describe("buildStatusReply subagent summary", () => {
],
});
await withTempHome(async (dir) => {
saveStatusTestAuthProfile({
dir,
profileId: "openai-codex:legacy",
provider: "openai-codex",
});
await withTempHome(
async (dir) => {
saveStatusTestAuthProfile({
dir,
profileId: "openai-codex:legacy",
provider: "openai-codex",
});
await buildStatusText({
await buildStatusText({
cfg: {
...baseCfg,
agents: {
defaults: {
agentRuntime: { id: "codex" },
},
},
},
sessionEntry: {
sessionId: "sess-status-codex-legacy-profile",
updatedAt: 0,
authProfileOverride: "openai-codex:legacy",
},
sessionKey: "agent:main:main",
parentSessionKey: "agent:main:main",
sessionScope: "per-sender",
statusChannel: "mobilechat",
provider: "openai",
model: "gpt-5.5",
contextTokens: 32_000,
resolvedFastMode: false,
resolvedVerboseLevel: "off",
resolvedReasoningLevel: "off",
resolveDefaultThinkingLevel: async () => undefined,
isGroup: false,
defaultGroupActivation: () => "mention",
modelAuthOverride: "oauth",
activeModelAuthOverride: "oauth",
});
const providerUsageCall = providerUsageMock.loadProviderUsageSummary.mock.calls.find(
([params]) => params?.providers?.includes("openai"),
);
expect(providerUsageCall?.[0]?.auth).toEqual([
{
...expectedCodexRuntimeUsageAuth[0],
authProfileId: "openai-codex:legacy",
},
]);
},
{ skipSessionCleanup: true, skipHomeCleanup: true },
);
});
it("loads Codex synthetic usage when no local OpenAI profile label exists", async () => {
registerStatusCodexHarness();
providerUsageMock.loadProviderUsageSummary.mockResolvedValue({
updatedAt: Date.now(),
providers: [
{
provider: "openai",
displayName: "OpenAI",
windows: [{ label: "5h", usedPercent: 16 }],
},
],
});
await withTempHome(async () => {
const text = await buildStatusText({
cfg: {
...baseCfg,
agents: {
@@ -1158,9 +1225,8 @@ describe("buildStatusReply subagent summary", () => {
},
},
sessionEntry: {
sessionId: "sess-status-codex-legacy-profile",
sessionId: "sess-status-codex-no-profile",
updatedAt: 0,
authProfileOverride: "openai-codex:legacy",
},
sessionKey: "agent:main:main",
parentSessionKey: "agent:main:main",
@@ -1175,19 +1241,13 @@ describe("buildStatusReply subagent summary", () => {
resolveDefaultThinkingLevel: async () => undefined,
isGroup: false,
defaultGroupActivation: () => "mention",
modelAuthOverride: "oauth",
activeModelAuthOverride: "oauth",
});
expect(normalizeTestText(text)).toContain("Usage: 5h 84% left");
const providerUsageCall = providerUsageMock.loadProviderUsageSummary.mock.calls.find(
([params]) => params?.providers?.includes("openai"),
);
expect(providerUsageCall?.[0]?.auth).toEqual([
{
...expectedCodexRuntimeUsageAuth[0],
authProfileId: "openai-codex:legacy",
},
]);
expect(providerUsageCall?.[0]?.auth).toEqual(expectedCodexRuntimeUsageAuth);
});
});
@@ -1204,49 +1264,52 @@ describe("buildStatusReply subagent summary", () => {
],
});
await withTempHome(async (dir) => {
saveStatusTestAuthProfiles({
dir,
profiles: [
{ profileId: "openai:status", provider: "openai" },
{ profileId: "anthropic:work", provider: "anthropic" },
],
});
await withTempHome(
async (dir) => {
saveStatusTestAuthProfiles({
dir,
profiles: [
{ profileId: "openai:status", provider: "openai" },
{ profileId: "anthropic:work", provider: "anthropic" },
],
});
await buildStatusText({
cfg: {
...baseCfg,
agents: {
defaults: {
agentRuntime: { id: "codex" },
await buildStatusText({
cfg: {
...baseCfg,
agents: {
defaults: {
agentRuntime: { id: "codex" },
},
},
},
},
sessionEntry: {
sessionId: "sess-status-codex-stale-profile",
updatedAt: 0,
authProfileOverride: "anthropic:work",
},
sessionKey: "agent:main:main",
parentSessionKey: "agent:main:main",
sessionScope: "per-sender",
statusChannel: "mobilechat",
provider: "openai",
model: "gpt-5.5",
contextTokens: 32_000,
resolvedFastMode: false,
resolvedVerboseLevel: "off",
resolvedReasoningLevel: "off",
resolveDefaultThinkingLevel: async () => undefined,
isGroup: false,
defaultGroupActivation: () => "mention",
});
sessionEntry: {
sessionId: "sess-status-codex-stale-profile",
updatedAt: 0,
authProfileOverride: "anthropic:work",
},
sessionKey: "agent:main:main",
parentSessionKey: "agent:main:main",
sessionScope: "per-sender",
statusChannel: "mobilechat",
provider: "openai",
model: "gpt-5.5",
contextTokens: 32_000,
resolvedFastMode: false,
resolvedVerboseLevel: "off",
resolvedReasoningLevel: "off",
resolveDefaultThinkingLevel: async () => undefined,
isGroup: false,
defaultGroupActivation: () => "mention",
});
const providerUsageCall = providerUsageMock.loadProviderUsageSummary.mock.calls.find(
([params]) => params?.providers?.includes("openai"),
);
expect(providerUsageCall?.[0]?.auth).toEqual(expectedCodexRuntimeUsageAuth);
});
const providerUsageCall = providerUsageMock.loadProviderUsageSummary.mock.calls.find(
([params]) => params?.providers?.includes("openai"),
);
expect(providerUsageCall?.[0]?.auth).toEqual(expectedCodexRuntimeUsageAuth);
},
{ skipSessionCleanup: true, skipHomeCleanup: true },
);
});
it("uses active fallback provider usage for legacy fallback notices", async () => {
@@ -1751,7 +1814,7 @@ describe("buildStatusReply subagent summary", () => {
expect(normalized).toContain("oauth (openai:status)");
expect(normalized).not.toContain("api-key (openai:backup)");
},
{ env: { OPENAI_API_KEY: undefined } },
{ env: { OPENAI_API_KEY: undefined }, skipSessionCleanup: true, skipHomeCleanup: true },
);
});

View File

@@ -277,6 +277,105 @@ describe("channel ingress queue", () => {
});
});
it("refreshes claimed rows only with the active claim token", async () => {
await withTempState(async (stateDir) => {
const queue = createChannelIngressQueue<{ text: string }>({
channelId: "test",
accountId: "account",
stateDir,
now: () => 10,
});
await queue.enqueue("event-1", { text: "claimed" });
const claimed = await queue.claim("event-1", { ownerId: "worker" });
if (!claimed) {
throw new Error("Expected a claimed ingress event");
}
expect(await queue.refreshClaim?.(claimed, { refreshedAt: 20 })).toBe(true);
expect(
(await queue.listClaims()).map((claim) => ({
id: claim.id,
claimedAt: claim.claim.claimedAt,
updatedAt: claim.updatedAt,
})),
).toEqual([{ id: "event-1", claimedAt: 20, updatedAt: 20 }]);
expect(
await queue.refreshClaim?.(
{ id: "event-1", claim: { token: "wrong" } },
{
refreshedAt: 30,
},
),
).toBe(false);
expect((await queue.listClaims())[0]?.claim.claimedAt).toBe(20);
});
});
it("does not let old claim tokens refresh recovered and reclaimed rows", async () => {
await withTempState(async (stateDir) => {
const queue = createChannelIngressQueue<{ text: string }>({
channelId: "test",
accountId: "account",
stateDir,
now: () => 10,
});
await queue.enqueue("event-1", { text: "claimed" });
const oldClaim = await queue.claim("event-1", { ownerId: "worker-1" });
if (!oldClaim) {
throw new Error("Expected a claimed ingress event");
}
expect(await queue.recoverStaleClaims({ staleMs: 5, now: 20 })).toBe(1);
const newClaim = await queue.claim("event-1", { ownerId: "worker-2" });
if (!newClaim) {
throw new Error("Expected reclaimed ingress event");
}
expect(await queue.refreshClaim?.(oldClaim, { refreshedAt: 30 })).toBe(false);
expect(await queue.refreshClaim?.(newClaim, { refreshedAt: 40 })).toBe(true);
expect((await queue.listClaims())[0]?.claim).toMatchObject({
ownerId: "worker-2",
claimedAt: 40,
});
});
});
it("does not recover a claim refreshed after stale recovery snapshots it", async () => {
await withTempState(async (stateDir) => {
const queue = createChannelIngressQueue<{ text: string }>({
channelId: "test",
accountId: "account",
stateDir,
now: () => 10,
});
await queue.enqueue("event-1", { text: "claimed" });
const claimed = await queue.claim("event-1", { ownerId: "worker" });
if (!claimed) {
throw new Error("Expected a claimed ingress event");
}
expect(
await queue.recoverStaleClaims({
staleMs: 5,
now: 20,
shouldRecover: async (claim) => {
expect(claim.id).toBe("event-1");
expect(await queue.refreshClaim?.(claim, { refreshedAt: 20 })).toBe(true);
return true;
},
}),
).toBe(0);
expect((await queue.listPending()).map((record) => record.id)).toEqual([]);
expect((await queue.listClaims())[0]?.claim).toMatchObject({
ownerId: "worker",
claimedAt: 20,
});
});
});
it("recovers stale claims and prunes completed or failed rows", async () => {
await withTempState(async (stateDir) => {
const queue = createChannelIngressQueue<{ text: string }>({

View File

@@ -142,6 +142,10 @@ export type ChannelIngressQueue<TPayload, TMetadata = unknown, TCompletedMetadat
id: string,
options?: { ownerId?: string },
): Promise<ChannelIngressQueueClaim<TPayload, TMetadata> | null>;
refreshClaim?(
claim: ChannelIngressQueueClaimRef,
options?: { refreshedAt?: number },
): Promise<boolean>;
complete(
idOrClaim: string | ChannelIngressQueueClaimRef,
options?: { metadata?: TCompletedMetadata; completedAt?: number },
@@ -440,26 +444,6 @@ export function createChannelIngressQueue<
return rows.map((row) => claimedRecord<TPayload, TMetadata>(row));
};
const recoverStaleClaims: ChannelIngressQueue<
TPayload,
TMetadata,
TCompletedMetadata
>["recoverStaleClaims"] = async (recoverOptions) => {
const staleMs = Math.max(0, Math.floor(recoverOptions?.staleMs ?? 0));
const cutoff = (recoverOptions?.now ?? now()) - staleMs;
const claims = (await listClaims()).filter((claim) => claim.claim.claimedAt <= cutoff);
let recovered = 0;
for (const claim of claims) {
if (recoverOptions?.shouldRecover && !(await recoverOptions.shouldRecover(claim))) {
continue;
}
if (await release(claim, { releasedAt: recoverOptions?.now ?? now() })) {
recovered += 1;
}
}
return recovered;
};
const claimNext: ChannelIngressQueue<
TPayload,
TMetadata,
@@ -561,6 +545,89 @@ export function createChannelIngressQueue<
);
};
const refreshClaim: NonNullable<
ChannelIngressQueue<TPayload, TMetadata, TCompletedMetadata>["refreshClaim"]
> = async (claimRef, refreshOptions) => {
const eventId = idFrom(claimRef);
const refreshedAt = refreshOptions?.refreshedAt ?? now();
const database = openStateDatabase(options.stateDir);
return runOpenClawStateWriteTransaction(
(tx) => {
const kysely = getChannelIngressKysely(tx.db);
const result = executeSqliteQuerySync(
tx.db,
kysely
.updateTable("channel_ingress_events")
.set({
claimed_at: refreshedAt,
updated_at: refreshedAt,
})
.where("queue_name", "=", queueName)
.where("event_id", "=", eventId)
.where("status", "=", "claimed")
.where("claim_token", "=", claimRef.claim.token),
);
return affectedRows(result) > 0;
},
{ path: database.path },
);
};
const releaseClaimIfStillStale = async (
claimRef: ChannelIngressQueueClaimRef,
releaseOptions: { cutoff: number; releasedAt: number },
): Promise<boolean> => {
const eventId = idFrom(claimRef);
const database = openStateDatabase(options.stateDir);
return runOpenClawStateWriteTransaction(
(tx) => {
const kysely = getChannelIngressKysely(tx.db);
const result = executeSqliteQuerySync(
tx.db,
kysely
.updateTable("channel_ingress_events")
.set((eb) => ({
status: "pending",
claim_token: null,
claim_owner: null,
claimed_at: null,
attempts: eb("attempts", "+", 1),
last_attempt_at: releaseOptions.releasedAt,
updated_at: releaseOptions.releasedAt,
}))
.where("queue_name", "=", queueName)
.where("event_id", "=", eventId)
.where("status", "=", "claimed")
.where("claim_token", "=", claimRef.claim.token)
.where("claimed_at", "<=", releaseOptions.cutoff),
);
return affectedRows(result) > 0;
},
{ path: database.path },
);
};
const recoverStaleClaims: ChannelIngressQueue<
TPayload,
TMetadata,
TCompletedMetadata
>["recoverStaleClaims"] = async (recoverOptions) => {
const current = recoverOptions?.now ?? now();
const staleMs = Math.max(0, Math.floor(recoverOptions?.staleMs ?? 0));
const cutoff = current - staleMs;
const staleClaims = (await listClaims()).filter((claimed) => claimed.claim.claimedAt <= cutoff);
let recovered = 0;
for (const staleClaim of staleClaims) {
if (recoverOptions?.shouldRecover && !(await recoverOptions.shouldRecover(staleClaim))) {
continue;
}
if (await releaseClaimIfStillStale(staleClaim, { cutoff, releasedAt: current })) {
recovered += 1;
}
}
return recovered;
};
const complete: ChannelIngressQueue<TPayload, TMetadata, TCompletedMetadata>["complete"] = async (
idOrClaim,
completeOptions,
@@ -845,6 +912,7 @@ export function createChannelIngressQueue<
listClaims,
claimNext,
claim,
refreshClaim,
complete,
release,
fail,

View File

@@ -1075,6 +1075,81 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
expect(inheritedAccount?.config?.token).not.toBe("prototype-token");
});
it("ignores manifest account keys that normalize to blocked object keys", () => {
const { pluginDir } = writeExternalSetupChannelPlugin({
setupEntry: false,
pluginId: "external-chat-plugin",
channelId: "external-chat",
manifestChannelConfig: true,
});
const cfg = {
channels: {
"external-chat": {
accounts: {
"constructor ": {
token: "blocked-token",
},
},
},
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat-plugin"],
},
} as never;
const plugin = listReadOnlyChannelPluginsForConfig(cfg, {
env: { ...process.env },
includePersistedAuthState: false,
}).find((entry) => entry.id === "external-chat");
expect(plugin?.config.listAccountIds(cfg)).toEqual([]);
const account = plugin?.config.resolveAccount(cfg, "default");
const accountFields = expectRecordFields(account, {
accountId: "default",
});
const configFields = expectRecordFields(accountFields.config, {});
expect(configFields.token).toBeUndefined();
});
it("resolves manifest channel account config from raw account keys with opaque provider ids", () => {
const { pluginDir } = writeExternalSetupChannelPlugin({
setupEntry: false,
pluginId: "external-chat-plugin",
channelId: "external-chat",
manifestChannelConfig: true,
});
const cfg = {
channels: {
"external-chat": {
accounts: {
"59000514e8ad@im.bot": {
enabled: true,
baseUrl: "https://ilinkai.weixin.qq.com",
},
},
},
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat-plugin"],
},
} as never;
const plugin = listReadOnlyChannelPluginsForConfig(cfg, {
env: { ...process.env },
includePersistedAuthState: false,
}).find((entry) => entry.id === "external-chat");
expect(plugin?.config.listAccountIds(cfg)).toEqual(["59000514e8ad-im-bot"]);
const account = plugin?.config.resolveAccount(cfg, "59000514e8ad-im-bot");
const fields = expectRecordFields(account, {
accountId: "59000514e8ad-im-bot",
});
expectRecordFields(fields.config, {
enabled: true,
baseUrl: "https://ilinkai.weixin.qq.com",
});
});
it("keeps setup-entry precedence when channel config descriptors are not runtime cutoffs", () => {
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({
pluginId: "external-chat-plugin",

View File

@@ -35,7 +35,12 @@ import {
type PluginModuleLoaderCache,
} from "../../plugins/plugin-module-loader-cache.js";
import { getActivePluginChannelRegistryVersion } from "../../plugins/runtime.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import { resolveNormalizedAccountEntry } from "../../routing/account-lookup.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "../../routing/session-key.js";
import { getBundledChannelSetupPlugin } from "./bundled.js";
import {
isSafeManifestChannelId,
@@ -349,6 +354,10 @@ function getChannelConfigRecord(cfg: OpenClawConfig, channelId: string): Record<
: {};
}
function normalizeManifestAccountConfigKey(accountId: string): string {
return normalizeOptionalAccountId(accountId) ?? "";
}
function listManifestChannelAccountIds(cfg: OpenClawConfig, channelId: string): string[] {
const channelConfig = getChannelConfigRecord(cfg, channelId);
const accounts = channelConfig.accounts;
@@ -356,8 +365,8 @@ function listManifestChannelAccountIds(cfg: OpenClawConfig, channelId: string):
return sortUniqueStrings(
Object.keys(accounts)
.filter((accountId) => !isBlockedObjectKey(accountId))
.map((accountId) => normalizeAccountId(accountId))
.filter((accountId) => !isBlockedObjectKey(accountId)),
.map((accountId) => normalizeOptionalAccountId(accountId))
.filter((accountId): accountId is string => Boolean(accountId)),
);
}
return hasExplicitChannelConfig({ config: cfg, channelId }) ? [DEFAULT_ACCOUNT_ID] : [];
@@ -372,9 +381,10 @@ function resolveManifestChannelAccountConfig(params: {
const resolvedAccountId = normalizeAccountId(params.accountId);
const accounts = channelConfig.accounts;
if (accounts && typeof accounts === "object" && !Array.isArray(accounts)) {
const accountConfig = readOwnRecordValue(
const accountConfig = resolveNormalizedAccountEntry(
accounts as Record<string, unknown>,
resolvedAccountId,
normalizeManifestAccountConfigKey,
);
if (accountConfig && typeof accountConfig === "object" && !Array.isArray(accountConfig)) {
return accountConfig as Record<string, unknown>;

View File

@@ -112,6 +112,7 @@ describe("registerMaintenanceCommands doctor action", () => {
"--json",
"--severity-min",
"error",
"--all",
"--skip",
"a",
"--only",
@@ -123,6 +124,7 @@ describe("registerMaintenanceCommands doctor action", () => {
expect(runDoctorLintCli).toHaveBeenCalledWith(runtime, {
json: true,
severityMin: "error",
includeAllChecks: true,
skipIds: ["a"],
onlyIds: ["b"],
allowExec: true,
@@ -141,6 +143,17 @@ describe("registerMaintenanceCommands doctor action", () => {
expect(runtime.exit).toHaveBeenCalledWith(2);
});
it("rejects --all outside doctor lint mode", async () => {
await runMaintenanceCli(["doctor", "--all"]);
expect(doctorCommand).not.toHaveBeenCalled();
expect(runDoctorLintCli).not.toHaveBeenCalled();
expect(runtime.error).toHaveBeenCalledWith(
"doctor lint options require --lint. Use `openclaw doctor --lint ...`.",
);
expect(runtime.exit).toHaveBeenCalledWith(2);
});
it("exits with code 2 when doctor lint mode fails before findings are emitted", async () => {
runDoctorLintCli.mockRejectedValue(new Error("lint failed"));

View File

@@ -39,6 +39,7 @@ export function registerMaintenanceCommands(program: Command) {
"--severity-min <level>",
"With --lint: drop findings below this severity (info|warning|error)",
)
.option("--all", "With --lint: run all registered checks, including opt-in checks", false)
.option(
"--skip <id>",
"With --lint: skip a specific check id (repeatable)",
@@ -60,6 +61,7 @@ export function registerMaintenanceCommands(program: Command) {
const exitCode = await runDoctorLintCli(defaultRuntime, {
json: Boolean(opts.json),
severityMin: typeof opts.severityMin === "string" ? opts.severityMin : undefined,
includeAllChecks: Boolean(opts.all),
skipIds: Array.isArray(opts.skip) ? opts.skip : [],
onlyIds: Array.isArray(opts.only) ? opts.only : [],
allowExec: Boolean(opts.allowExec),
@@ -180,12 +182,14 @@ function hasLintOnlyDoctorOptions(opts: {
readonly json?: boolean;
readonly postUpgrade?: boolean;
readonly severityMin?: unknown;
readonly all?: boolean;
readonly skip?: unknown;
readonly only?: unknown;
}): boolean {
return (
(opts.json === true && opts.postUpgrade !== true) ||
typeof opts.severityMin === "string" ||
opts.all === true ||
(Array.isArray(opts.skip) && opts.skip.length > 0) ||
(Array.isArray(opts.only) && opts.only.length > 0)
);

View File

@@ -26,6 +26,7 @@ interface DoctorLintCliOptions {
readonly onlyIds?: readonly string[];
readonly allowExec?: boolean;
readonly deep?: boolean;
readonly includeAllChecks?: boolean;
}
function detectMode(opts: DoctorLintCliOptions): "human" | "json" {
@@ -86,6 +87,7 @@ export async function runDoctorLintCli(
const runOpts: DoctorLintRunOptions = {
checks: [...coreChecks.map((check) => withCoreLintContext(check, coreCtx)), ...extensionChecks],
includeAllChecks: opts.includeAllChecks === true,
...(opts.skipIds && opts.skipIds.length > 0 ? { skipIds: opts.skipIds } : {}),
...(opts.onlyIds && opts.onlyIds.length > 0 ? { onlyIds: opts.onlyIds } : {}),
};

View File

@@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({
getDaemonStatusSummary: vi.fn(),
getNodeDaemonStatusSummary: vi.fn(),
resolveReadOnlyChannelPluginsForConfig: vi.fn(),
resolveModelAuthLabel: vi.fn(),
}));
vi.mock("../channels/plugins/read-only.js", () => ({
@@ -28,6 +29,10 @@ vi.mock("../infra/provider-usage.js", () => ({
loadProviderUsageSummary: mocks.loadProviderUsageSummary,
}));
vi.mock("../agents/model-auth-label.js", () => ({
resolveModelAuthLabel: mocks.resolveModelAuthLabel,
}));
vi.mock("../security/audit.runtime.js", () => ({
runSecurityAudit: mocks.runSecurityAudit,
}));
@@ -45,6 +50,8 @@ function requireProviderUsageCall(): {
timeoutMs?: number;
config?: unknown;
agentDir?: string;
providers?: string[];
auth?: Array<Record<string, unknown>>;
} {
const call = mocks.loadProviderUsageSummary.mock.calls[0];
if (!call) {
@@ -58,6 +65,8 @@ function requireProviderUsageCall(): {
timeoutMs?: number;
config?: unknown;
agentDir?: string;
providers?: string[];
auth?: Array<Record<string, unknown>>;
};
}
@@ -69,6 +78,7 @@ describe("status-runtime-shared", () => {
mocks.callGateway.mockResolvedValue({ ok: true });
mocks.getDaemonStatusSummary.mockResolvedValue({ label: "LaunchAgent" });
mocks.getNodeDaemonStatusSummary.mockResolvedValue({ label: "node" });
mocks.resolveModelAuthLabel.mockReturnValue(undefined);
mocks.resolveReadOnlyChannelPluginsForConfig.mockReturnValue({
plugins: [{ id: "telegram" }],
configuredChannelIds: ["telegram"],
@@ -134,6 +144,176 @@ describe("status-runtime-shared", () => {
expect(usageCall.agentDir).toContain("main");
});
it("adds Codex synthetic usage for configured OpenAI Codex runtime routes without profiles", async () => {
mocks.loadProviderUsageSummary
.mockResolvedValueOnce({
updatedAt: 1,
providers: [
{
provider: "anthropic",
displayName: "Claude",
windows: [],
error: "HTTP 429",
},
],
})
.mockResolvedValueOnce({
updatedAt: 2,
providers: [
{
provider: "openai",
displayName: "OpenAI",
windows: [{ label: "5h", usedPercent: 9 }],
},
],
});
await expect(
resolveStatusUsageSummary({
timeoutMs: 3456,
config: {
agents: {
defaults: {
model: { primary: "openai/gpt-5.5" },
models: {
"openai/gpt-5.5": { agentRuntime: { id: "codex" } },
},
},
},
},
agentDir: "/tmp/status-agent",
}),
).resolves.toEqual({
updatedAt: 1,
providers: [
{
provider: "anthropic",
displayName: "Claude",
windows: [],
error: "HTTP 429",
},
{
provider: "openai",
displayName: "OpenAI",
windows: [{ label: "5h", usedPercent: 9 }],
},
],
});
expect(mocks.loadProviderUsageSummary).toHaveBeenNthCalledWith(2, {
timeoutMs: 3456,
providers: ["openai"],
auth: [
{
provider: "openai",
token: "codex-app-server",
hookProvider: "codex",
},
],
config: expect.any(Object),
agentDir: "/tmp/status-agent",
});
});
it("keeps existing OpenAI usage when Codex synthetic usage has no windows", async () => {
mocks.loadProviderUsageSummary
.mockResolvedValueOnce({
updatedAt: 1,
providers: [
{
provider: "openai",
displayName: "OpenAI",
windows: [{ label: "5h", usedPercent: 22 }],
},
],
})
.mockResolvedValueOnce({
updatedAt: 2,
providers: [
{
provider: "openai",
displayName: "OpenAI",
windows: [],
},
],
});
await expect(
resolveStatusUsageSummary({
timeoutMs: 3456,
config: {
agents: {
defaults: {
model: { primary: "openai/gpt-5.5" },
models: {
"openai/gpt-5.5": { agentRuntime: { id: "codex" } },
},
},
},
},
agentDir: "/tmp/status-agent",
}),
).resolves.toEqual({
updatedAt: 1,
providers: [
{
provider: "openai",
displayName: "OpenAI",
windows: [{ label: "5h", usedPercent: 22 }],
},
],
});
});
it("does not add Codex synthetic usage for OpenAI routes pinned to OpenClaw runtime", async () => {
await resolveStatusUsageSummary({
timeoutMs: 3456,
config: {
agents: {
defaults: {
model: { primary: "openai/gpt-5.5" },
models: {
"openai/gpt-5.5": { agentRuntime: { id: "openclaw" } },
},
},
},
},
agentDir: "/tmp/status-agent",
});
expect(mocks.loadProviderUsageSummary).toHaveBeenCalledOnce();
expect(requireProviderUsageCall()).not.toHaveProperty("auth");
});
it("does not add Codex synthetic usage for API-key-backed OpenAI Codex runtime routes", async () => {
mocks.resolveModelAuthLabel.mockReturnValue("api-key (openai:api)");
await resolveStatusUsageSummary({
timeoutMs: 3456,
config: {
agents: {
defaults: {
model: { primary: "openai/gpt-5.5" },
models: {
"openai/gpt-5.5": { agentRuntime: { id: "codex" } },
},
},
},
},
agentDir: "/tmp/status-agent",
});
expect(mocks.loadProviderUsageSummary).toHaveBeenCalledOnce();
expect(requireProviderUsageCall()).not.toHaveProperty("auth");
expect(mocks.resolveModelAuthLabel).toHaveBeenCalledWith({
provider: "openai",
acceptedProviderIds: ["openai"],
cfg: expect.any(Object),
agentDir: "/tmp/status-agent",
includeExternalProfiles: false,
});
});
it("resolves usage summaries with explicit agent scope", async () => {
await resolveStatusUsageSummary({
timeoutMs: 2345,

View File

@@ -1,10 +1,20 @@
// Shared runtime probes used by status text and JSON commands.
// Heavy modules stay lazily loaded so fast status output avoids security/provider/gateway costs.
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import { resolveDefaultAgentDir } from "../agents/agent-scope.js";
import { resolveAgentHarnessPolicy } from "../agents/harness/policy.js";
import { resolveModelAuthLabel } from "../agents/model-auth-label.js";
import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
import { listOpenAIAuthProfileProvidersForAgentRuntime } from "../agents/openai-routing.js";
import type { OpenClawConfig } from "../config/types.js";
import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js";
import { createLazyImportLoader } from "../shared/lazy-promise.js";
import {
buildCodexSyntheticUsageAuth,
mergeUsageSummaries,
shouldUseCodexSyntheticUsageForRuntime,
} from "../status/codex-synthetic-usage.js";
import type { HealthSummary } from "./health.js";
import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js";
@@ -33,6 +43,58 @@ function loadGatewayCallModule() {
return gatewayCallModuleLoader.load();
}
function resolveUsageCredentialType(authLabel?: string): "oauth" | "token" | "api_key" | undefined {
const auth = normalizeOptionalLowercaseString(authLabel);
if (!auth) {
return undefined;
}
if (auth.startsWith("oauth")) {
return "oauth";
}
if (auth.startsWith("token")) {
return "token";
}
if (auth.startsWith("api-key") || auth.startsWith("api key")) {
return "api_key";
}
return undefined;
}
function shouldUseConfiguredCodexSyntheticUsage(params: {
config: OpenClawConfig;
agentDir: string;
}): boolean {
const configuredDefault = resolveDefaultModelForAgent({
cfg: params.config,
allowPluginNormalization: false,
});
const policy = resolveAgentHarnessPolicy({
config: params.config,
provider: configuredDefault.provider,
modelId: configuredDefault.model,
});
if (
!shouldUseCodexSyntheticUsageForRuntime({
provider: configuredDefault.provider,
effectiveHarness: policy.runtime,
})
) {
return false;
}
const authLabel = resolveModelAuthLabel({
provider: configuredDefault.provider,
acceptedProviderIds: listOpenAIAuthProfileProvidersForAgentRuntime({
provider: configuredDefault.provider,
harnessRuntime: policy.runtime,
config: params.config,
}),
cfg: params.config,
agentDir: params.agentDir,
includeExternalProfiles: false,
});
return resolveUsageCredentialType(authLabel) !== "api_key";
}
/** Runs the lightweight security audit used by status JSON/all output. */
export async function resolveStatusSecurityAudit(params: {
config: OpenClawConfig;
@@ -69,11 +131,23 @@ type StatusUsageSummaryOptions = {
/** Loads provider usage for status output, defaulting to the config's default agent directory. */
export async function resolveStatusUsageSummary(params: StatusUsageSummaryOptions) {
const { loadProviderUsageSummary } = await loadProviderUsage();
return await loadProviderUsageSummary({
const agentDir = params.agentDir ?? resolveDefaultAgentDir(params.config);
const usage = await loadProviderUsageSummary({
timeoutMs: params.timeoutMs,
config: params.config,
agentDir: params.agentDir ?? resolveDefaultAgentDir(params.config),
agentDir,
});
if (!shouldUseConfiguredCodexSyntheticUsage({ config: params.config, agentDir })) {
return usage;
}
const codexUsage = await loadProviderUsageSummary({
timeoutMs: params.timeoutMs,
providers: ["openai"],
auth: [buildCodexSyntheticUsageAuth()],
config: params.config,
agentDir,
});
return mergeUsageSummaries(usage, codexUsage);
}
/** Exposes the lazily loaded provider-usage module for callers that need its helpers. */

View File

@@ -1,6 +1,6 @@
// Direct delivery tests cover isolated agent delivery through core channel targets.
import "./isolated-agent.mocks.js";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
import type { ChannelOutboundAdapter, ChannelOutboundContext } from "../channels/plugins/types.js";
import type { CliDeps } from "../cli/deps.js";
@@ -455,6 +455,55 @@ describe("runCronIsolatedAgentTurn telegram forum-topic direct delivery", () =>
});
});
it("does not report delivered when telegram announce produces no platform result", async () => {
await withTempCronHome(async (home) => {
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
const sendText = vi.fn(async () => ({ channel: "telegram", messageId: "" }));
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
plugin: createOutboundTestPlugin({
id: "telegram",
outbound: {
deliveryMode: "direct",
preferFinalAssistantVisibleText: true,
sendText,
resolveTarget: ({ to }) =>
to?.trim()
? { ok: true, to: to.trim() }
: { ok: false, error: new Error("target is required") },
},
messaging: {
parseExplicitTarget: ({ raw }) => ({ to: raw.trim() }),
},
}),
source: "test",
},
]),
);
const deps = createCliDeps();
mockAgentPayloads([{ text: "cron message with no platform receipt" }]);
const res = await runTelegramAnnounceTurn({
home,
storePath,
deps,
delivery: { mode: "announce", channel: "telegram", to: "123" },
});
expect(res.status).toBe("ok");
expect(res.delivered).toBe(false);
expect(res.deliveryAttempted).toBe(true);
expect(res.delivery).toMatchObject({
fallbackUsed: true,
delivered: false,
});
expect(sendText).toHaveBeenCalledTimes(1);
expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
});
});
it("delivers only the final assistant-visible text to forum-topic telegram targets", async () => {
await expectTelegramAnnounceDelivery({
to: "123:topic:42",

View File

@@ -69,6 +69,27 @@ describe("runDoctorLintChecks", () => {
});
});
it("runs default-disabled checks when all checks are requested", async () => {
const defaultDisabled = normalizeHealthCheck({
...check("targeted", async () => [
{ checkId: "targeted", severity: "warning" as const, message: "warn" },
]),
defaultEnabled: false,
});
const defaultEnabled = check("regular", async () => []);
const result = await runDoctorLintChecks(ctx, {
checks: [defaultDisabled, defaultEnabled],
includeAllChecks: true,
});
expect(result).toMatchObject({
checksRun: 2,
checksSkipped: 0,
findings: [expect.objectContaining({ checkId: "targeted" })],
});
});
it("supports single-run checks in lint mode", async () => {
const runnable: RunnableHealthCheck = {
id: "run-check",

View File

@@ -15,6 +15,7 @@ export interface DoctorLintRunOptions {
readonly checks?: readonly HealthCheck[];
readonly skipIds?: ReadonlySet<string> | readonly string[];
readonly onlyIds?: ReadonlySet<string> | readonly string[];
readonly includeAllChecks?: boolean;
}
export interface DoctorLintRunResult {
@@ -32,12 +33,13 @@ export async function runDoctorLintChecks(
const skip = opts.skipIds instanceof Set ? opts.skipIds : new Set(opts.skipIds ?? []);
const only = opts.onlyIds instanceof Set ? opts.onlyIds : new Set(opts.onlyIds ?? []);
const allIds = new Set(all.map((check) => check.id));
const includeDefaultDisabled = opts.includeAllChecks === true;
const selected = all.filter((c) => {
if (only.size > 0 && !only.has(c.id)) {
return false;
}
if (only.size === 0 && isDefaultDisabled(c)) {
if (only.size === 0 && !includeDefaultDisabled && isDefaultDisabled(c)) {
return false;
}
if (skip.has(c.id)) {

View File

@@ -3598,6 +3598,54 @@ describe("deliverOutboundPayloads", () => {
expect(mocks.appendAssistantMessageToSessionTranscript).not.toHaveBeenCalled();
});
it("does not reuse a previous payload message id for a suppressed text send", async () => {
hookMocks.runner.hasHooks.mockReturnValue(true);
const sendText = vi
.fn()
.mockResolvedValueOnce({ channel: "matrix", messageId: "mx-1" })
.mockResolvedValueOnce({ channel: "matrix", messageId: "" });
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "matrix",
source: "test",
plugin: createOutboundTestPlugin({
id: "matrix",
outbound: { deliveryMode: "direct", sendText },
}),
},
]),
);
const results = await deliverOutboundPayloads({
cfg: {},
channel: "matrix",
to: "!room:1",
payloads: [{ text: "first" }, { text: "second" }],
});
expect(results).toStrictEqual([{ channel: "matrix", messageId: "mx-1" }]);
expect(hookMocks.runner.runMessageSent).toHaveBeenCalledTimes(2);
expect(hookMocks.runner.runMessageSent).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
content: "first",
success: true,
messageId: "mx-1",
}),
expect.objectContaining({ channelId: "matrix" }),
);
expect(hookMocks.runner.runMessageSent).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
content: "second",
success: false,
}),
expect.objectContaining({ channelId: "matrix" }),
);
expect(hookMocks.runner.runMessageSent.mock.calls[1]?.[0]).not.toHaveProperty("messageId");
});
it("emits message_sent success for sendPayload deliveries", async () => {
hookMocks.runner.hasHooks.mockReturnValue(true);
const sendPayload = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "mx-1" });

View File

@@ -868,6 +868,23 @@ function hasDeliveryResultIdentity(result: OutboundDeliveryResult): boolean {
);
}
function pushIdentifiedDeliveryResult(
results: OutboundDeliveryResult[],
delivery: OutboundDeliveryResult,
): boolean {
if (!hasDeliveryResultIdentity(delivery)) {
return false;
}
results.push(delivery);
return true;
}
function filterIdentifiedDeliveryResults(
results: readonly OutboundDeliveryResult[],
): OutboundDeliveryResult[] {
return results.filter((result) => hasDeliveryResultIdentity(result));
}
function normalizeDeliveryPin(payload: ReplyPayload): ReplyPayloadDeliveryPin | undefined {
const pin = payload.delivery?.pin;
if (pin === true) {
@@ -1529,7 +1546,7 @@ async function deliverOutboundPayloadsCore(
continue;
}
throwIfAborted(abortSignal);
results.push(await sendHandler.sendText(unit.text, unit.overrides));
pushIdentifiedDeliveryResult(results, await sendHandler.sendText(unit.text, unit.overrides));
}
};
const normalizedPayloads = normalizePayloadsForChannelDelivery(outboundPayloadPlan, handler);
@@ -1783,10 +1800,12 @@ async function deliverOutboundPayloadsCore(
const beforeCount = results.length;
if (deliveryHandler.sendFormattedText) {
results.push(
...(await deliveryHandler.sendFormattedText(
payloadSummary.text,
applySendReplyToConsumption(sendOverrides),
)),
...filterIdentifiedDeliveryResults(
await deliveryHandler.sendFormattedText(
payloadSummary.text,
applySendReplyToConsumption(sendOverrides),
),
),
);
} else {
await sendTextChunks(deliveryHandler, payloadSummary.text, sendOverrides);
@@ -1807,7 +1826,7 @@ async function deliverOutboundPayloadsCore(
}),
);
}
const messageId = results.at(-1)?.messageId;
const messageId = deliveredResults.at(-1)?.messageId;
const pinMessageId = deliveredResults.find((entry) => entry.messageId)?.messageId;
await maybePinDeliveredMessage({
handler: deliveryHandler,
@@ -1824,7 +1843,7 @@ async function deliverOutboundPayloadsCore(
});
completeDeliveryDiagnostics(deliveredResults.length);
emitMessageSent({
success: results.length > beforeCount,
success: deliveredResults.length > 0,
content: payloadSummary.hookContent ?? payloadSummary.text,
messageId,
});
@@ -1864,7 +1883,7 @@ async function deliverOutboundPayloadsCore(
}),
);
}
const messageId = results.at(-1)?.messageId;
const messageId = deliveredResults.at(-1)?.messageId;
const pinMessageId = deliveredResults.find((entry) => entry.messageId)?.messageId;
await maybePinDeliveredMessage({
handler: deliveryHandler,
@@ -1881,7 +1900,7 @@ async function deliverOutboundPayloadsCore(
});
completeDeliveryDiagnostics(deliveredResults.length);
emitMessageSent({
success: results.length > beforeCount,
success: deliveredResults.length > 0,
content: payloadSummary.hookContent ?? payloadSummary.text,
messageId,
});
@@ -1909,9 +1928,10 @@ async function deliverOutboundPayloadsCore(
unit.overrides,
)
: await deliveryHandler.sendMedia(unit.caption ?? "", unit.mediaUrl, unit.overrides);
results.push(delivery);
firstMessageId ??= delivery.messageId;
lastMessageId = delivery.messageId;
if (pushIdentifiedDeliveryResult(results, delivery)) {
firstMessageId ??= delivery.messageId;
lastMessageId = delivery.messageId;
}
}
await maybePinDeliveredMessage({
handler: deliveryHandler,
@@ -1944,7 +1964,7 @@ async function deliverOutboundPayloadsCore(
}
completeDeliveryDiagnostics(results.length - beforeCount);
emitMessageSent({
success: true,
success: results.length > beforeCount,
content: payloadSummary.hookContent ?? payloadSummary.text,
messageId: lastMessageId,
});

View File

@@ -1902,7 +1902,7 @@ describe("updateNpmInstalledPlugins", () => {
});
});
it("falls through to npm reinstall when metadata probing fails", async () => {
it("falls through to npm reinstall when metadata probing fails for valid specs", async () => {
const warn = vi.fn();
const installPath = createInstalledPackageDir({
name: "@martian-engineering/lossless-claw",
@@ -1937,6 +1937,107 @@ describe("updateNpmInstalledPlugins", () => {
expect(installPluginFromNpmSpecMock).toHaveBeenCalledTimes(1);
});
it("records range metadata probing failures without falling through to npm reinstall", async () => {
const warn = vi.fn();
const installPath = createInstalledPackageDir({
name: "@martian-engineering/lossless-claw",
version: "0.9.0",
});
runCommandWithTimeoutMock.mockResolvedValueOnce({
code: 1,
stdout: "",
stderr: "registry timeout",
});
const result = await updateNpmInstalledPlugins({
config: createNpmInstallConfig({
pluginId: "lossless-claw",
spec: "@martian-engineering/lossless-claw@^0.9.0",
installPath,
}),
pluginIds: ["lossless-claw"],
logger: { warn },
});
expect(warn).not.toHaveBeenCalled();
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
expect(result.changed).toBe(false);
expect(result.outcomes).toEqual([
{
pluginId: "lossless-claw",
status: "error",
message: "Failed to check lossless-claw: npm view failed: registry timeout",
},
]);
});
it("uses failure cleanup when metadata probing fails and disableOnFailure is enabled", async () => {
const warn = vi.fn();
const installPath = createInstalledPackageDir({
name: "@martian-engineering/lossless-claw",
version: "0.9.0",
});
runCommandWithTimeoutMock.mockResolvedValueOnce({
code: 1,
stdout: "",
stderr: "registry timeout",
});
const result = await updateNpmInstalledPlugins({
config: {
plugins: {
allow: ["lossless-claw", "keep"],
deny: ["lossless-claw", "blocked"],
slots: {
memory: "lossless-claw",
contextEngine: "lossless-claw",
},
entries: {
"lossless-claw": {
enabled: true,
config: { preserved: true },
},
},
installs: {
"lossless-claw": {
source: "npm",
spec: "@martian-engineering/lossless-claw@^0.9.0",
installPath,
resolvedName: "@martian-engineering/lossless-claw",
resolvedVersion: "0.9.0",
resolvedSpec: "@martian-engineering/lossless-claw@0.9.0",
},
},
},
},
pluginIds: ["lossless-claw"],
disableOnFailure: true,
logger: { warn },
});
const message =
'Disabled "lossless-claw" after plugin update failure; OpenClaw will continue without it. Failed to check lossless-claw: npm view failed: registry timeout';
expect(warn).toHaveBeenCalledWith(message);
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
expect(result.changed).toBe(true);
expect(result.config.plugins?.entries?.["lossless-claw"]).toEqual({
enabled: false,
config: { preserved: true },
});
expect(result.config.plugins?.allow).toEqual(["keep"]);
expect(result.config.plugins?.deny).toEqual(["blocked"]);
expect(result.config.plugins?.slots).toEqual({
memory: "memory-core",
contextEngine: "legacy",
});
expect(result.outcomes).toEqual([
{
pluginId: "lossless-claw",
status: "skipped",
message,
},
]);
});
it.each([
{
source: "npm",
@@ -3864,6 +3965,7 @@ describe("updateNpmInstalledPlugins", () => {
it("reuses the recorded managed extensions root when updating external plugins", async () => {
const installPath = "/var/openclaw/extensions/demo";
const extensionsDir = "/var/openclaw/extensions";
const expectedExtensionsDir = path.resolve(extensionsDir);
installPluginFromNpmSpecMock.mockResolvedValue(
createSuccessfulNpmUpdateResult({
pluginId: "demo",
@@ -3947,7 +4049,6 @@ describe("updateNpmInstalledPlugins", () => {
pluginIds: ["demo"],
});
const expectedExtensionsDir = path.resolve(extensionsDir);
expect(npmInstallCall()?.extensionsDir).toBe(expectedExtensionsDir);
expect(clawHubInstallCall()?.extensionsDir).toBe(expectedExtensionsDir);
expect(marketplaceInstallCall()?.extensionsDir).toBe(expectedExtensionsDir);

View File

@@ -493,7 +493,7 @@ function resolveRecordedExtensionsDir(params: {
const parentDir = path.dirname(params.installPath);
try {
const canonicalInstallPath = resolvePluginInstallDir(params.pluginId, parentDir);
return canonicalInstallPath === params.installPath ? parentDir : undefined;
return pathsEqual(canonicalInstallPath, params.installPath) ? parentDir : undefined;
} catch {
return undefined;
}
@@ -1584,6 +1584,10 @@ export async function updateNpmInstalledPlugins(params: {
continue;
}
} else {
if (!parseRegistryNpmSpec(effectiveSpec!)) {
recordFailure(pluginId, `Failed to check ${pluginId}: ${metadataResult.error}`);
continue;
}
logger.warn?.(
`Could not check ${pluginId} before update; falling back to installer path: ${metadataResult.error}`,
);

View File

@@ -1,5 +1,6 @@
// Account lookup tests cover account matching by id, alias, and chat metadata.
import { describe, expect, it } from "vitest";
import { normalizeAccountId as normalizeRoutingAccountId } from "./account-id.js";
import { resolveAccountEntry, resolveNormalizedAccountEntry } from "./account-lookup.js";
function createAccountsWithPrototypePollution() {
@@ -75,6 +76,33 @@ describe("resolveNormalizedAccountEntry", () => {
id: "ops",
},
},
{
name: "does not resolve blocked raw keys as the default account",
accounts: JSON.parse('{"__proto__":{"id":"blocked"}}') as Record<string, { id: string }>,
resolve: (accounts: Record<string, { id: string }>) =>
resolveNormalizedAccountEntry(accounts, "default", normalizeRoutingAccountId),
expected: undefined,
},
{
name: "does not resolve keys that normalize to blocked object keys",
accounts: {
"constructor ": { id: "blocked" },
} as Record<string, { id: string }>,
resolve: (accounts: Record<string, { id: string }>) =>
resolveNormalizedAccountEntry(accounts, "constructor", (accountId) =>
accountId.trim().toLowerCase(),
),
expected: undefined,
},
{
name: "does not resolve invalid raw keys through the default account fallback",
accounts: {
"constructor ": { id: "blocked" },
} as Record<string, { id: string }>,
resolve: (accounts: Record<string, { id: string }>) =>
resolveNormalizedAccountEntry(accounts, "default", normalizeRoutingAccountId),
expected: undefined,
},
{
name: "ignores prototype-chain values",
resolve: () => undefined,

View File

@@ -1,5 +1,7 @@
// Account lookup helpers resolve route accounts from normalized account ids.
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
import { normalizeOptionalAccountId } from "./account-id.js";
// Case-insensitive account lookup for config maps that may preserve user
// casing. Exact keys win so callers can still distinguish intentional entries.
@@ -30,10 +32,20 @@ export function resolveNormalizedAccountEntry<T>(
if (!accounts || typeof accounts !== "object") {
return undefined;
}
if (Object.hasOwn(accounts, accountId)) {
if (Object.hasOwn(accounts, accountId) && !isBlockedObjectKey(accountId)) {
return accounts[accountId];
}
const normalized = normalizeAccountId(accountId);
const matchKey = Object.keys(accounts).find((key) => normalizeAccountId(key) === normalized);
const matchKey = Object.keys(accounts).find((key) => {
if (isBlockedObjectKey(key)) {
return false;
}
const candidate = normalizeAccountId(key);
return (
Boolean(normalizeOptionalAccountId(key)) &&
!isBlockedObjectKey(candidate) &&
candidate === normalized
);
});
return matchKey ? accounts[matchKey] : undefined;
}

View File

@@ -0,0 +1,63 @@
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import { CODEX_APP_SERVER_AUTH_MARKER } from "../agents/model-auth-markers.js";
import type { ProviderAuth } from "../infra/provider-usage.auth.js";
import type { ProviderUsageSnapshot, UsageSummary } from "../infra/provider-usage.types.js";
export const CODEX_SYNTHETIC_USAGE_PROVIDER = "openai";
export const CODEX_SYNTHETIC_USAGE_HOOK_PROVIDER = "codex";
export function buildCodexSyntheticUsageAuth(
params: {
authProfileId?: string;
} = {},
): ProviderAuth {
return {
provider: CODEX_SYNTHETIC_USAGE_PROVIDER,
token: CODEX_APP_SERVER_AUTH_MARKER,
...(params.authProfileId ? { authProfileId: params.authProfileId } : {}),
hookProvider: CODEX_SYNTHETIC_USAGE_HOOK_PROVIDER,
};
}
export function shouldUseCodexSyntheticUsageForRuntime(params: {
provider?: string;
effectiveHarness?: string;
}): boolean {
const harness = normalizeOptionalLowercaseString(params.effectiveHarness);
const provider = normalizeOptionalLowercaseString(params.provider);
return (
harness === CODEX_SYNTHETIC_USAGE_HOOK_PROVIDER &&
(provider === CODEX_SYNTHETIC_USAGE_PROVIDER || provider === "codex")
);
}
function hasDisplayableUsageSnapshot(snapshot: ProviderUsageSnapshot): boolean {
return snapshot.windows.length > 0 || Boolean(snapshot.summary?.trim());
}
function usageSnapshotRank(snapshot: ProviderUsageSnapshot): number {
if (hasDisplayableUsageSnapshot(snapshot)) {
return 2;
}
return snapshot.error ? 0 : 1;
}
export function mergeUsageSummaries(
base: UsageSummary,
extra: UsageSummary | undefined,
): UsageSummary {
if (!extra || extra.providers.length === 0) {
return base;
}
const providersById = new Map(base.providers.map((provider) => [provider.provider, provider]));
for (const provider of extra.providers) {
const existing = providersById.get(provider.provider);
if (!existing || usageSnapshotRank(provider) >= usageSnapshotRank(existing)) {
providersById.set(provider.provider, provider);
}
}
return {
updatedAt: base.updatedAt,
providers: [...providersById.values()],
};
}

View File

@@ -13,7 +13,6 @@ import { ensureAuthProfileStore } from "../agents/auth-profiles/store.js";
import { resolveContextTokensForModel } from "../agents/context.js";
import { resolveFastModeState } from "../agents/fast-mode.js";
import { resolveModelAuthLabel } from "../agents/model-auth-label.js";
import { CODEX_APP_SERVER_AUTH_MARKER } from "../agents/model-auth-markers.js";
import {
areRuntimeModelRefsEquivalent,
shouldPreferActiveRuntimeAliasAuthLabel,
@@ -50,6 +49,10 @@ import {
formatTaskStatusTitle,
} from "../tasks/task-status.js";
import { resolveActiveFallbackState } from "./fallback-notice-state.js";
import {
buildCodexSyntheticUsageAuth,
shouldUseCodexSyntheticUsageForRuntime,
} from "./codex-synthetic-usage.js";
import { formatCompactPluginHealthLine } from "./status-plugin-health.js";
import type { BuildStatusTextParams } from "./status-text.types.js";
@@ -226,15 +229,6 @@ function resolveCodexSyntheticUsageAuthProfileId(params: {
}
}
function shouldUseCodexSyntheticUsage(params: {
provider?: string;
effectiveHarness?: string;
}): boolean {
const harness = normalizeOptionalLowercaseString(params.effectiveHarness);
const provider = normalizeOptionalLowercaseString(params.provider);
return harness === "codex" && (provider === "openai" || provider === "codex");
}
function formatSessionTaskLine(sessionKey: string): string | undefined {
const snapshot = buildTaskStatusSnapshot(listTasksForSessionKeyForStatus(sessionKey));
const task = snapshot.focus;
@@ -453,11 +447,11 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise<st
const usageProvider = activeRuntimeIsAuthoritative ? activeProvider : selectedLookupProvider;
const selectedUsageCredentialType = resolveUsageCredentialType(usageAuthLabel);
const useCodexSyntheticUsage =
shouldUseCodexSyntheticUsage({
selectedUsageCredentialType !== "api_key" &&
shouldUseCodexSyntheticUsageForRuntime({
provider: usageStatusProvider,
effectiveHarness,
}) &&
(selectedUsageCredentialType === "oauth" || selectedUsageCredentialType === "token");
});
const codexUsageAuthProfileId = useCodexSyntheticUsage
? resolveCodexSyntheticUsageAuthProfileId({
profileId: sessionEntry?.authProfileOverride,
@@ -491,14 +485,7 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise<st
workspaceDir: statusWorkspaceDir,
config: cfg,
auth: useCodexSyntheticUsage
? [
{
provider: "openai",
token: CODEX_APP_SERVER_AUTH_MARKER,
...(codexUsageAuthProfileId ? { authProfileId: codexUsageAuthProfileId } : {}),
hookProvider: "codex",
},
]
? [buildCodexSyntheticUsageAuth({ authProfileId: codexUsageAuthProfileId })]
: undefined,
}),
new Promise<never>((_, reject) => {

View File

@@ -573,7 +573,11 @@ describe("ci workflow guards", () => {
expect(runStep.run).toContain("childEnv[key] = value");
});
it("uploads a CI timing summary after the run lanes finish", () => {
it("keeps the CI timing summary parked for timing optimization work", () => {
expect(readFileSync(".github/workflows/ci.yml", "utf8")).toContain(
"Re-enable this job when we want to collect CI timing data for timing optimization.",
);
const workflow = readCiWorkflow();
const timingJob = workflow.jobs["ci-timings-summary"];
@@ -598,6 +602,7 @@ describe("ci workflow guards", () => {
"ios-build",
"android",
]);
expect(timingJob.if).toContain("false");
expect(timingJob.if).toContain("always()");
expect(timingJob.if).toContain("!cancelled()");

View File

@@ -4,7 +4,12 @@ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { execPlainGh, plainGhEnv, resolvePlainGhBin } from "../../scripts/lib/plain-gh.mjs";
import {
execPlainGh,
plainGhEnv,
PLAIN_GH_SYSTEM_CANDIDATES,
resolvePlainGhBin,
} from "../../scripts/lib/plain-gh.mjs";
const tempDirs: string[] = [];
@@ -64,6 +69,16 @@ describe("plain gh helpers", () => {
).toBe(ghPath);
});
it("prefers package-manager gh paths over bin shims", () => {
const realGh = makeFakeGh();
const shimGh = makeFakeGh();
expect(resolvePlainGhBin({ PATH: shimGh }, [realGh, shimGh])).toBe(realGh);
expect(PLAIN_GH_SYSTEM_CANDIDATES.indexOf("/opt/homebrew/opt/gh/bin/gh")).toBeLessThan(
PLAIN_GH_SYSTEM_CANDIDATES.indexOf("/opt/homebrew/bin/gh"),
);
});
it("normalizes color environment for JSON-safe gh output", () => {
expect(
plainGhEnv({
@@ -135,5 +150,8 @@ describe("plain gh helpers", () => {
expect(helper).toContain("type -P gh");
expect(helper).not.toContain("command -v gh");
expect(helper.indexOf("/opt/homebrew/opt/gh/bin/gh")).toBeLessThan(
helper.indexOf("/opt/homebrew/bin/gh"),
);
});
});

View File

@@ -1303,80 +1303,6 @@
background: var(--bg-content);
}
.content--sessions {
position: relative;
isolation: isolate;
background:
radial-gradient(circle at 8% 12%, rgba(255, 0, 128, 0.6), transparent 24%),
radial-gradient(circle at 90% 10%, rgba(0, 180, 255, 0.58), transparent 25%),
radial-gradient(circle at 82% 88%, rgba(132, 255, 0, 0.48), transparent 26%),
linear-gradient(
135deg,
#ff004c 0%,
#ff8a00 15%,
#ffe600 30%,
#00d084 45%,
#00a3ff 60%,
#6d4cff 75%,
#ff4fd8 90%,
#ff004c 100%
);
background-attachment: local;
}
.content--sessions::before {
content: "";
position: fixed;
inset: var(--shell-topbar-height) 0 0 var(--shell-nav-width);
z-index: -2;
pointer-events: none;
background:
repeating-linear-gradient(115deg, rgba(255, 255, 255, 0.24) 0 18px, transparent 18px 36px),
conic-gradient(
from 0.1turn at 52% 48%,
rgba(255, 0, 76, 0.42),
rgba(255, 138, 0, 0.38),
rgba(255, 230, 0, 0.36),
rgba(0, 208, 132, 0.38),
rgba(0, 163, 255, 0.42),
rgba(109, 76, 255, 0.42),
rgba(255, 79, 216, 0.44),
rgba(255, 0, 76, 0.42)
);
filter: saturate(1.45);
mix-blend-mode: screen;
}
.shell--nav-collapsed .content--sessions::before {
left: var(--shell-nav-rail-width);
}
.content--sessions::after {
content: "";
position: fixed;
inset: var(--shell-topbar-height) 0 0 var(--shell-nav-width);
z-index: -1;
pointer-events: none;
background:
linear-gradient(180deg, rgba(8, 8, 16, 0.34), rgba(8, 8, 16, 0.66)),
radial-gradient(circle at 50% 18%, rgba(255, 255, 255, 0.36), transparent 22%);
}
.shell--nav-collapsed .content--sessions::after {
left: var(--shell-nav-rail-width);
}
.content--sessions .content-header,
.content--sessions .panel,
.content--sessions .card,
.content--sessions .settings-workspace {
backdrop-filter: blur(14px) saturate(1.35);
-webkit-backdrop-filter: blur(14px) saturate(1.35);
box-shadow:
0 16px 44px rgba(0, 0, 0, 0.26),
0 0 0 1px rgba(255, 255, 255, 0.1);
}
.content--chat {
display: flex;
flex-direction: column;

View File

@@ -348,16 +348,6 @@ describe("renderApp assistant avatar routing", () => {
expect(content?.classList.contains("content--chat")).toBe(false);
});
it("marks the sessions route so it can carry the rainbow background treatment", () => {
const container = document.createElement("div");
render(renderApp(createState({ tab: "sessions" })), container);
const content = container.querySelector<HTMLElement>("main.content");
expect(content?.classList.contains("content--sessions")).toBe(true);
expect(content?.classList.contains("content--chat")).toBe(false);
});
it("does not render chat errors in non-chat page headers", () => {
const container = document.createElement("div");

View File

@@ -2651,9 +2651,8 @@ export function renderApp(state: AppViewState) {
<main
class="content ${isChat ? "content--chat" : ""} ${state.tab === "logs"
? "content--logs"
: ""} ${state.tab === "sessions" ? "content--sessions" : ""} ${state.tab === "workboard"
? "content--workboard"
: ""} ${state.tab === "skillWorkshop"
: ""} ${state.tab === "workboard" ? "content--workboard" : ""} ${state.tab ===
"skillWorkshop"
? `content--skill-workshop ${
state.skillWorkshopMode === "today" ? "content--skill-workshop-today" : ""
}`