mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-28 18:31:39 +08:00
Compare commits
15 Commits
codex/red-
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f0a8e5949 | ||
|
|
9a735bea03 | ||
|
|
81e53202f2 | ||
|
|
e9f9a68d68 | ||
|
|
db255b1154 | ||
|
|
4fc504d321 | ||
|
|
751a6c23f0 | ||
|
|
899f65097b | ||
|
|
a6a4652c70 | ||
|
|
3b292ba9d4 | ||
|
|
0fdfc9f65f | ||
|
|
448b7c75b6 | ||
|
|
6830aa39ea | ||
|
|
a0b397748f | ||
|
|
dd0e4f6e61 |
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@@ -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:
|
||||
|
||||
4
.github/workflows/clawsweeper-dispatch.yml
vendored
4
.github/workflows/clawsweeper-dispatch.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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"
|
||||
|
||||
4
.github/workflows/dependency-guard.yml
vendored
4
.github/workflows/dependency-guard.yml
vendored
@@ -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 }}
|
||||
|
||||
2
.github/workflows/docs-agent.yml
vendored
2
.github/workflows/docs-agent.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
2
.github/workflows/mantis-telegram-live.yml
vendored
2
.github/workflows/mantis-telegram-live.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/maturity-scorecard.yml
vendored
2
.github/workflows/maturity-scorecard.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/test-performance-agent.yml
vendored
2
.github/workflows/test-performance-agent.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/workflow-sanity.yml
vendored
2
.github/workflows/workflow-sanity.yml
vendored
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
134
extensions/google-meet/src/node-invoke-policy.test.ts
Normal file
134
extensions/google-meet/src/node-invoke-policy.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
192
extensions/google-meet/src/node-invoke-policy.ts
Normal file
192
extensions/google-meet/src/node-invoke-policy.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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-"));
|
||||
|
||||
@@ -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);
|
||||
|
||||
127
extensions/signal/src/monitor.approval-reply-delivery.test.ts
Normal file
127
extensions/signal/src/monitor.approval-reply-delivery.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]>>(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }>({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
63
src/status/codex-synthetic-usage.ts
Normal file
63
src/status/codex-synthetic-usage.ts
Normal 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()],
|
||||
};
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()");
|
||||
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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" : ""
|
||||
}`
|
||||
|
||||
Reference in New Issue
Block a user