mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-24 00:04:25 +08:00
Compare commits
156 Commits
codex/mess
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ff7abc898 | ||
|
|
dc9c11be91 | ||
|
|
58552f6d7c | ||
|
|
b8811b7dde | ||
|
|
0850d83de1 | ||
|
|
92c10d4edc | ||
|
|
b22ae2a4da | ||
|
|
a822c9abaa | ||
|
|
c308295cd3 | ||
|
|
524e19726f | ||
|
|
bc243568e7 | ||
|
|
2cbb4e70cc | ||
|
|
e9b017d9dc | ||
|
|
bde5be874a | ||
|
|
8c09419f20 | ||
|
|
71f84f910a | ||
|
|
8e6624cb6c | ||
|
|
273eed4c51 | ||
|
|
0bc5fb86a8 | ||
|
|
7bde374c47 | ||
|
|
fa263affd5 | ||
|
|
fa0427347a | ||
|
|
aad78d399c | ||
|
|
928607ac4a | ||
|
|
010c7f7110 | ||
|
|
69891cf2ac | ||
|
|
541f9b25d2 | ||
|
|
c045fbf8ec | ||
|
|
e63d11ea24 | ||
|
|
fed369085f | ||
|
|
6834a2d47b | ||
|
|
52251261ca | ||
|
|
e94deea4f2 | ||
|
|
b827629418 | ||
|
|
02556f9caf | ||
|
|
3f2b205dde | ||
|
|
3d2c52c935 | ||
|
|
e11539234b | ||
|
|
720e295cff | ||
|
|
20d1dc8f0a | ||
|
|
d3ac8e3caa | ||
|
|
93cfd59dd6 | ||
|
|
5078ffdeb4 | ||
|
|
475252453b | ||
|
|
d38fb7456a | ||
|
|
08f8de3aee | ||
|
|
a02a8cca79 | ||
|
|
c638f2beda | ||
|
|
34d2d54d6c | ||
|
|
7cc0879d0e | ||
|
|
2af06042c2 | ||
|
|
8cda4399d0 | ||
|
|
3d6127f7e4 | ||
|
|
72816124c9 | ||
|
|
0e091482a3 | ||
|
|
d51582a936 | ||
|
|
7374ecc777 | ||
|
|
e856a24754 | ||
|
|
9dbdefd43c | ||
|
|
0177521375 | ||
|
|
c714bfd8b6 | ||
|
|
d980f2555a | ||
|
|
300b09b33f | ||
|
|
2429585046 | ||
|
|
a972855150 | ||
|
|
306f0ec37f | ||
|
|
cb6b15f782 | ||
|
|
44d77de0c5 | ||
|
|
bd9f2a5e2e | ||
|
|
b3b210b706 | ||
|
|
536b437454 | ||
|
|
dd055c4f7c | ||
|
|
f484bf9985 | ||
|
|
04575a97b6 | ||
|
|
318f95417a | ||
|
|
1aad7d4e50 | ||
|
|
c313642ae2 | ||
|
|
932b58b94b | ||
|
|
d37300f357 | ||
|
|
9e63323388 | ||
|
|
00f8b10567 | ||
|
|
4dac8f47ed | ||
|
|
9089a8ab32 | ||
|
|
67b26126ce | ||
|
|
307300ac97 | ||
|
|
7e0083ce0b | ||
|
|
740578b596 | ||
|
|
0a986f893a | ||
|
|
9535b102d3 | ||
|
|
1ce8eb3993 | ||
|
|
f354889efa | ||
|
|
cdf35e83f3 | ||
|
|
8a8c6b2a27 | ||
|
|
f5148aff25 | ||
|
|
7bec91c8d8 | ||
|
|
64c81f25c0 | ||
|
|
13ecb5c55e | ||
|
|
db212e572e | ||
|
|
5738cfb6df | ||
|
|
33b8b72ad3 | ||
|
|
e998986889 | ||
|
|
9549545dd0 | ||
|
|
9f0d2427cd | ||
|
|
a59b2f2958 | ||
|
|
c061373ede | ||
|
|
ea0330963c | ||
|
|
43890ebc3b | ||
|
|
e2bcde9b1c | ||
|
|
695cea68f5 | ||
|
|
dd76fdceb6 | ||
|
|
32dc664b4b | ||
|
|
3d8d45fb0d | ||
|
|
d63a73a1b8 | ||
|
|
ca5905eb90 | ||
|
|
023394000c | ||
|
|
a0f93cf88f | ||
|
|
1876e3e1c1 | ||
|
|
6f63140902 | ||
|
|
d0f591893b | ||
|
|
da32c7fe53 | ||
|
|
d3019e6127 | ||
|
|
21d67b168a | ||
|
|
2824c02a42 | ||
|
|
4c9c6f5116 | ||
|
|
90d4aa7a8e | ||
|
|
f826a665a2 | ||
|
|
add9f3c6d3 | ||
|
|
603b250125 | ||
|
|
19ddaa28b9 | ||
|
|
f6b2a5ffb4 | ||
|
|
78a8caef38 | ||
|
|
e0d7776fff | ||
|
|
8efed50c4e | ||
|
|
3e84836b01 | ||
|
|
01abe0a33d | ||
|
|
f0a2ba0584 | ||
|
|
f24b1a9c0c | ||
|
|
7c60379589 | ||
|
|
0fed6402be | ||
|
|
a13e2b92b3 | ||
|
|
e583e62190 | ||
|
|
fe5c098fd7 | ||
|
|
28a5b0a212 | ||
|
|
53f9b6a36b | ||
|
|
eae53595b0 | ||
|
|
ca2f4c0d67 | ||
|
|
f66e83154b | ||
|
|
1479078a25 | ||
|
|
0a97f73402 | ||
|
|
7668a72843 | ||
|
|
10d850b39c | ||
|
|
d4f666874f | ||
|
|
606706492f | ||
|
|
cc1b3a8550 | ||
|
|
438f208a76 | ||
|
|
b8f1961aae |
@@ -146,7 +146,7 @@ Default guidance:
|
||||
|
||||
Default Completeness bands:
|
||||
|
||||
- `Lovable` (95-100): complete across expected workflows, variants, and
|
||||
- `Clawesome` (95-100): complete across expected workflows, variants, and
|
||||
recovery branches, with only minor polish gaps.
|
||||
- `Stable` (80-95): the expected workflow set is broadly present, with only
|
||||
bounded missing branches.
|
||||
@@ -172,7 +172,7 @@ Default Completeness bands:
|
||||
|
||||
Bands:
|
||||
|
||||
- `Lovable`: 95-100
|
||||
- `Clawesome`: 95-100
|
||||
- `Stable`: 80-95
|
||||
- `Beta`: 70-80
|
||||
- `Alpha`: 50-70
|
||||
|
||||
23
.github/codex/prompts/maturity-scorecard-agent.md
vendored
Normal file
23
.github/codex/prompts/maturity-scorecard-agent.md
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# OpenClaw Maturity Scorecard Agent
|
||||
|
||||
You are refreshing the OpenClaw maturity score source for a release scorecard.
|
||||
|
||||
Goal: use the `$claw-score` skill to refresh `qa/maturity-scores.yaml` for every active surface in `taxonomy.yaml`, using the current repository and the release evidence artifacts in `.artifacts/maturity-evidence`.
|
||||
|
||||
Allowed tracked paths:
|
||||
|
||||
- `qa/maturity-scores.yaml`
|
||||
|
||||
Hard limits:
|
||||
|
||||
- Do not edit generated docs, taxonomy, workflows, scripts, package metadata, lockfiles, tests, or application code.
|
||||
- Do not render docs. The workflow renders docs after validating the score source.
|
||||
- Keep the score source schema valid for QA Lab maturity score validation.
|
||||
|
||||
Required workflow:
|
||||
|
||||
1. Use the `$claw-score` skill before editing.
|
||||
2. Read `taxonomy.yaml`, any existing maturity score file, and the release evidence artifacts.
|
||||
3. Refresh scores for every active surface in `taxonomy.yaml`.
|
||||
4. Run the QA Lab maturity score validation used by this repository.
|
||||
5. If no defensible score update is possible, leave a valid `qa/maturity-scores.yaml` and explain the uncertainty in the final message.
|
||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -1177,7 +1177,9 @@ jobs:
|
||||
timeout-minutes: ${{ matrix.timeout_minutes || 60 }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 12
|
||||
# The canonical main path waits for the admission debounce above, so
|
||||
# modestly widen this large matrix without recreating registration bursts.
|
||||
max-parallel: 16
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_nondist_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
18
.github/workflows/codeql.yml
vendored
18
.github/workflows/codeql.yml
vendored
@@ -22,12 +22,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- ".github/actions/**"
|
||||
- ".github/codeql/**"
|
||||
- ".github/workflows/**"
|
||||
- "packages/**"
|
||||
- "src/**"
|
||||
schedule:
|
||||
- cron: "0 6 * * *"
|
||||
|
||||
@@ -55,32 +49,32 @@ jobs:
|
||||
include:
|
||||
- language: javascript-typescript
|
||||
category: core-auth-secrets
|
||||
runs_on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs_on: ubuntu-24.04
|
||||
timeout_minutes: 25
|
||||
config_file: ./.github/codeql/codeql-core-auth-secrets-critical-security.yml
|
||||
- language: javascript-typescript
|
||||
category: channel-runtime-boundary
|
||||
runs_on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs_on: ubuntu-24.04
|
||||
timeout_minutes: 25
|
||||
config_file: ./.github/codeql/codeql-channel-runtime-boundary-critical-security.yml
|
||||
- language: javascript-typescript
|
||||
category: network-ssrf-boundary
|
||||
runs_on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs_on: ubuntu-24.04
|
||||
timeout_minutes: 25
|
||||
config_file: ./.github/codeql/codeql-network-ssrf-boundary-critical-security.yml
|
||||
- language: javascript-typescript
|
||||
category: mcp-process-tool-boundary
|
||||
runs_on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs_on: ubuntu-24.04
|
||||
timeout_minutes: 25
|
||||
config_file: ./.github/codeql/codeql-mcp-process-tool-boundary-critical-security.yml
|
||||
- language: javascript-typescript
|
||||
category: plugin-trust-boundary
|
||||
runs_on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs_on: ubuntu-24.04
|
||||
timeout_minutes: 25
|
||||
config_file: ./.github/codeql/codeql-plugin-trust-boundary-critical-security.yml
|
||||
- language: actions
|
||||
category: actions
|
||||
runs_on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs_on: ubuntu-24.04
|
||||
timeout_minutes: 10
|
||||
config_file: ./.github/codeql/codeql-actions-critical-security.yml
|
||||
steps:
|
||||
|
||||
4
.github/workflows/crabbox-hydrate.yml
vendored
4
.github/workflows/crabbox-hydrate.yml
vendored
@@ -490,7 +490,7 @@ jobs:
|
||||
if (-not $env:CRABBOX_ID -or $env:CRABBOX_ID -notmatch '^[A-Za-z0-9._-]+$') {
|
||||
Write-Error "Invalid crabbox_id"
|
||||
}
|
||||
$actionsRoot = Join-Path $HOME ".crabbox\actions"
|
||||
$actionsRoot = "C:\ProgramData\crabbox\actions"
|
||||
New-Item -ItemType Directory -Force $actionsRoot | Out-Null
|
||||
$state = Join-Path $actionsRoot "$env:CRABBOX_ID.env"
|
||||
$envFile = Join-Path $actionsRoot "$env:CRABBOX_ID.env.ps1"
|
||||
@@ -546,7 +546,7 @@ jobs:
|
||||
if ($env:CRABBOX_KEEP_ALIVE_MINUTES -match '^[0-9]+$') {
|
||||
$minutes = [int]$env:CRABBOX_KEEP_ALIVE_MINUTES
|
||||
}
|
||||
$stop = Join-Path $HOME ".crabbox\actions\$env:CRABBOX_ID.stop"
|
||||
$stop = Join-Path "C:\ProgramData\crabbox\actions" "$env:CRABBOX_ID.stop"
|
||||
$deadline = (Get-Date).AddMinutes($minutes)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
if (Test-Path $stop) {
|
||||
|
||||
2
.github/workflows/ios-periphery.yml
vendored
2
.github/workflows/ios-periphery.yml
vendored
@@ -220,7 +220,7 @@ jobs:
|
||||
with:
|
||||
name: ios-periphery-dead-code-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ios-periphery
|
||||
if-no-files-found: warn
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
- name: Fail on dead code
|
||||
|
||||
129
.github/workflows/maturity-scorecard.yml
vendored
129
.github/workflows/maturity-scorecard.yml
vendored
@@ -12,6 +12,40 @@ on:
|
||||
required: true
|
||||
default: main
|
||||
type: string
|
||||
expected_sha:
|
||||
description: Optional full SHA that ref must resolve to
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
qa_evidence_run_id:
|
||||
description: Optional workflow run id containing qa-evidence.json
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
ref:
|
||||
description: OpenClaw branch, tag, or SHA containing the maturity score source
|
||||
required: true
|
||||
type: string
|
||||
expected_sha:
|
||||
description: Optional full SHA that ref must resolve to
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
secrets:
|
||||
OPENAI_API_KEY:
|
||||
description: OpenAI API key used by live QA profile scenarios
|
||||
required: true
|
||||
OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY:
|
||||
description: Optional OpenAI API key used by maturity scorecard agent steps
|
||||
required: false
|
||||
GH_APP_PRIVATE_KEY:
|
||||
description: Optional GitHub App private key for generated docs PR creation
|
||||
required: false
|
||||
GH_APP_PRIVATE_KEY_FALLBACK:
|
||||
description: Optional fallback GitHub App private key for generated docs PR creation
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -43,14 +77,25 @@ jobs:
|
||||
- name: Validate selected ref
|
||||
id: validate
|
||||
env:
|
||||
EXPECTED_SHA: ${{ inputs.expected_sha }}
|
||||
INPUT_REF: ${{ inputs.ref }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
selected_revision="$(git rev-parse HEAD)"
|
||||
expected_sha="${EXPECTED_SHA,,}"
|
||||
trusted_reason=""
|
||||
|
||||
if [[ -n "${expected_sha// }" && ! "$expected_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "expected_sha must be a full 40-character SHA; got: ${EXPECTED_SHA}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "${expected_sha// }" && "${selected_revision,,}" != "$expected_sha" ]]; then
|
||||
echo "Ref '${INPUT_REF}' resolved to ${selected_revision}, expected ${EXPECTED_SHA}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
|
||||
if git merge-base --is-ancestor "$selected_revision" refs/remotes/origin/main; then
|
||||
@@ -87,9 +132,9 @@ jobs:
|
||||
if: ${{ inputs.qa_evidence_run_id == '' }}
|
||||
uses: ./.github/workflows/qa-profile-evidence.yml
|
||||
with:
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
|
||||
qa_profile: all
|
||||
fail_on_qa_failure: false
|
||||
ref: ${{ inputs.ref }}
|
||||
expected_sha: ${{ needs.validate_selected_ref.outputs.selected_revision }}
|
||||
qa_profile: release
|
||||
secrets:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
@@ -193,8 +238,8 @@ jobs:
|
||||
}
|
||||
|
||||
const evidence = JSON.parse(fs.readFileSync(evidencePath, "utf8"));
|
||||
if (evidence.profile !== "all") {
|
||||
throw new Error(`qa-evidence.json profile must be all, got ${JSON.stringify(evidence.profile)}`);
|
||||
if (evidence.profile !== "release") {
|
||||
throw new Error(`qa-evidence.json profile must be release, got ${JSON.stringify(evidence.profile)}`);
|
||||
}
|
||||
|
||||
const artifactDir = path.dirname(evidencePath);
|
||||
@@ -211,14 +256,75 @@ jobs:
|
||||
const manifestPath = path.join(artifactDir, manifestNames[0]);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
const manifestProfile = manifest.qaProfile ?? evidence.profile;
|
||||
if (manifestProfile !== "all") {
|
||||
throw new Error(`QA evidence manifest profile must be all, got ${JSON.stringify(manifestProfile)}`);
|
||||
if (manifestProfile !== "release") {
|
||||
throw new Error(`QA evidence manifest profile must be release, got ${JSON.stringify(manifestProfile)}`);
|
||||
}
|
||||
if (manifest.targetSha !== targetSha) {
|
||||
throw new Error(`QA evidence manifest targetSha ${manifest.targetSha} does not match selected ref ${targetSha}`);
|
||||
}
|
||||
NODE
|
||||
|
||||
- name: Ensure maturity scorecard agent key exists
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
|
||||
echo "Missing OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY or OPENAI_API_KEY secret." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run Codex maturity scorecard agent
|
||||
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
|
||||
env:
|
||||
MATURITY_EVIDENCE_DIR: .artifacts/maturity-evidence
|
||||
MATURITY_SCORES_PATH: qa/maturity-scores.yaml
|
||||
MATURITY_TAXONOMY_PATH: taxonomy.yaml
|
||||
with:
|
||||
openai-api-key: ${{ secrets.OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
|
||||
prompt-file: .github/codex/prompts/maturity-scorecard-agent.md
|
||||
model: ${{ vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
|
||||
effort: high
|
||||
sandbox: workspace-write
|
||||
safety-strategy: drop-sudo
|
||||
|
||||
- name: Enforce focused maturity score patch
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git restore --staged :/
|
||||
|
||||
allowed='^qa/maturity-scores\.yaml$'
|
||||
bad_tracked="$(
|
||||
git diff --name-only HEAD -- | while IFS= read -r path; do
|
||||
if [[ ! "$path" =~ $allowed ]]; then
|
||||
printf '%s\n' "$path"
|
||||
fi
|
||||
done
|
||||
)"
|
||||
if [[ -n "$bad_tracked" ]]; then
|
||||
echo "Maturity scorecard agent touched forbidden tracked paths:"
|
||||
printf '%s\n' "$bad_tracked"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
bad_untracked="$(
|
||||
git ls-files --others --exclude-standard | while IFS= read -r path; do
|
||||
if [[ "$path" != "qa/maturity-scores.yaml" ]]; then
|
||||
printf '%s\n' "$path"
|
||||
fi
|
||||
done
|
||||
)"
|
||||
if [[ -n "$bad_untracked" ]]; then
|
||||
echo "Maturity scorecard agent created forbidden untracked paths:"
|
||||
printf '%s\n' "$bad_untracked"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f qa/maturity-scores.yaml ]]; then
|
||||
echo "Maturity scorecard agent must produce qa/maturity-scores.yaml." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate maturity score sources
|
||||
run: |
|
||||
node --import tsx --input-type=module <<'NODE'
|
||||
@@ -261,6 +367,7 @@ jobs:
|
||||
--strict-inputs
|
||||
|
||||
- name: Create generated docs PR app token
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
|
||||
@@ -271,7 +378,7 @@ jobs:
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Create generated docs PR fallback app token
|
||||
if: ${{ steps.app-token.outcome == 'failure' }}
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && steps.app-token.outcome == 'failure' }}
|
||||
id: app-token-fallback
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
|
||||
with:
|
||||
@@ -281,6 +388,7 @@ jobs:
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Open generated docs PR
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
QA_EVIDENCE_RUN_ID: ${{ inputs.qa_evidence_run_id }}
|
||||
@@ -292,7 +400,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$(git status --porcelain -- qa/maturity-scores.yaml docs/maturity-scores.yaml docs/maturity/scorecard.md docs/maturity/taxonomy.md)" ]]; then
|
||||
if [[ -z "$(git status --porcelain -- qa/maturity-scores.yaml docs/maturity/scorecard.md docs/maturity/taxonomy.md)" ]]; then
|
||||
{
|
||||
echo
|
||||
echo "- Pull request: skipped; generated scorecard matches selected ref"
|
||||
@@ -312,9 +420,6 @@ jobs:
|
||||
git fetch --no-tags --depth=1 origin "refs/heads/${branch}:refs/remotes/origin/${branch}" || true
|
||||
git switch -C "$branch"
|
||||
git add qa/maturity-scores.yaml docs/maturity/scorecard.md docs/maturity/taxonomy.md
|
||||
if git ls-files --error-unmatch docs/maturity-scores.yaml >/dev/null 2>&1 || [[ -e docs/maturity-scores.yaml ]]; then
|
||||
git add docs/maturity-scores.yaml
|
||||
fi
|
||||
git commit -m "docs: update maturity scorecard"
|
||||
git push --force-with-lease origin "$branch"
|
||||
|
||||
|
||||
52
.github/workflows/openclaw-release-checks.yml
vendored
52
.github/workflows/openclaw-release-checks.yml
vendored
@@ -44,6 +44,11 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
run_maturity_scorecard:
|
||||
description: Render advisory maturity scorecard release docs; default release checks rely on dedicated package, QA, live, and E2E gates
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
rerun_group:
|
||||
description: Release check group to run
|
||||
required: false
|
||||
@@ -106,6 +111,7 @@ jobs:
|
||||
mode: ${{ steps.inputs.outputs.mode }}
|
||||
release_profile: ${{ steps.inputs.outputs.release_profile }}
|
||||
run_release_soak: ${{ steps.inputs.outputs.run_release_soak }}
|
||||
run_maturity_scorecard: ${{ steps.inputs.outputs.run_maturity_scorecard }}
|
||||
rerun_group: ${{ steps.inputs.outputs.rerun_group }}
|
||||
live_suite_filter: ${{ steps.inputs.outputs.live_suite_filter }}
|
||||
cross_os_suite_filter: ${{ steps.inputs.outputs.cross_os_suite_filter }}
|
||||
@@ -279,6 +285,7 @@ jobs:
|
||||
RELEASE_MODE_INPUT: ${{ inputs.mode }}
|
||||
RELEASE_PROFILE_INPUT: ${{ inputs.release_profile }}
|
||||
RELEASE_RUN_RELEASE_SOAK_INPUT: ${{ inputs.run_release_soak }}
|
||||
RELEASE_RUN_MATURITY_SCORECARD_INPUT: ${{ inputs.run_maturity_scorecard }}
|
||||
RELEASE_RERUN_GROUP_INPUT: ${{ inputs.rerun_group }}
|
||||
RELEASE_LIVE_SUITE_FILTER_INPUT: ${{ inputs.live_suite_filter }}
|
||||
RELEASE_CROSS_OS_SUITE_FILTER_INPUT: ${{ inputs.cross_os_suite_filter }}
|
||||
@@ -319,6 +326,12 @@ jobs:
|
||||
else
|
||||
run_release_soak=true
|
||||
fi
|
||||
run_maturity_scorecard="$(printf '%s' "$RELEASE_RUN_MATURITY_SCORECARD_INPUT" | tr '[:upper:]' '[:lower:]')"
|
||||
if [[ "$run_maturity_scorecard" != "true" && "$run_maturity_scorecard" != "1" && "$run_maturity_scorecard" != "yes" ]]; then
|
||||
run_maturity_scorecard=false
|
||||
else
|
||||
run_maturity_scorecard=true
|
||||
fi
|
||||
release_profile="$RELEASE_PROFILE_INPUT"
|
||||
if [[ "$release_profile" == "minimum" ]]; then
|
||||
release_profile=beta
|
||||
@@ -422,6 +435,7 @@ jobs:
|
||||
printf 'mode=%s\n' "$RELEASE_MODE_INPUT"
|
||||
printf 'release_profile=%s\n' "$release_profile"
|
||||
printf 'run_release_soak=%s\n' "$run_release_soak"
|
||||
printf 'run_maturity_scorecard=%s\n' "$run_maturity_scorecard"
|
||||
printf 'rerun_group=%s\n' "$RELEASE_RERUN_GROUP_INPUT"
|
||||
printf 'live_suite_filter=%s\n' "$RELEASE_LIVE_SUITE_FILTER_INPUT"
|
||||
printf 'cross_os_suite_filter=%s\n' "$RELEASE_CROSS_OS_SUITE_FILTER_INPUT"
|
||||
@@ -444,6 +458,7 @@ jobs:
|
||||
RELEASE_MODE: ${{ inputs.mode }}
|
||||
RELEASE_PROFILE: ${{ steps.inputs.outputs.release_profile }}
|
||||
RUN_RELEASE_SOAK: ${{ steps.inputs.outputs.run_release_soak }}
|
||||
RUN_MATURITY_SCORECARD: ${{ steps.inputs.outputs.run_maturity_scorecard }}
|
||||
RELEASE_RERUN_GROUP: ${{ inputs.rerun_group }}
|
||||
RELEASE_LIVE_SUITE_FILTER: ${{ inputs.live_suite_filter }}
|
||||
RELEASE_CROSS_OS_SUITE_FILTER: ${{ inputs.cross_os_suite_filter }}
|
||||
@@ -461,6 +476,7 @@ jobs:
|
||||
echo "- Cross-OS mode: \`${RELEASE_MODE}\`"
|
||||
echo "- Release profile: \`${RELEASE_PROFILE}\`"
|
||||
echo "- Release soak lanes: \`${RUN_RELEASE_SOAK}\`"
|
||||
echo "- Maturity scorecard docs: \`${RUN_MATURITY_SCORECARD}\`"
|
||||
echo "- Rerun group: \`${RELEASE_RERUN_GROUP}\`"
|
||||
if [[ -n "${RELEASE_LIVE_SUITE_FILTER// }" ]]; then
|
||||
echo "- Live suite filter: \`${RELEASE_LIVE_SUITE_FILTER}\`"
|
||||
@@ -767,6 +783,20 @@ jobs:
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
|
||||
maturity_scorecard_release_checks:
|
||||
name: Render maturity scorecard release docs
|
||||
needs: [resolve_target]
|
||||
if: contains(fromJSON('["all","qa"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.run_maturity_scorecard == 'true'
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
uses: ./.github/workflows/maturity-scorecard.yml
|
||||
with:
|
||||
ref: ${{ needs.resolve_target.outputs.ref }}
|
||||
expected_sha: ${{ needs.resolve_target.outputs.revision }}
|
||||
secrets:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
qa_lab_parity_lane_release_checks:
|
||||
name: Run QA Lab parity lane (${{ matrix.lane }})
|
||||
needs: [resolve_target]
|
||||
@@ -853,7 +883,7 @@ jobs:
|
||||
name: release-qa-parity-${{ matrix.lane }}-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -959,7 +989,7 @@ jobs:
|
||||
name: release-qa-parity-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -1131,7 +1161,7 @@ jobs:
|
||||
name: release-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -1241,13 +1271,13 @@ jobs:
|
||||
--output .artifacts/qa-e2e/runtime-parity-standard-report/qa-runtime-tool-coverage-report.md
|
||||
|
||||
- name: Upload runtime tool coverage artifacts
|
||||
if: always()
|
||||
if: ${{ always() && steps.verify_runtime_parity_status.outputs.ready == 'true' }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: release-qa-runtime-tool-coverage-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/runtime-parity-standard-report/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
if-no-files-found: error
|
||||
|
||||
qa_live_matrix_release_checks:
|
||||
name: Run QA Lab live Matrix lane
|
||||
@@ -1327,7 +1357,7 @@ jobs:
|
||||
name: release-qa-live-matrix-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -1467,7 +1497,7 @@ jobs:
|
||||
name: release-qa-live-telegram-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -1607,7 +1637,7 @@ jobs:
|
||||
name: release-qa-live-discord-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -1750,7 +1780,7 @@ jobs:
|
||||
name: release-qa-live-whatsapp-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -1890,7 +1920,7 @@ jobs:
|
||||
name: release-qa-live-slack-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -1946,6 +1976,7 @@ jobs:
|
||||
- docker_e2e_release_checks
|
||||
- package_acceptance_release_checks
|
||||
- qa_lab_parity_lane_release_checks
|
||||
- maturity_scorecard_release_checks
|
||||
- qa_lab_parity_report_release_checks
|
||||
- qa_lab_runtime_parity_release_checks
|
||||
- runtime_tool_coverage_release_checks
|
||||
@@ -2031,6 +2062,7 @@ jobs:
|
||||
"docker_e2e_release_checks=${{ needs.docker_e2e_release_checks.result }}" \
|
||||
"package_acceptance_release_checks=${{ needs.package_acceptance_release_checks.result }}" \
|
||||
"qa_lab_parity_lane_release_checks=${{ needs.qa_lab_parity_lane_release_checks.result }}" \
|
||||
"maturity_scorecard_release_checks=${{ needs.maturity_scorecard_release_checks.result }}" \
|
||||
"qa_lab_parity_report_release_checks=${{ needs.qa_lab_parity_report_release_checks.result }}" \
|
||||
"qa_lab_runtime_parity_release_checks=${{ needs.qa_lab_runtime_parity_release_checks.result }}" \
|
||||
"runtime_tool_coverage_release_checks=${{ needs.runtime_tool_coverage_release_checks.result }}" \
|
||||
|
||||
@@ -1466,9 +1466,9 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload postpublish evidence
|
||||
if: ${{ always() }}
|
||||
if: ${{ always() && inputs.publish_openclaw_npm }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: openclaw-release-postpublish-evidence-${{ inputs.tag }}
|
||||
path: ${{ runner.temp }}/openclaw-release-postpublish-evidence
|
||||
if-no-files-found: ignore
|
||||
if-no-files-found: error
|
||||
|
||||
2
.github/workflows/opengrep-precise-full.yml
vendored
2
.github/workflows/opengrep-precise-full.yml
vendored
@@ -66,5 +66,5 @@ jobs:
|
||||
with:
|
||||
name: opengrep-full-sarif
|
||||
path: .opengrep-out/precise.sarif
|
||||
if-no-files-found: warn
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
2
.github/workflows/opengrep-precise.yml
vendored
2
.github/workflows/opengrep-precise.yml
vendored
@@ -97,5 +97,5 @@ jobs:
|
||||
with:
|
||||
name: opengrep-pr-diff-sarif
|
||||
path: .opengrep-out/precise.sarif
|
||||
if-no-files-found: warn
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
24
.github/workflows/qa-profile-evidence.yml
vendored
24
.github/workflows/qa-profile-evidence.yml
vendored
@@ -20,11 +20,6 @@ on:
|
||||
required: true
|
||||
default: release
|
||||
type: string
|
||||
fail_on_qa_failure:
|
||||
description: Fail the workflow when the QA profile command exits non-zero
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
@@ -40,11 +35,6 @@ on:
|
||||
description: Taxonomy QA profile id to run
|
||||
required: true
|
||||
type: string
|
||||
fail_on_qa_failure:
|
||||
description: Fail the reusable workflow when the QA profile command exits non-zero
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
secrets:
|
||||
OPENAI_API_KEY:
|
||||
description: OpenAI API key used by live QA profile scenarios
|
||||
@@ -99,6 +89,13 @@ jobs:
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
script: |
|
||||
// Reusable workflow jobs inherit the caller event but run as
|
||||
// github-actions[bot]; selected ref validation still gates secrets.
|
||||
if (context.actor === "github-actions[bot]") {
|
||||
core.info("Skipping manual actor permission check for a reusable workflow call.");
|
||||
core.setOutput("authorized", "true");
|
||||
return;
|
||||
}
|
||||
if (context.eventName !== "workflow_dispatch") {
|
||||
core.info(`Skipping manual actor permission check for ${context.eventName}.`);
|
||||
core.setOutput("authorized", "true");
|
||||
@@ -253,6 +250,9 @@ jobs:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
|
||||
- name: Ensure Playwright Chromium
|
||||
run: node scripts/ensure-playwright-chromium.mjs
|
||||
|
||||
- name: Run QA profile
|
||||
id: run_profile
|
||||
env:
|
||||
@@ -367,8 +367,8 @@ jobs:
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Fail if configured QA gate failed
|
||||
if: always() && inputs.fail_on_qa_failure
|
||||
- name: Fail if QA profile failed
|
||||
if: always()
|
||||
env:
|
||||
QA_EXIT_CODE: ${{ steps.run_profile.outputs.qa_exit_code }}
|
||||
QA_PROFILE: ${{ steps.profile.outputs.profile }}
|
||||
|
||||
@@ -37,6 +37,7 @@ This audited record covers the complete v2026.6.8..HEAD history: 423 merged PRs.
|
||||
|
||||
#### Pull requests
|
||||
|
||||
- **PR #92154** fix(qqbot): gate private group commands and close strict command visibility gaps. Thanks @sliverp.
|
||||
- **PR #90463** refactor: add session accessor seam with gateway consumer. Thanks @jalehman.
|
||||
- **PR #88656** Drop reasoning-only length turns from replay. Thanks @abel-zer0.
|
||||
- **PR #92856** feat(webui): add session workspace rail. Thanks @Solvely-Colin.
|
||||
|
||||
@@ -67,9 +67,9 @@ Release behavior:
|
||||
- App Store release uses canonical `ai.openclawfoundation.app*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/AppStoreRelease.xcconfig`.
|
||||
- App Store release uses manual `Apple Distribution` signing with profile names pinned in `apps/ios/Config/AppStoreSigning.json`.
|
||||
- Fastlane owns one-time Developer Portal setup, encrypted `match` signing sync to the repo/branch pinned in `apps/ios/Config/AppStoreSigning.json`, and release handling.
|
||||
- App Store release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, `OpenClawPushAPNsEnvironment=production`, `OpenClawPushRelayProfile=production`, `OpenClawPushProofPolicy=appleStrict`, and the App-Attest-capable entitlement file.
|
||||
- App Store release also switches the app to `OpenClawPushMode=appStore`, which derives relay transport, official distribution, the canonical production relay, production APNs, production relay profile, `appleStrict` proof, and the App-Attest-capable entitlement file.
|
||||
- `pnpm ios:release:upload` generates App Store screenshots and uploads release notes before archiving and uploading the IPA.
|
||||
- `pnpm ios:release` remains a compatibility alias for `pnpm ios:release:upload`; prefer the explicit upload command in new release docs and automation.
|
||||
- The release archive is validated before upload by inspecting the exported IPA's signed entitlements, embedded App Store profile, and push mode. The upload fails if the IPA is not an App Store production relay build.
|
||||
- App Review submission is manual in App Store Connect. The release lane uploads a build and metadata, but does not submit for review.
|
||||
- The release flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
|
||||
- `apps/ios/version.json` is the pinned iOS release version source.
|
||||
@@ -83,9 +83,8 @@ Release behavior:
|
||||
|
||||
Relay behavior for App Store builds:
|
||||
|
||||
- Release builds default to `https://ios-push-relay.openclaw.ai`.
|
||||
- Optional custom relay override: `OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com`
|
||||
This must be a plain `https://host[:port][/path]` base URL without whitespace, query params, fragments, or xcconfig metacharacters.
|
||||
- App Store release builds use the canonical hosted relay at `https://ios-push-relay.openclaw.ai`.
|
||||
- App Store release builds reject custom relay URL overrides. Future self-hosted relay support should use a separate explicit release path, not the public App Store build lane.
|
||||
|
||||
Signing setup commands:
|
||||
|
||||
@@ -162,25 +161,19 @@ This should create `apps/ios/fastlane/.env` with non-secret App Store Connect va
|
||||
|
||||
Use `pnpm ios:release:signing:setup` for the initial portal setup, then `MATCH_PASSWORD=... pnpm ios:release:signing:sync:push` to publish encrypted Fastlane match assets to the shared private repo.
|
||||
|
||||
4. Optional: set a custom official relay URL for the build. If unset, the release flow uses `https://ios-push-relay.openclaw.ai`.
|
||||
|
||||
```bash
|
||||
export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
|
||||
```
|
||||
|
||||
5. If you are starting a brand-new production release train, pin iOS to the current gateway version first:
|
||||
4. If you are starting a brand-new production release train, pin iOS to the current gateway version first:
|
||||
|
||||
```bash
|
||||
pnpm ios:version:pin -- --from-gateway
|
||||
```
|
||||
|
||||
6. Upload the build:
|
||||
5. Upload the build:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:upload
|
||||
```
|
||||
|
||||
7. Expected behavior:
|
||||
6. Expected behavior:
|
||||
- Fastlane reads `apps/ios/version.json`
|
||||
- verifies synced iOS versioning artifacts
|
||||
- resolves the next App Store Connect build number for that short version
|
||||
@@ -188,15 +181,16 @@ pnpm ios:release:upload
|
||||
- uploads release notes and screenshots to the editable App Store version
|
||||
- generates `apps/ios/build/AppStoreRelease.xcconfig`
|
||||
- archives `OpenClaw`
|
||||
- validates the exported IPA's push mode, signed entitlements, and embedded App Store profile
|
||||
- uploads the IPA to App Store Connect for TestFlight/App Review use
|
||||
- leaves App Review submission for a maintainer to complete manually
|
||||
|
||||
8. Expected outputs after a successful run:
|
||||
7. Expected outputs after a successful run:
|
||||
- `apps/ios/build/app-store/OpenClaw-<version>.ipa`
|
||||
- `apps/ios/build/app-store/OpenClaw-<version>.app.dSYM.zip`
|
||||
- Fastlane log line like `Uploaded iOS App Store build: version=<version> short=<short> build=<build>`
|
||||
|
||||
9. If this is a fresh clone on a maintainer machine that already works elsewhere, it is OK to copy the non-secret `apps/ios/fastlane/.env` from another trusted local clone on the same Mac. The Keychain-backed private key remains machine-local and is not stored in the repo.
|
||||
8. If this is a fresh clone on a maintainer machine that already works elsewhere, it is OK to copy the non-secret `apps/ios/fastlane/.env` from another trusted local clone on the same Mac. The Keychain-backed private key remains machine-local and is not stored in the repo.
|
||||
|
||||
## iOS Versioning Workflow
|
||||
|
||||
@@ -246,13 +240,13 @@ See `apps/ios/VERSIONING.md` for the detailed spec.
|
||||
- `apps/ios/Sources/OpenClaw.entitlements` derives `aps-environment` from the active build configuration/signing override.
|
||||
- App Attest relay builds use `apps/ios/Sources/OpenClawAppAttest.entitlements`; local/direct builds do not require App Attest provisioning.
|
||||
- APNs token registration to gateway happens only after gateway connection (`push.apns.register`).
|
||||
- Local/manual builds default to `OpenClawPushTransport=direct`, `OpenClawPushDistribution=local`, and a development `aps-environment` entitlement.
|
||||
- Local/manual Debug builds default to `OpenClawPushMode=localSandbox`, direct APNs registration, and a development `aps-environment` entitlement. Local/manual Release builds default to `OpenClawPushMode=localProduction` and direct production APNs registration.
|
||||
- Your selected team/profile must support Push Notifications for the app bundle ID you are signing.
|
||||
- If push capability or provisioning is wrong, APNs registration fails at runtime (check Xcode logs for `APNs registration failed`).
|
||||
- The gateway host also needs direct APNs auth configured separately with `OPENCLAW_APNS_TEAM_ID`, `OPENCLAW_APNS_KEY_ID`, and either `OPENCLAW_APNS_PRIVATE_KEY_P8` or `OPENCLAW_APNS_PRIVATE_KEY_PATH`.
|
||||
- Recommended gateway-host storage for the APNs `.p8` file is `~/.openclaw/credentials/apns/AuthKey_<KEYID>.p8` with restrictive permissions, then point `OPENCLAW_APNS_PRIVATE_KEY_PATH` at that file.
|
||||
- `apps/ios/fastlane/.env` only covers App Store Connect / Fastlane auth; it does not provide gateway APNs credentials for local direct-push testing.
|
||||
- Debug builds default to `OpenClawPushAPNsEnvironment=sandbox`; Release builds default to `production`.
|
||||
- Debug builds default to sandbox APNs through `OpenClawPushMode=localSandbox`; Release builds default to production APNs through `OpenClawPushMode=localProduction`.
|
||||
|
||||
## APNs Expectations For Official Builds
|
||||
|
||||
@@ -261,7 +255,7 @@ See `apps/ios/VERSIONING.md` for the detailed spec.
|
||||
- The relay registration is bound to the gateway identity fetched from `gateway.identity.get`, so another gateway cannot reuse that stored registration.
|
||||
- The app persists the relay handle metadata locally so reconnects can republish the gateway registration without re-registering on every connect.
|
||||
- If the relay base URL changes in a later build, the app refreshes the relay registration instead of reusing the old relay origin.
|
||||
- Production relay mode uses the `production` relay profile, production APNs, App Attest, and a StoreKit app transaction JWS during registration.
|
||||
- App Store release mode uses the internal `production` relay profile, production APNs, App Attest, and a StoreKit app transaction JWS during registration.
|
||||
- Gateway-side relay sending is configured through `gateway.push.apns.relay.baseUrl` in `openclaw.json`. `OPENCLAW_APNS_RELAY_BASE_URL` remains a temporary env override only.
|
||||
|
||||
## Official Build Relay Trust Model
|
||||
|
||||
@@ -152,6 +152,7 @@ extension SettingsProTab {
|
||||
}
|
||||
let notificationSettings = await UNUserNotificationCenter.current().notificationSettings()
|
||||
self.applyNotificationStatus(notificationSettings.authorizationStatus)
|
||||
self.registerForRemoteNotificationsIfEnrollmentReady()
|
||||
|
||||
let issueCount = SettingsDiagnostics.issueCount(
|
||||
gatewayConnected: self.gatewayDiagnosticConnected,
|
||||
@@ -417,6 +418,7 @@ extension SettingsProTab {
|
||||
let status = settings.authorizationStatus
|
||||
Task { @MainActor in
|
||||
self.applyNotificationStatus(status)
|
||||
self.registerForRemoteNotificationsIfEnrollmentReady()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -437,6 +439,7 @@ extension SettingsProTab {
|
||||
|
||||
func requestNotificationAuthorizationFromSettings() {
|
||||
guard !self.isRequestingNotificationAuthorization else { return }
|
||||
PushEnrollmentConsent.markDisclosureAccepted()
|
||||
self.isRequestingNotificationAuthorization = true
|
||||
Task {
|
||||
let granted = await (try? UNUserNotificationCenter.current().requestAuthorization(options: [
|
||||
@@ -448,12 +451,19 @@ extension SettingsProTab {
|
||||
await MainActor.run {
|
||||
self.isRequestingNotificationAuthorization = false
|
||||
self.notificationStatus = SettingsNotificationStatus(settings.authorizationStatus)
|
||||
guard granted, self.notificationStatus.allowsNotifications else { return }
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
guard granted else { return }
|
||||
self.registerForRemoteNotificationsIfEnrollmentReady()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func registerForRemoteNotificationsIfEnrollmentReady() {
|
||||
guard PushEnrollmentConsent.disclosureAccepted else { return }
|
||||
guard self.notificationStatus.allowsNotifications else { return }
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func applyNotificationStatus(_ status: UNAuthorizationStatus) {
|
||||
self.notificationStatus = SettingsNotificationStatus(status)
|
||||
|
||||
@@ -82,18 +82,10 @@
|
||||
<string>$(OPENCLAW_APP_GROUP_ID)</string>
|
||||
<key>OpenClawCanonicalVersion</key>
|
||||
<string>$(OPENCLAW_IOS_VERSION)</string>
|
||||
<key>OpenClawPushAPNsEnvironment</key>
|
||||
<string>$(OPENCLAW_PUSH_APNS_ENVIRONMENT)</string>
|
||||
<key>OpenClawPushDistribution</key>
|
||||
<string>$(OPENCLAW_PUSH_DISTRIBUTION)</string>
|
||||
<key>OpenClawPushProofPolicy</key>
|
||||
<string>$(OPENCLAW_PUSH_PROOF_POLICY)</string>
|
||||
<key>OpenClawPushMode</key>
|
||||
<string>$(OPENCLAW_PUSH_MODE)</string>
|
||||
<key>OpenClawPushRelayBaseURL</key>
|
||||
<string>$(OPENCLAW_PUSH_RELAY_BASE_URL)</string>
|
||||
<key>OpenClawPushRelayProfile</key>
|
||||
<string>$(OPENCLAW_PUSH_RELAY_PROFILE)</string>
|
||||
<key>OpenClawPushTransport</key>
|
||||
<string>$(OPENCLAW_PUSH_TRANSPORT)</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
@@ -4103,6 +4103,9 @@ extension NodeAppModel {
|
||||
|
||||
private func registerAPNsTokenIfNeeded() async {
|
||||
let usesRelayTransport = await self.pushRegistrationManager.usesRelayTransport
|
||||
guard await self.canPublishAPNsRegistration(usesRelayTransport: usesRelayTransport) else {
|
||||
return
|
||||
}
|
||||
guard self.gatewayConnected else {
|
||||
if usesRelayTransport {
|
||||
GatewayDiagnostics.pushRelay.skipped("gateway_offline")
|
||||
@@ -4163,6 +4166,23 @@ extension NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func canPublishAPNsRegistration(usesRelayTransport: Bool) async -> Bool {
|
||||
guard PushEnrollmentConsent.disclosureAccepted else {
|
||||
if usesRelayTransport {
|
||||
GatewayDiagnostics.pushRelay.skipped("enrollment_disclosure_not_accepted")
|
||||
}
|
||||
return false
|
||||
}
|
||||
let status = await self.notificationAuthorizationStatus()
|
||||
guard Self.isNotificationAuthorizationAllowed(status) else {
|
||||
if usesRelayTransport {
|
||||
GatewayDiagnostics.pushRelay.skipped("notifications_not_authorized")
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func fetchPushRelayGatewayIdentity() async throws -> PushRelayGatewayIdentity {
|
||||
let response = try await self.operatorGateway.request(
|
||||
method: "gateway.identity.get",
|
||||
@@ -5126,6 +5146,10 @@ extension NodeAppModel {
|
||||
self.setOperatorConnected(connected)
|
||||
}
|
||||
|
||||
func _test_canPublishAPNsRegistration(usesRelayTransport: Bool = true) async -> Bool {
|
||||
await self.canPublishAPNsRegistration(usesRelayTransport: usesRelayTransport)
|
||||
}
|
||||
|
||||
nonisolated static func _test_makeWatchChatItems(from raw: [OpenClawKit.AnyCodable]) -> [OpenClawWatchChatItem] {
|
||||
self.makeWatchChatItems(from: raw)
|
||||
}
|
||||
|
||||
@@ -123,10 +123,30 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
let notificationCenter = UNUserNotificationCenter.current()
|
||||
notificationCenter.delegate = self
|
||||
ExecApprovalNotificationBridge.registerCategory(center: notificationCenter)
|
||||
application.registerForRemoteNotifications()
|
||||
Task { @MainActor in
|
||||
await self.registerForRemoteNotificationsIfEnrollmentReady(application)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func registerForRemoteNotificationsIfEnrollmentReady(_ application: UIApplication) async {
|
||||
guard PushEnrollmentConsent.disclosureAccepted else { return }
|
||||
guard await Self.isNotificationAuthorizationAllowed() else { return }
|
||||
application.registerForRemoteNotifications()
|
||||
}
|
||||
|
||||
private static func isNotificationAuthorizationAllowed() async -> Bool {
|
||||
let settings = await UNUserNotificationCenter.current().notificationSettings()
|
||||
switch settings.authorizationStatus {
|
||||
case .authorized, .provisional, .ephemeral:
|
||||
return true
|
||||
case .denied, .notDetermined:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
if let appModel = self.resolvedAppModel() {
|
||||
Task { @MainActor in
|
||||
|
||||
@@ -27,7 +27,16 @@ enum PushProofPolicy: String {
|
||||
case internalSimulator
|
||||
}
|
||||
|
||||
enum PushBuildMode: String {
|
||||
case localSandbox
|
||||
case localProduction
|
||||
case appStore
|
||||
case deviceSandbox
|
||||
case simulatorSandbox
|
||||
}
|
||||
|
||||
struct PushBuildConfig {
|
||||
let mode: PushBuildMode
|
||||
let transport: PushTransportMode
|
||||
let distribution: PushDistributionMode
|
||||
let relayBaseURL: URL?
|
||||
@@ -54,31 +63,64 @@ struct PushBuildConfig {
|
||||
}
|
||||
|
||||
init(bundle: Bundle = .main) {
|
||||
self.transport = Self.readEnum(
|
||||
bundle: bundle,
|
||||
key: "OpenClawPushTransport",
|
||||
fallback: .direct)
|
||||
self.distribution = Self.readEnum(
|
||||
bundle: bundle,
|
||||
key: "OpenClawPushDistribution",
|
||||
fallback: .local)
|
||||
self.apnsEnvironment = Self.readEnum(
|
||||
bundle: bundle,
|
||||
key: "OpenClawPushAPNsEnvironment",
|
||||
fallback: Self.defaultAPNsEnvironment)
|
||||
self.relayProfile = Self.readEnum(
|
||||
bundle: bundle,
|
||||
key: "OpenClawPushRelayProfile",
|
||||
fallback: Self.defaultRelayProfile(apnsEnvironment: self.apnsEnvironment))
|
||||
self.proofPolicy = Self.readEnum(
|
||||
bundle: bundle,
|
||||
key: "OpenClawPushProofPolicy",
|
||||
fallback: Self.defaultProofPolicy(relayProfile: self.relayProfile))
|
||||
self.relayBaseURL = Self.readURL(bundle: bundle, key: "OpenClawPushRelayBaseURL")
|
||||
self.init(readValue: { bundle.object(forInfoDictionaryKey: $0) })
|
||||
}
|
||||
|
||||
private static func readURL(bundle: Bundle, key: String) -> URL? {
|
||||
guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return nil }
|
||||
init(infoDictionary: [String: Any]) {
|
||||
self.init(readValue: { infoDictionary[$0] })
|
||||
}
|
||||
|
||||
private init(readValue: (String) -> Any?) {
|
||||
self.mode = Self.readEnum(
|
||||
readValue: readValue,
|
||||
key: "OpenClawPushMode",
|
||||
fallback: .localSandbox)
|
||||
let relayBaseURLOverride = Self.readURL(
|
||||
readValue: readValue,
|
||||
key: "OpenClawPushRelayBaseURL")
|
||||
switch self.mode {
|
||||
case .localSandbox:
|
||||
self.transport = .direct
|
||||
self.distribution = .local
|
||||
self.relayBaseURL = nil
|
||||
self.apnsEnvironment = .sandbox
|
||||
self.relayProfile = .deviceSandbox
|
||||
self.proofPolicy = .appleDevelopment
|
||||
case .localProduction:
|
||||
self.transport = .direct
|
||||
self.distribution = .local
|
||||
self.relayBaseURL = nil
|
||||
self.apnsEnvironment = .production
|
||||
self.relayProfile = .production
|
||||
self.proofPolicy = .appleStrict
|
||||
case .appStore:
|
||||
self.transport = .relay
|
||||
self.distribution = .official
|
||||
self.relayBaseURL = URL(string: "https://\(Self.openClawHostedRelayHost)")!
|
||||
self.apnsEnvironment = .production
|
||||
self.relayProfile = .production
|
||||
self.proofPolicy = .appleStrict
|
||||
case .deviceSandbox:
|
||||
self.transport = .relay
|
||||
self.distribution = .official
|
||||
self.relayBaseURL = relayBaseURLOverride
|
||||
?? URL(string: "https://\(Self.openClawSandboxRelayHost)")!
|
||||
self.apnsEnvironment = .sandbox
|
||||
self.relayProfile = .deviceSandbox
|
||||
self.proofPolicy = .appleDevelopment
|
||||
case .simulatorSandbox:
|
||||
self.transport = .relay
|
||||
self.distribution = .official
|
||||
self.relayBaseURL = relayBaseURLOverride
|
||||
?? URL(string: "https://\(Self.openClawSandboxRelayHost)")!
|
||||
self.apnsEnvironment = .sandbox
|
||||
self.relayProfile = .simulatorSandbox
|
||||
self.proofPolicy = .internalSimulator
|
||||
}
|
||||
}
|
||||
|
||||
private static func readURL(readValue: (String) -> Any?, key: String) -> URL? {
|
||||
guard let raw = readValue(key) as? String else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
guard let components = URLComponents(string: trimmed),
|
||||
@@ -96,29 +138,12 @@ struct PushBuildConfig {
|
||||
}
|
||||
|
||||
private static func readEnum<T: RawRepresentable>(
|
||||
bundle: Bundle,
|
||||
readValue: (String) -> Any?,
|
||||
key: String,
|
||||
fallback: T)
|
||||
-> T where T.RawValue == String {
|
||||
guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return fallback }
|
||||
guard let raw = readValue(key) as? String else { return fallback }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return T(rawValue: trimmed) ?? T(rawValue: trimmed.lowercased()) ?? fallback
|
||||
}
|
||||
|
||||
private static let defaultAPNsEnvironment: PushAPNsEnvironment = .sandbox
|
||||
|
||||
private static func defaultRelayProfile(apnsEnvironment: PushAPNsEnvironment) -> PushRelayProfile {
|
||||
apnsEnvironment == .production ? .production : .deviceSandbox
|
||||
}
|
||||
|
||||
private static func defaultProofPolicy(relayProfile: PushRelayProfile) -> PushProofPolicy {
|
||||
switch relayProfile {
|
||||
case .production:
|
||||
.appleStrict
|
||||
case .deviceSandbox:
|
||||
.appleDevelopment
|
||||
case .simulatorSandbox:
|
||||
.internalSimulator
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
19
apps/ios/Sources/Push/PushEnrollmentConsent.swift
Normal file
19
apps/ios/Sources/Push/PushEnrollmentConsent.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
enum PushEnrollmentConsent {
|
||||
static let disclosureAcceptedKey = "push.enrollment.disclosureAccepted"
|
||||
|
||||
static var disclosureAccepted: Bool {
|
||||
UserDefaults.standard.bool(forKey: disclosureAcceptedKey)
|
||||
}
|
||||
|
||||
static func markDisclosureAccepted() {
|
||||
UserDefaults.standard.set(true, forKey: self.disclosureAcceptedKey)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
static func reset() {
|
||||
UserDefaults.standard.removeObject(forKey: self.disclosureAcceptedKey)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -69,7 +69,7 @@ actor PushRegistrationManager {
|
||||
async throws -> String {
|
||||
guard self.buildConfig.distribution == .official else {
|
||||
throw PushRelayError.relayMisconfigured(
|
||||
"Relay transport requires OpenClawPushDistribution=official")
|
||||
"Relay transport requires an official push build mode")
|
||||
}
|
||||
try Self.validateRelayContract(
|
||||
relayProfile: self.buildConfig.relayProfile,
|
||||
|
||||
@@ -76,6 +76,7 @@ Sources/Permissions/PermissionRequestBridge.swift
|
||||
Sources/Push/ExecApprovalNotificationBridge.swift
|
||||
Sources/Push/BackgroundAliveBeacon.swift
|
||||
Sources/Push/PushBuildConfig.swift
|
||||
Sources/Push/PushEnrollmentConsent.swift
|
||||
Sources/Push/PushRegistrationManager.swift
|
||||
Sources/Push/PushRelayClient.swift
|
||||
Sources/Push/PushRelayKeychainStore.swift
|
||||
|
||||
@@ -1377,6 +1377,24 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
#expect(center.addCalls == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor func apnsRegistrationRequiresDisclosureAndNotificationAuthorization() async {
|
||||
let center = MockBootstrapNotificationCenter()
|
||||
center.status = .authorized
|
||||
let appModel = NodeAppModel(notificationCenter: center)
|
||||
PushEnrollmentConsent.reset()
|
||||
defer { PushEnrollmentConsent.reset() }
|
||||
|
||||
#expect(await appModel._test_canPublishAPNsRegistration() == false)
|
||||
#expect(await appModel._test_canPublishAPNsRegistration(usesRelayTransport: false) == false)
|
||||
|
||||
PushEnrollmentConsent.markDisclosureAccepted()
|
||||
center.status = .notDetermined
|
||||
#expect(await appModel._test_canPublishAPNsRegistration() == false)
|
||||
|
||||
center.status = .authorized
|
||||
#expect(await appModel._test_canPublishAPNsRegistration())
|
||||
}
|
||||
|
||||
@Test @MainActor func chatPushWithoutSpeechReturnsUnavailableWhenNotificationsOff() async throws {
|
||||
let center = MockBootstrapNotificationCenter()
|
||||
center.status = .notDetermined
|
||||
|
||||
50
apps/ios/Tests/PushBuildConfigTests.swift
Normal file
50
apps/ios/Tests/PushBuildConfigTests.swift
Normal file
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct PushBuildConfigTests {
|
||||
@Test func `app store mode derives production relay contract`() {
|
||||
let config = PushBuildConfig(infoDictionary: [
|
||||
"OpenClawPushMode": "appStore",
|
||||
"OpenClawPushRelayBaseURL": "https://wrong.example.com",
|
||||
])
|
||||
|
||||
#expect(config.mode == .appStore)
|
||||
#expect(config.transport == .relay)
|
||||
#expect(config.distribution == .official)
|
||||
#expect(config.relayBaseURL?.absoluteString == "https://ios-push-relay.openclaw.ai")
|
||||
#expect(config.apnsEnvironment == .production)
|
||||
#expect(config.relayProfile == .production)
|
||||
#expect(config.proofPolicy == .appleStrict)
|
||||
}
|
||||
|
||||
@Test func `simulator sandbox mode derives internal proof contract`() {
|
||||
let config = PushBuildConfig(infoDictionary: [
|
||||
"OpenClawPushMode": "simulatorSandbox",
|
||||
"OpenClawPushRelayBaseURL": "https://staging-relay.example.com",
|
||||
])
|
||||
|
||||
#expect(config.mode == .simulatorSandbox)
|
||||
#expect(config.transport == .relay)
|
||||
#expect(config.distribution == .official)
|
||||
#expect(config.relayBaseURL?.absoluteString == "https://staging-relay.example.com")
|
||||
#expect(config.apnsEnvironment == .sandbox)
|
||||
#expect(config.relayProfile == .simulatorSandbox)
|
||||
#expect(config.proofPolicy == .internalSimulator)
|
||||
}
|
||||
|
||||
@Test func `local release mode remains direct production push`() {
|
||||
let config = PushBuildConfig(infoDictionary: [
|
||||
"OpenClawPushMode": "localProduction",
|
||||
"OpenClawPushRelayBaseURL": "https://ios-push-relay.openclaw.ai",
|
||||
])
|
||||
|
||||
#expect(config.mode == .localProduction)
|
||||
#expect(config.transport == .direct)
|
||||
#expect(config.distribution == .local)
|
||||
#expect(config.relayBaseURL == nil)
|
||||
#expect(config.apnsEnvironment == .production)
|
||||
#expect(config.relayProfile == .production)
|
||||
#expect(config.proofPolicy == .appleStrict)
|
||||
}
|
||||
}
|
||||
@@ -550,6 +550,20 @@ struct RootTabsSourceGuardTests {
|
||||
#expect(docsSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
|
||||
}
|
||||
|
||||
@Test func `push enrollment stays behind notification disclosure flow`() throws {
|
||||
let appSource = try String(contentsOf: Self.openClawAppSourceURL(), encoding: .utf8)
|
||||
let actionsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8)
|
||||
let modelSource = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(appSource.contains("PushEnrollmentConsent.disclosureAccepted"))
|
||||
#expect(appSource.contains("await Self.isNotificationAuthorizationAllowed()"))
|
||||
#expect(actionsSource.contains("PushEnrollmentConsent.markDisclosureAccepted()"))
|
||||
#expect(actionsSource.contains("self.registerForRemoteNotificationsIfEnrollmentReady()"))
|
||||
#expect(modelSource.contains("PushEnrollmentConsent.disclosureAccepted"))
|
||||
#expect(modelSource.contains("notifications_not_authorized"))
|
||||
#expect(modelSource.contains("enrollment_disclosure_not_accepted"))
|
||||
}
|
||||
|
||||
@Test func `gateway settings keeps pairing trust diagnostics and tailscale actions`() throws {
|
||||
let settingsSource = try String(contentsOf: Self.settingsProTabSourceURL(), encoding: .utf8)
|
||||
let sectionsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
|
||||
@@ -786,6 +800,13 @@ struct RootTabsSourceGuardTests {
|
||||
.appendingPathComponent("Sources/Design/SettingsProTab.swift")
|
||||
}
|
||||
|
||||
private static func openClawAppSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/OpenClawApp.swift")
|
||||
}
|
||||
|
||||
private static func notificationPermissionGuidanceDialogSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
|
||||
@@ -15,13 +15,12 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
var deviceLanguage = ""
|
||||
var locale = ""
|
||||
|
||||
@MainActor
|
||||
func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
|
||||
Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
|
||||
if waitForLoadingIndicator {
|
||||
Snapshot.snapshot(name)
|
||||
@@ -33,6 +32,7 @@ func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
|
||||
/// - Parameters:
|
||||
/// - name: The name of the snapshot
|
||||
/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait.
|
||||
@MainActor
|
||||
func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
|
||||
Snapshot.snapshot(name, timeWaitingForIdle: timeout)
|
||||
}
|
||||
@@ -52,6 +52,7 @@ enum SnapshotError: Error, CustomDebugStringConvertible {
|
||||
}
|
||||
|
||||
@objcMembers
|
||||
@MainActor
|
||||
open class Snapshot: NSObject {
|
||||
static var app: XCUIApplication?
|
||||
static var waitForAnimations = true
|
||||
@@ -59,6 +60,8 @@ open class Snapshot: NSObject {
|
||||
static var screenshotsDirectory: URL? {
|
||||
return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true)
|
||||
}
|
||||
static var deviceLanguage = ""
|
||||
static var currentLocale = ""
|
||||
|
||||
open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
|
||||
|
||||
@@ -103,17 +106,17 @@ open class Snapshot: NSObject {
|
||||
|
||||
do {
|
||||
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
|
||||
locale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
|
||||
currentLocale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
|
||||
} catch {
|
||||
NSLog("Couldn't detect/set locale...")
|
||||
}
|
||||
|
||||
if locale.isEmpty && !deviceLanguage.isEmpty {
|
||||
locale = Locale(identifier: deviceLanguage).identifier
|
||||
if currentLocale.isEmpty && !deviceLanguage.isEmpty {
|
||||
currentLocale = Locale(identifier: deviceLanguage).identifier
|
||||
}
|
||||
|
||||
if !locale.isEmpty {
|
||||
app.launchArguments += ["-AppleLocale", "\"\(locale)\""]
|
||||
if !currentLocale.isEmpty {
|
||||
app.launchArguments += ["-AppleLocale", "\"\(currentLocale)\""]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +168,7 @@ open class Snapshot: NSObject {
|
||||
}
|
||||
|
||||
let screenshot = XCUIScreen.main.screenshot()
|
||||
#if os(iOS)
|
||||
#if os(iOS) && !targetEnvironment(macCatalyst)
|
||||
let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image
|
||||
#else
|
||||
let image = screenshot.image
|
||||
@@ -181,7 +184,7 @@ open class Snapshot: NSObject {
|
||||
|
||||
let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png")
|
||||
#if swift(<5.0)
|
||||
UIImagePNGRepresentation(image)?.write(to: path, options: .atomic)
|
||||
try UIImagePNGRepresentation(image)?.write(to: path, options: .atomic)
|
||||
#else
|
||||
try image.pngData()?.write(to: path, options: .atomic)
|
||||
#endif
|
||||
@@ -281,6 +284,7 @@ private extension XCUIElementQuery {
|
||||
return self.containing(isNetworkLoadingIndicator)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
var deviceStatusBars: XCUIElementQuery {
|
||||
guard let app = Snapshot.app else {
|
||||
fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
||||
@@ -306,4 +310,4 @@ private extension CGFloat {
|
||||
|
||||
// Please don't remove the lines below
|
||||
// They are used to detect outdated configuration files
|
||||
// SnapshotHelperVersion [1.27]
|
||||
// SnapshotHelperVersion [1.30]
|
||||
|
||||
@@ -10,7 +10,24 @@ default_platform(:ios)
|
||||
|
||||
APP_STORE_APP_IDENTIFIER = "ai.openclawfoundation.app"
|
||||
DEFAULT_APP_STORE_CONNECT_KEYCHAIN_SERVICE = "openclaw-app-store-connect-key"
|
||||
DEFAULT_SNAPSHOT_DEVICES = ["iPhone 16 Pro Max", "iPad Pro 13-inch (M4)"].freeze
|
||||
DEFAULT_SNAPSHOT_DEVICE_FAMILIES = [
|
||||
{
|
||||
label: "iPhone",
|
||||
patterns: [
|
||||
/\AiPhone .* Pro Max\z/,
|
||||
/\AiPhone .* Plus\z/,
|
||||
/\AiPhone .*\z/
|
||||
]
|
||||
},
|
||||
{
|
||||
label: "13-inch iPad",
|
||||
patterns: [
|
||||
/\AiPad Pro 13-inch/,
|
||||
/\AiPad Air 13-inch/,
|
||||
/\AiPad .*13-inch/
|
||||
]
|
||||
}
|
||||
].freeze
|
||||
DEFAULT_WATCH_SNAPSHOT_DEVICE = "Apple Watch Ultra 3 (49mm)"
|
||||
WATCH_SCREENSHOT_MODE_DEFAULTS_KEY = "openclaw.watch.screenshotMode"
|
||||
WATCH_SNAPSHOT_STATUS_BAR_TIME = "09:41"
|
||||
@@ -77,11 +94,23 @@ end
|
||||
|
||||
def snapshot_devices
|
||||
raw = ENV["OPENCLAW_SNAPSHOT_DEVICES"].to_s.strip
|
||||
return DEFAULT_SNAPSHOT_DEVICES if raw.empty?
|
||||
return default_snapshot_devices if raw.empty?
|
||||
|
||||
raw.split(",").map(&:strip).reject(&:empty?)
|
||||
end
|
||||
|
||||
def default_snapshot_devices
|
||||
names = available_simulator_devices.map { |device| device["name"].to_s }.reject(&:empty?).uniq
|
||||
|
||||
DEFAULT_SNAPSHOT_DEVICE_FAMILIES.map do |family|
|
||||
match = family.fetch(:patterns).filter_map do |pattern|
|
||||
names.find { |name| name.match?(pattern) }
|
||||
end.first
|
||||
UI.user_error!("No available #{family.fetch(:label)} simulator found for App Store screenshots.") if match.nil?
|
||||
match
|
||||
end
|
||||
end
|
||||
|
||||
def watch_snapshot_device
|
||||
raw = ENV["OPENCLAW_WATCH_SNAPSHOT_DEVICE"].to_s.strip
|
||||
raw.empty? ? DEFAULT_WATCH_SNAPSHOT_DEVICE : raw
|
||||
@@ -113,6 +142,51 @@ def resolve_simulator_device(name)
|
||||
fallback
|
||||
end
|
||||
|
||||
def install_ready_for_review_edit_state_lookup!
|
||||
require "spaceship"
|
||||
|
||||
app_class = Spaceship::ConnectAPI::App
|
||||
app_class.class_eval do
|
||||
unless method_defined?(:openclaw_get_edit_app_store_version_without_ready_for_review)
|
||||
alias_method :openclaw_get_edit_app_store_version_without_ready_for_review, :get_edit_app_store_version
|
||||
end
|
||||
|
||||
unless method_defined?(:openclaw_fetch_edit_app_info_without_ready_for_review)
|
||||
alias_method :openclaw_fetch_edit_app_info_without_ready_for_review, :fetch_edit_app_info
|
||||
end
|
||||
|
||||
def get_edit_app_store_version(client: nil, platform: nil, includes: Spaceship::ConnectAPI::AppStoreVersion::ESSENTIAL_INCLUDES)
|
||||
version = openclaw_get_edit_app_store_version_without_ready_for_review(client: client, platform: platform, includes: includes)
|
||||
return version if version
|
||||
|
||||
# First public releases can leave the only version in READY_FOR_REVIEW.
|
||||
# Fastlane 2.236.1 excludes that state and then tries to create an illegal
|
||||
# second version; use the existing review-ready version as the edit target.
|
||||
client ||= Spaceship::ConnectAPI
|
||||
platform ||= Spaceship::ConnectAPI::Platform::IOS
|
||||
filter = {
|
||||
appVersionState: Spaceship::ConnectAPI::AppStoreVersion::AppVersionState::READY_FOR_REVIEW,
|
||||
platform: platform
|
||||
}
|
||||
|
||||
get_app_store_versions(client: client, filter: filter, includes: includes)
|
||||
.sort_by { |candidate| Gem::Version.new(candidate.version_string) }
|
||||
.last
|
||||
end
|
||||
|
||||
def fetch_edit_app_info(client: nil, includes: Spaceship::ConnectAPI::AppInfo::ESSENTIAL_INCLUDES)
|
||||
app_info = openclaw_fetch_edit_app_info_without_ready_for_review(client: client, includes: includes)
|
||||
return app_info if app_info
|
||||
|
||||
client ||= Spaceship::ConnectAPI
|
||||
client
|
||||
.get_app_infos(app_id: id, includes: includes)
|
||||
.to_models
|
||||
.find { |candidate| candidate.state == Spaceship::ConnectAPI::AppInfo::State::READY_FOR_REVIEW }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def bundle_identifier_for_product(product_path)
|
||||
info_plist_path = File.join(product_path, "Info.plist")
|
||||
UI.user_error!("Expected Info.plist at #{info_plist_path}.") unless File.exist?(info_plist_path)
|
||||
@@ -210,6 +284,7 @@ def normalize_watch_screenshot_status_bar(path)
|
||||
script = <<~SWIFT
|
||||
import AppKit
|
||||
import Foundation
|
||||
import ImageIO
|
||||
|
||||
let path = CommandLine.arguments[1]
|
||||
let timeText = CommandLine.arguments[2]
|
||||
@@ -221,36 +296,37 @@ def normalize_watch_screenshot_status_bar(path)
|
||||
exit(2)
|
||||
}
|
||||
|
||||
let width = CGFloat(cgImage.width)
|
||||
let height = CGFloat(cgImage.height)
|
||||
guard let bitmap = NSBitmapImageRep(
|
||||
bitmapDataPlanes: nil,
|
||||
pixelsWide: Int(width),
|
||||
pixelsHigh: Int(height),
|
||||
bitsPerSample: 8,
|
||||
samplesPerPixel: 4,
|
||||
hasAlpha: true,
|
||||
isPlanar: false,
|
||||
colorSpaceName: .deviceRGB,
|
||||
bytesPerRow: 0,
|
||||
bitsPerPixel: 0),
|
||||
let graphicsContext = NSGraphicsContext(bitmapImageRep: bitmap)
|
||||
let width = cgImage.width
|
||||
let height = cgImage.height
|
||||
let drawWidth = CGFloat(width)
|
||||
let drawHeight = CGFloat(height)
|
||||
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
||||
guard let bitmapContext = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: width * 4,
|
||||
space: colorSpace,
|
||||
bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)
|
||||
else {
|
||||
fputs("Failed to create normalized screenshot bitmap at \\(path)\\n", stderr)
|
||||
exit(3)
|
||||
}
|
||||
|
||||
bitmap.size = NSSize(width: width, height: height)
|
||||
let graphicsContext = NSGraphicsContext(cgContext: bitmapContext, flipped: false)
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = graphicsContext
|
||||
NSColor.black.setFill()
|
||||
NSBezierPath(rect: NSRect(x: 0, y: 0, width: drawWidth, height: drawHeight)).fill()
|
||||
source.draw(
|
||||
in: NSRect(x: 0, y: 0, width: width, height: height),
|
||||
from: NSRect(x: 0, y: 0, width: width, height: height),
|
||||
operation: .copy,
|
||||
in: NSRect(x: 0, y: 0, width: drawWidth, height: drawHeight),
|
||||
from: NSRect(x: 0, y: 0, width: drawWidth, height: drawHeight),
|
||||
operation: .sourceOver,
|
||||
fraction: 1.0)
|
||||
|
||||
NSColor.black.setFill()
|
||||
NSBezierPath(rect: NSRect(x: width - 146, y: height - 92, width: 124, height: 70)).fill()
|
||||
NSBezierPath(rect: NSRect(x: drawWidth - 146, y: drawHeight - 92, width: 124, height: 70)).fill()
|
||||
|
||||
let paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.alignment = .right
|
||||
@@ -260,17 +336,26 @@ def normalize_watch_screenshot_status_bar(path)
|
||||
.paragraphStyle: paragraphStyle,
|
||||
]
|
||||
timeText.draw(
|
||||
in: NSRect(x: width - 134, y: height - 82, width: 102, height: 44),
|
||||
in: NSRect(x: drawWidth - 134, y: drawHeight - 82, width: 102, height: 44),
|
||||
withAttributes: attributes)
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
|
||||
guard let png = bitmap.representation(using: .png, properties: [:])
|
||||
guard let output = bitmapContext.makeImage(),
|
||||
let destination = CGImageDestinationCreateWithURL(
|
||||
URL(fileURLWithPath: path) as CFURL,
|
||||
"public.png" as CFString,
|
||||
1,
|
||||
nil)
|
||||
else {
|
||||
fputs("Failed to encode normalized screenshot at \\(path)\\n", stderr)
|
||||
exit(4)
|
||||
}
|
||||
|
||||
try png.write(to: URL(fileURLWithPath: path))
|
||||
CGImageDestinationAddImage(destination, output, nil)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
fputs("Failed to write normalized screenshot at \\(path)\\n", stderr)
|
||||
exit(5)
|
||||
}
|
||||
SWIFT
|
||||
|
||||
Tempfile.create(["openclaw-watch-status-bar", ".swift"]) do |file|
|
||||
@@ -754,6 +839,11 @@ def prepare_app_store_release!(version:, build_number:)
|
||||
release_xcconfig
|
||||
end
|
||||
|
||||
def validate_app_store_ipa!(ipa_path)
|
||||
script_path = File.join(repo_root, "scripts", "ios-validate-app-store-ipa.sh")
|
||||
sh(shell_join(["bash", script_path, "--ipa", ipa_path]))
|
||||
end
|
||||
|
||||
def build_app_store_release(context)
|
||||
version = context[:version]
|
||||
project_path = File.join(ios_root, "OpenClaw.xcodeproj")
|
||||
@@ -804,6 +894,7 @@ def build_app_store_release(context)
|
||||
UI.user_error!("xcodebuild export produced multiple IPAs in #{output_directory}: #{exported_ipas.join(", ")}") if exported_ipas.length > 1
|
||||
exported_ipa = exported_ipas.first
|
||||
FileUtils.mv(exported_ipa, expected_ipa_path) unless exported_ipa == expected_ipa_path
|
||||
validate_app_store_ipa!(expected_ipa_path)
|
||||
|
||||
{
|
||||
archive_path: archive_path,
|
||||
@@ -923,25 +1014,12 @@ platform :ios do
|
||||
ENV.delete("XCODE_XCCONFIG_FILE")
|
||||
end
|
||||
|
||||
desc "Build + upload an App Store distribution build to App Store Connect"
|
||||
lane :app_store do
|
||||
context = prepare_app_store_context(require_api_key: true)
|
||||
build = build_app_store_release(context)
|
||||
|
||||
upload_to_testflight(
|
||||
api_key: context[:api_key],
|
||||
ipa: build[:ipa_path],
|
||||
skip_waiting_for_build_processing: true,
|
||||
uses_non_exempt_encryption: false
|
||||
)
|
||||
|
||||
UI.success("Uploaded iOS App Store build: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
|
||||
ensure
|
||||
ENV.delete("XCODE_XCCONFIG_FILE")
|
||||
end
|
||||
|
||||
desc "Generate screenshots, update App Store version metadata, then upload an App Store build"
|
||||
lane :release_upload do
|
||||
unless ENV["OPENCLAW_IOS_RELEASE_WRAPPER"] == "1"
|
||||
UI.user_error!("Use `pnpm ios:release:upload`; direct Fastlane TestFlight upload is disabled.")
|
||||
end
|
||||
|
||||
release_signing_check!
|
||||
preserve_local_signing do
|
||||
screenshots
|
||||
@@ -968,6 +1046,7 @@ platform :ios do
|
||||
|
||||
desc "Upload App Store metadata (and optionally screenshots)"
|
||||
lane :metadata do
|
||||
install_ready_for_review_edit_state_lookup!
|
||||
sync_ios_versioning!
|
||||
version_metadata = read_ios_version_metadata
|
||||
api_key = app_store_connect_api_key_config
|
||||
|
||||
@@ -104,7 +104,7 @@ Generate deterministic App Store screenshots:
|
||||
pnpm ios:screenshots
|
||||
```
|
||||
|
||||
The screenshot lane runs the app with `--openclaw-screenshot-mode`, which enters the built-in connected screenshot fixture instead of pairing with a live gateway. By default it captures the tab set on `iPhone 16 Pro Max` and `iPad Pro 13-inch (M4)`; override devices with a comma-separated `OPENCLAW_SNAPSHOT_DEVICES` value when the requested simulators exist locally.
|
||||
The screenshot lane runs the app with `--openclaw-screenshot-mode`, which enters the built-in connected screenshot fixture instead of pairing with a live gateway. By default it chooses one available large iPhone simulator and one available 13-inch iPad simulator from the installed Xcode runtime; override devices with a comma-separated `OPENCLAW_SNAPSHOT_DEVICES` value when the requested simulators exist locally.
|
||||
|
||||
Upload to App Store Connect:
|
||||
|
||||
@@ -112,12 +112,9 @@ Upload to App Store Connect:
|
||||
pnpm ios:release:upload
|
||||
```
|
||||
|
||||
Direct Fastlane entry point:
|
||||
|
||||
```bash
|
||||
cd apps/ios
|
||||
fastlane ios release_upload
|
||||
```
|
||||
Direct Fastlane TestFlight upload is disabled. Use the package script so the
|
||||
release wrapper, App Store push mode, and exported-IPA validation gate all run
|
||||
in the same path.
|
||||
|
||||
Maintainer recovery path for a fresh clone on the same Mac:
|
||||
|
||||
@@ -144,13 +141,7 @@ fastlane ios auth_check
|
||||
pnpm ios:version:pin -- --from-gateway
|
||||
```
|
||||
|
||||
5. Set the official relay URL before release:
|
||||
|
||||
```bash
|
||||
export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
|
||||
```
|
||||
|
||||
6. Upload:
|
||||
5. Upload:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:upload
|
||||
@@ -159,6 +150,7 @@ pnpm ios:release:upload
|
||||
Quick verification after upload:
|
||||
|
||||
- confirm `apps/ios/build/app-store/OpenClaw-<version>.ipa` exists
|
||||
- confirm Fastlane validates the exported IPA before upload
|
||||
- confirm Fastlane prints `Uploaded iOS App Store build: version=<version> short=<short> build=<build>`
|
||||
- remember that App Store Connect/TestFlight processing can take a few minutes after the upload succeeds
|
||||
|
||||
@@ -175,5 +167,7 @@ Versioning rules:
|
||||
- `pnpm ios:version:check` validates that checked-in iOS version artifacts are in sync
|
||||
- The release flow regenerates `apps/ios/OpenClaw.xcodeproj` from `apps/ios/project.yml` before archiving
|
||||
- Local App Store signing uses a temporary generated xcconfig with profile names from `apps/ios/Config/AppStoreSigning.json` and leaves local development signing overrides untouched
|
||||
- App Store release uses `OpenClawPushMode=appStore`, which derives the canonical production hosted relay, production APNs, production relay profile, and `appleStrict` proof. The release lane rejects custom production relay URL overrides.
|
||||
- The exported IPA is validated before upload by inspecting its push mode, signed entitlements, and embedded App Store profile.
|
||||
- `pnpm ios:release:upload` generates and uploads screenshots and release notes before archiving, then uploads the IPA without submitting it for App Review
|
||||
- See `apps/ios/VERSIONING.md` for the detailed workflow
|
||||
|
||||
@@ -2,10 +2,9 @@ project("OpenClaw.xcodeproj")
|
||||
scheme("OpenClawUITests")
|
||||
configuration("Debug")
|
||||
|
||||
devices([
|
||||
"iPhone 16 Pro Max",
|
||||
"iPad Pro 13-inch (M4)",
|
||||
])
|
||||
# The Fastfile screenshot lane resolves concrete device names from the installed
|
||||
# Xcode simulators. Fastlane validates Snapfile devices before lane overrides, so
|
||||
# this file intentionally does not hardcode simulator model names.
|
||||
|
||||
languages([
|
||||
"en-US",
|
||||
|
||||
@@ -122,21 +122,13 @@ targets:
|
||||
Debug:
|
||||
OPENCLAW_CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements
|
||||
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT: development
|
||||
OPENCLAW_PUSH_TRANSPORT: direct
|
||||
OPENCLAW_PUSH_DISTRIBUTION: local
|
||||
OPENCLAW_PUSH_MODE: localSandbox
|
||||
OPENCLAW_PUSH_RELAY_BASE_URL: ""
|
||||
OPENCLAW_PUSH_APNS_ENVIRONMENT: sandbox
|
||||
OPENCLAW_PUSH_RELAY_PROFILE: deviceSandbox
|
||||
OPENCLAW_PUSH_PROOF_POLICY: appleDevelopment
|
||||
Release:
|
||||
OPENCLAW_CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements
|
||||
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT: production
|
||||
OPENCLAW_PUSH_TRANSPORT: direct
|
||||
OPENCLAW_PUSH_DISTRIBUTION: local
|
||||
OPENCLAW_PUSH_MODE: localProduction
|
||||
OPENCLAW_PUSH_RELAY_BASE_URL: ""
|
||||
OPENCLAW_PUSH_APNS_ENVIRONMENT: production
|
||||
OPENCLAW_PUSH_RELAY_PROFILE: production
|
||||
OPENCLAW_PUSH_PROOF_POLICY: appleStrict
|
||||
info:
|
||||
path: Sources/Info.plist
|
||||
properties:
|
||||
@@ -178,12 +170,8 @@ targets:
|
||||
NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for talk mode and voice wake.
|
||||
NSSupportsLiveActivities: true
|
||||
ITSAppUsesNonExemptEncryption: false
|
||||
OpenClawPushTransport: "$(OPENCLAW_PUSH_TRANSPORT)"
|
||||
OpenClawPushDistribution: "$(OPENCLAW_PUSH_DISTRIBUTION)"
|
||||
OpenClawPushMode: "$(OPENCLAW_PUSH_MODE)"
|
||||
OpenClawPushRelayBaseURL: "$(OPENCLAW_PUSH_RELAY_BASE_URL)"
|
||||
OpenClawPushAPNsEnvironment: "$(OPENCLAW_PUSH_APNS_ENVIRONMENT)"
|
||||
OpenClawPushRelayProfile: "$(OPENCLAW_PUSH_RELAY_PROFILE)"
|
||||
OpenClawPushProofPolicy: "$(OPENCLAW_PUSH_PROOF_POLICY)"
|
||||
UISupportedInterfaceOrientations:
|
||||
- UIInterfaceOrientationPortrait
|
||||
- UIInterfaceOrientationPortraitUpsideDown
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Bundled A2UI runtime resource embedded by OpenClawKit.
|
||||
var __defProp$1 = Object.defineProperty;
|
||||
var __exportAll = (all, no_symbols) => {
|
||||
let target = {};
|
||||
@@ -11936,6 +11935,10 @@ var __runInitializers = function(thisArg, initializers, value) {
|
||||
};
|
||||
return _classThis;
|
||||
})();
|
||||
/**
|
||||
* Canvas A2UI browser bootstrap that installs theme overrides and native bridge
|
||||
* helpers.
|
||||
*/
|
||||
const modalStyles = i$10`
|
||||
dialog {
|
||||
position: fixed;
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
da3373338b7f9c5f5639ad8233a32897d2346a0babe69a77386a7bff154cdcb1 plugin-sdk-api-baseline.json
|
||||
17404d885e0d64ebc8e3c99443921058a8f1aebf76a5e612eb1f0cd7817d48f0 plugin-sdk-api-baseline.jsonl
|
||||
57b3f65c9d8c4edddea6ffa86584756234e761cc1cdd561e4f57c8c072baaad2 plugin-sdk-api-baseline.json
|
||||
1c20edb5599d0050382a32272ff3708e969f4605a2dca3db8b5cef9ab7680bd6 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -155,6 +155,7 @@ Notes:
|
||||
|
||||
- `onchar` still responds to explicit @mentions.
|
||||
- `channels.mattermost.requireMention` is honored for legacy configs but `chatmode` is preferred.
|
||||
- After the bot sends a visible reply in a channel thread, later messages in that same thread are answered without a new @mention or `onchar` prefix, so multi-turn thread conversations keep flowing. Participation is remembered for 7 days of thread inactivity (refreshed on each reply) and persists across gateway restarts. Threads the bot has only observed are unaffected; start a new top-level message to require an explicit mention again.
|
||||
|
||||
## Threading and sessions
|
||||
|
||||
|
||||
@@ -151,6 +151,7 @@ to a group, then mention it or configure the group to run without a mention.
|
||||
groups: {
|
||||
"*": {
|
||||
requireMention: true,
|
||||
commandLevel: "all",
|
||||
historyLimit: 50,
|
||||
tools: { deny: ["exec", "read", "write"] },
|
||||
},
|
||||
@@ -158,6 +159,7 @@ to a group, then mention it or configure the group to run without a mention.
|
||||
name: "Release room",
|
||||
requireMention: false,
|
||||
ignoreOtherMentions: true,
|
||||
commandLevel: "safety",
|
||||
historyLimit: 20,
|
||||
prompt: "Keep replies short and operational.",
|
||||
},
|
||||
@@ -172,6 +174,9 @@ to a group, then mention it or configure the group to run without a mention.
|
||||
settings include:
|
||||
|
||||
- `requireMention`: require an @mention before the bot replies. Default: `true`.
|
||||
- `commandLevel`: control which built-in slash commands can run in groups.
|
||||
Default: `all`, which preserves the pre-existing QQBot group behavior when the
|
||||
setting is omitted.
|
||||
- `ignoreOtherMentions`: drop messages that mention someone else but not the bot.
|
||||
- `historyLimit`: keep recent non-mention group messages as context for the next mentioned turn. Set `0` to disable.
|
||||
- `tools`: allow/deny tools for the whole group.
|
||||
@@ -179,6 +184,17 @@ settings include:
|
||||
- `name`: friendly label used in logs and group context.
|
||||
- `prompt`: per-group behavior prompt appended to the agent context.
|
||||
|
||||
`commandLevel` accepts:
|
||||
|
||||
- `all`: keep recognized built-in commands available as before. Some commands may
|
||||
stay hidden from menus, but authorized users can still run them in the group.
|
||||
- `safety`: allow common collaboration commands such as `/help`, `/btw`, and
|
||||
`/stop`; ask users to run sensitive commands such as `/config`, `/tools`, and
|
||||
`/bash` in private chat.
|
||||
- `strict`: only allow the group-session controls needed for strict group
|
||||
operation. `/stop` still stays urgent so an authorized sender can interrupt an
|
||||
active run.
|
||||
|
||||
Old QQBot `toolPolicy` entries are retired. Run `openclaw doctor --fix` to migrate them to `tools`.
|
||||
|
||||
Activation modes are `mention` and `always`. `requireMention: true` maps to
|
||||
|
||||
@@ -198,7 +198,7 @@ Every lane uploads GitHub artifacts. When `CLAWGRIT_REPORTS_TOKEN` is configured
|
||||
|
||||
## Full Release Validation
|
||||
|
||||
`Full Release Validation` is the manual umbrella workflow for "run everything before release." It accepts a branch, tag, or full commit SHA, dispatches the manual `CI` workflow with that target, dispatches `Plugin Prerelease` for release-only plugin/package/static/Docker proof, and dispatches `OpenClaw Release Checks` for install smoke, package acceptance, cross-OS package checks, QA Lab parity, Matrix, and Telegram lanes. Stable and full profiles always include exhaustive live/E2E and Docker release-path soak coverage; the beta profile can opt in with `run_release_soak=true`. The canonical package Telegram E2E runs inside Package Acceptance, so a full candidate does not start a duplicate live poller. After publishing, pass `release_package_spec` to reuse the shipped npm package across release checks, Package Acceptance, Docker, cross-OS, and Telegram without rebuilding. Use `npm_telegram_package_spec` only for a focused published-package Telegram rerun. The Codex plugin live package lane uses the same selected state by default: published `release_package_spec=openclaw@<tag>` derives `codex_plugin_spec=npm:@openclaw/codex@<tag>`, while SHA/artifact runs pack `extensions/codex` from the selected ref. Set `codex_plugin_spec` explicitly for custom plugin sources such as `npm:`, `npm-pack:`, or `git:` specs.
|
||||
`Full Release Validation` is the manual umbrella workflow for "run everything before release." It accepts a branch, tag, or full commit SHA, dispatches the manual `CI` workflow with that target, dispatches `Plugin Prerelease` for release-only plugin/package/static/Docker proof, and dispatches `OpenClaw Release Checks` for install smoke, package acceptance, cross-OS package checks, maturity scorecard rendering from QA profile evidence, QA Lab parity, Matrix, and Telegram lanes. Stable and full profiles always include exhaustive live/E2E and Docker release-path soak coverage; the beta profile can opt in with `run_release_soak=true`. The canonical package Telegram E2E runs inside Package Acceptance, so a full candidate does not start a duplicate live poller. After publishing, pass `release_package_spec` to reuse the shipped npm package across release checks, Package Acceptance, Docker, cross-OS, and Telegram without rebuilding. Use `npm_telegram_package_spec` only for a focused published-package Telegram rerun. The Codex plugin live package lane uses the same selected state by default: published `release_package_spec=openclaw@<tag>` derives `codex_plugin_spec=npm:@openclaw/codex@<tag>`, while SHA/artifact runs pack `extensions/codex` from the selected ref. Set `codex_plugin_spec` explicitly for custom plugin sources such as `npm:`, `npm-pack:`, or `git:` specs.
|
||||
|
||||
See [Full release validation](/reference/full-release-validation) for the
|
||||
stage matrix, exact workflow job names, profile differences, artifacts, and
|
||||
|
||||
@@ -23,8 +23,8 @@ OpenClaw agent or Gateway.
|
||||
|
||||
```bash
|
||||
openclaw skills search "calendar"
|
||||
openclaw skills install <slug>
|
||||
openclaw skills update <slug>
|
||||
openclaw skills install @owner/<slug>
|
||||
openclaw skills update @owner/<slug>
|
||||
openclaw skills verify <slug>
|
||||
|
||||
openclaw plugins search "calendar"
|
||||
|
||||
@@ -24,13 +24,13 @@ where you have publisher access.
|
||||
Skills are published from a skill folder. The public page is:
|
||||
|
||||
```text
|
||||
https://clawhub.ai/<owner>/<slug>
|
||||
https://clawhub.ai/<owner>/skills/<slug>
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
https://clawhub.ai/alice/review-helper
|
||||
https://clawhub.ai/alice/skills/review-helper
|
||||
```
|
||||
|
||||
The publish request includes the selected owner, slug, version, changelog, and
|
||||
|
||||
@@ -25,16 +25,16 @@ Related:
|
||||
```bash
|
||||
openclaw skills search "calendar"
|
||||
openclaw skills search --limit 20 --json
|
||||
openclaw skills install <slug>
|
||||
openclaw skills install <slug> --version <version>
|
||||
openclaw skills install @owner/<slug>
|
||||
openclaw skills install @owner/<slug> --version <version>
|
||||
openclaw skills install git:owner/repo
|
||||
openclaw skills install git:owner/repo@main
|
||||
openclaw skills install ./path/to/skill --as custom-name
|
||||
openclaw skills install <slug> --force
|
||||
openclaw skills install <slug> --agent <id>
|
||||
openclaw skills install <slug> --global
|
||||
openclaw skills update <slug>
|
||||
openclaw skills update <slug> --global
|
||||
openclaw skills install @owner/<slug> --force
|
||||
openclaw skills install @owner/<slug> --agent <id>
|
||||
openclaw skills install @owner/<slug> --global
|
||||
openclaw skills update @owner/<slug>
|
||||
openclaw skills update @owner/<slug> --global
|
||||
openclaw skills update --all
|
||||
openclaw skills update --all --agent <id>
|
||||
openclaw skills update --all --global
|
||||
@@ -64,8 +64,8 @@ openclaw skills workshop reject <proposal-id> --reason "Not reusable"
|
||||
openclaw skills workshop quarantine <proposal-id> --reason "Needs security review"
|
||||
```
|
||||
|
||||
`search`, `update`, and `verify` use ClawHub directly. `install <slug>` installs
|
||||
a ClawHub skill, `install git:owner/repo[@ref]` clones a Git skill, and
|
||||
`search`, `update`, and `verify` use ClawHub directly. `install @owner/<slug>`
|
||||
installs a ClawHub skill, `install git:owner/repo[@ref]` clones a Git skill, and
|
||||
`install ./path` copies a local skill directory. By default, `install`, `update`,
|
||||
and `verify` target the active workspace `skills/` directory; with `--global`,
|
||||
they target the shared managed skills directory. `list`/`info`/`check` still
|
||||
@@ -94,15 +94,15 @@ Notes:
|
||||
`SKILL.md`.
|
||||
- `install --as <slug>` overrides the inferred slug for Git and local directory
|
||||
installs.
|
||||
- `install --version <version>` applies only to ClawHub skill slugs.
|
||||
- `install --version <version>` applies only to ClawHub skill refs.
|
||||
- `install --force` overwrites an existing workspace skill folder for the same
|
||||
slug.
|
||||
- `--global` targets the shared managed skills directory and cannot be combined
|
||||
with `--agent <id>`.
|
||||
- `--agent <id>` targets one configured agent workspace and overrides current
|
||||
working directory inference.
|
||||
- `update <slug>` updates a single tracked skill. Add `--global` to target the
|
||||
shared managed skills directory instead of the workspace.
|
||||
- `update @owner/<slug>` updates a single tracked skill. Add `--global` to
|
||||
target the shared managed skills directory instead of the workspace.
|
||||
- `update --all` updates tracked ClawHub installs in the selected workspace, or
|
||||
in the shared managed skills directory when combined with `--global`.
|
||||
- `verify <slug>` prints ClawHub's `clawhub.skill.verify.v1` JSON envelope by
|
||||
|
||||
@@ -68,6 +68,14 @@
|
||||
"source": "/reference/openclaw-sdk-api-design",
|
||||
"destination": "/gateway/external-apps"
|
||||
},
|
||||
{
|
||||
"source": "/reference/maturity-scorecard",
|
||||
"destination": "/maturity/scorecard"
|
||||
},
|
||||
{
|
||||
"source": "/reference/maturity-taxonomy",
|
||||
"destination": "/maturity/taxonomy"
|
||||
},
|
||||
{
|
||||
"source": "/mcp",
|
||||
"destination": "/cli/mcp"
|
||||
@@ -1852,6 +1860,8 @@
|
||||
{
|
||||
"group": "Release and CI",
|
||||
"pages": [
|
||||
"maturity/scorecard",
|
||||
"maturity/taxonomy",
|
||||
"reference/RELEASING",
|
||||
"reference/full-release-validation",
|
||||
"reference/release-performance-sweep",
|
||||
|
||||
@@ -602,7 +602,7 @@ See [Inferred commitments](/concepts/commitments).
|
||||
- `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `wss://` for public hosts; plaintext `ws://` is accepted only for loopback, LAN, link-local, `.local`, `.ts.net`, and Tailscale CGNAT hosts.
|
||||
- `remote.remotePort`: gateway port on the remote SSH host. Defaults to `18789`; use this when the local tunnel port differs from the remote gateway port.
|
||||
- `gateway.remote.token` / `.password` are remote-client credential fields. They do not configure gateway auth by themselves.
|
||||
- `gateway.push.apns.relay.baseUrl`: base HTTPS URL for the external APNs relay used by official/TestFlight iOS builds after they publish relay-backed registrations to the gateway. This URL must match the relay URL compiled into the iOS build.
|
||||
- `gateway.push.apns.relay.baseUrl`: base HTTPS URL for the external APNs relay used after relay-backed iOS builds publish registrations to the gateway. Public App Store/TestFlight builds use the hosted OpenClaw relay. Custom relay URLs must match a deliberately separate iOS build/deployment path whose relay URL points at that relay.
|
||||
- `gateway.push.apns.relay.timeoutMs`: gateway-to-relay send timeout in milliseconds. Defaults to `10000`.
|
||||
- Relay-backed registrations are delegated to a specific gateway identity. The paired iOS app fetches `gateway.identity.get`, includes that identity in the relay registration, and forwards a registration-scoped send grant to the gateway. Another gateway cannot reuse that stored registration.
|
||||
- `OPENCLAW_APNS_RELAY_BASE_URL` / `OPENCLAW_APNS_RELAY_TIMEOUT_MS`: temporary env overrides for the relay config above.
|
||||
|
||||
@@ -337,9 +337,9 @@ candidate contains redacted secret placeholders such as `***`.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Enable relay-backed push for official iOS builds">
|
||||
Relay-backed push uses the hosted OpenClaw relay by default: `https://ios-push-relay.openclaw.ai`.
|
||||
Relay-backed push for public App Store/TestFlight builds uses the hosted OpenClaw relay: `https://ios-push-relay.openclaw.ai`.
|
||||
|
||||
To use a custom relay, set this in gateway config:
|
||||
Custom relay deployments require a deliberately separate iOS build/deployment path whose relay URL matches the gateway relay URL. If you are using a custom relay build, set this in gateway config:
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -369,12 +369,12 @@ candidate contains redacted secret placeholders such as `***`.
|
||||
- Uses a registration-scoped send grant forwarded by the paired iOS app. The gateway does not need a deployment-wide relay token.
|
||||
- Binds each relay-backed registration to the gateway identity that the iOS app paired with, so another gateway cannot reuse the stored registration.
|
||||
- Keeps local/manual iOS builds on direct APNs. Relay-backed sends apply only to official distributed builds that registered through the relay.
|
||||
- Must match the relay base URL baked into the official/TestFlight iOS build, so registration and send traffic reach the same relay deployment.
|
||||
- Must match the relay base URL baked into the iOS build, so registration and send traffic reach the same relay deployment.
|
||||
|
||||
End-to-end flow:
|
||||
|
||||
1. Install an official/TestFlight iOS build.
|
||||
2. Optional: configure `gateway.push.apns.relay.baseUrl` on the gateway only when using a custom relay deployment.
|
||||
2. Optional: configure `gateway.push.apns.relay.baseUrl` on the gateway only when using a deliberately separate custom relay build.
|
||||
3. Pair the iOS app to the gateway and let both node and operator sessions connect.
|
||||
4. The iOS app fetches the gateway identity, registers with the relay using App Attest plus the app receipt, and then publishes the relay-backed `push.apns.register` payload to the paired gateway.
|
||||
5. The gateway stores the relay handle and send grant, then uses them for `push.test`, wake nudges, and reconnect wakes.
|
||||
@@ -387,7 +387,7 @@ candidate contains redacted secret placeholders such as `***`.
|
||||
Compatibility note:
|
||||
|
||||
- `OPENCLAW_APNS_RELAY_BASE_URL` and `OPENCLAW_APNS_RELAY_TIMEOUT_MS` still work as temporary env overrides.
|
||||
- Custom gateway relay URLs must match the relay base URL baked into the official/TestFlight iOS build.
|
||||
- Custom gateway relay URLs must match the relay base URL baked into the iOS build. The public App Store release lane rejects custom iOS relay URL overrides.
|
||||
- `OPENCLAW_APNS_RELAY_ALLOW_HTTP=true` remains a loopback-only development escape hatch; do not persist HTTP relay URLs in config.
|
||||
|
||||
See [iOS App](/platforms/ios#relay-backed-push-for-official-builds) for the end-to-end flow and [Authentication and trust flow](/platforms/ios#authentication-and-trust-flow) for the relay security model.
|
||||
|
||||
@@ -346,10 +346,10 @@ lives on the [First-run FAQ](/help/faq-first-run).
|
||||
```bash
|
||||
openclaw skills search "calendar"
|
||||
openclaw skills search --limit 20
|
||||
openclaw skills install <skill-slug>
|
||||
openclaw skills install <skill-slug> --version <version>
|
||||
openclaw skills install <skill-slug> --force
|
||||
openclaw skills install <skill-slug> --global
|
||||
openclaw skills install @owner/<skill-slug>
|
||||
openclaw skills install @owner/<skill-slug> --version <version>
|
||||
openclaw skills install @owner/<skill-slug> --force
|
||||
openclaw skills install @owner/<skill-slug> --global
|
||||
openclaw skills update --all
|
||||
openclaw skills update --all --global
|
||||
openclaw skills list --eligible
|
||||
@@ -433,11 +433,11 @@ lives on the [First-run FAQ](/help/faq-first-run).
|
||||
Install skills:
|
||||
|
||||
```bash
|
||||
openclaw skills install <skill-slug>
|
||||
openclaw skills install @owner/<skill-slug>
|
||||
openclaw skills update --all
|
||||
```
|
||||
|
||||
Native installs land in the active workspace `skills/` directory. For shared skills across all local agents, use `openclaw skills install <slug> --global` (or place them manually in `~/.openclaw/skills/<name>/SKILL.md`). If only some agents should see a shared install, configure `agents.defaults.skills` or `agents.list[].skills`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills), [Skills config](/tools/skills-config), and [ClawHub](/tools/clawhub).
|
||||
Native installs land in the active workspace `skills/` directory. For shared skills across all local agents, use `openclaw skills install @owner/<skill-slug> --global` (or place them manually in `~/.openclaw/skills/<name>/SKILL.md`). If only some agents should see a shared install, configure `agents.defaults.skills` or `agents.list[].skills`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills), [Skills config](/tools/skills-config), and [ClawHub](/tools/clawhub).
|
||||
|
||||
</Accordion>
|
||||
|
||||
|
||||
@@ -110,14 +110,18 @@ systemctl --user daemon-reload
|
||||
### Windows (Scheduled Task)
|
||||
|
||||
Default task name is `OpenClaw Gateway` (or `OpenClaw Gateway (<profile>)`).
|
||||
The task script lives under your state dir.
|
||||
The task script lives under your state dir as `gateway.cmd`; current installs may
|
||||
also create a windowless `gateway.vbs` launcher that Task Scheduler runs instead
|
||||
of opening `gateway.cmd` directly.
|
||||
|
||||
```powershell
|
||||
schtasks /Delete /F /TN "OpenClaw Gateway"
|
||||
Remove-Item -Force "$env:USERPROFILE\.openclaw\gateway.cmd"
|
||||
Remove-Item -Force "$env:USERPROFILE\.openclaw\gateway.cmd" -ErrorAction SilentlyContinue
|
||||
Remove-Item -Force "$env:USERPROFILE\.openclaw\gateway.vbs" -ErrorAction SilentlyContinue
|
||||
```
|
||||
|
||||
If you used a profile, delete the matching task name and `~\.openclaw-<profile>\gateway.cmd`.
|
||||
If you used a profile, delete the matching task name and the `gateway.cmd` /
|
||||
`gateway.vbs` files under `~\.openclaw-<profile>`.
|
||||
|
||||
## Normal install vs source checkout
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -75,9 +75,9 @@ openclaw gateway call node.list --params "{}"
|
||||
Official distributed iOS builds use the external push relay instead of publishing the raw APNs
|
||||
token to the gateway.
|
||||
|
||||
By default, official/TestFlight builds and gateways use the hosted relay at `https://ios-push-relay.openclaw.ai`.
|
||||
Official/TestFlight builds from the public App Store release lane use the hosted relay at `https://ios-push-relay.openclaw.ai`.
|
||||
|
||||
Custom relay deployments can override the gateway relay URL:
|
||||
Custom relay deployments require a deliberately separate iOS build/deployment path whose relay URL matches the gateway relay URL. The public App Store release lane does not accept custom relay URL overrides. If you are using a custom relay build, set the matching gateway relay URL:
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -100,7 +100,7 @@ How the flow works:
|
||||
- The iOS app fetches the paired gateway identity and includes it in relay registration, so the relay-backed registration is delegated to that specific gateway.
|
||||
- The app forwards that relay-backed registration to the paired gateway with `push.apns.register`.
|
||||
- The gateway uses that stored relay handle for `push.test`, background wakes, and wake nudges.
|
||||
- Custom gateway relay URLs must match the relay URL baked into the official/TestFlight iOS build.
|
||||
- Custom gateway relay URLs must match the relay URL baked into the iOS build.
|
||||
- If the app later connects to a different gateway or a build with a different relay base URL, it refreshes the relay registration instead of reusing the old binding.
|
||||
|
||||
What the gateway does **not** need for this path:
|
||||
@@ -111,7 +111,7 @@ What the gateway does **not** need for this path:
|
||||
Expected operator flow:
|
||||
|
||||
1. Install the official/TestFlight iOS build.
|
||||
2. Optional: set `gateway.push.apns.relay.baseUrl` on the gateway only when using a custom relay deployment.
|
||||
2. Optional: set `gateway.push.apns.relay.baseUrl` on the gateway only when using a deliberately separate custom relay build.
|
||||
3. Pair the app to the gateway and let it finish connecting.
|
||||
4. The app publishes `push.apns.register` automatically after it has an APNs token, the operator session is connected, and relay registration succeeds.
|
||||
5. After that, `push.test`, reconnect wakes, and wake nudges can use the stored relay-backed registration.
|
||||
@@ -130,7 +130,7 @@ compatible but does not count as a durable last-seen update.
|
||||
Compatibility note:
|
||||
|
||||
- `OPENCLAW_APNS_RELAY_BASE_URL` still works as a temporary env override for the gateway.
|
||||
- `OPENCLAW_PUSH_RELAY_BASE_URL` still works as a temporary env override for official/TestFlight iOS builds.
|
||||
- The public App Store release lane rejects `OPENCLAW_PUSH_RELAY_BASE_URL` for iOS builds.
|
||||
|
||||
## Authentication and trust flow
|
||||
|
||||
|
||||
@@ -124,8 +124,11 @@ openclaw gateway status --json
|
||||
```
|
||||
|
||||
Native Windows CLI and Gateway flows are supported and continue to improve.
|
||||
Managed startup uses Windows Scheduled Tasks when available and falls back to a
|
||||
per-user Startup-folder login item if task creation is denied.
|
||||
Managed startup uses Windows Scheduled Tasks when available. The task keeps the
|
||||
readable `gateway.cmd` script in the OpenClaw state dir, but launches it through
|
||||
a generated `gateway.vbs` WScript wrapper so the background Gateway does not open
|
||||
a visible console window. If task creation is denied, OpenClaw falls back to a
|
||||
per-user Startup-folder login item.
|
||||
|
||||
To install the Gateway service:
|
||||
|
||||
|
||||
@@ -259,14 +259,10 @@ under `describe("runSideQuestion")`.
|
||||
fallback for whatever runtimes do not have a peer surface.
|
||||
- PI session state is not migrated when an agent switches to `copilot`.
|
||||
Selection is per attempt; existing PI sessions remain valid.
|
||||
- **Interactive `ask_user` is not yet wired.** The SDK's
|
||||
`onUserInputRequest` handler is intentionally not registered, which
|
||||
per the SDK contract hides the `ask_user` tool from the model
|
||||
entirely. Agents running under this harness make best-judgment
|
||||
decisions from the initial prompt rather than asking clarifying
|
||||
questions mid-turn. A follow-up will port the codex pattern at
|
||||
`extensions/codex/src/app-server/user-input-bridge.ts` to route SDK
|
||||
`UserInputRequest`s through the OpenClaw channel/TUI prompt path.
|
||||
- `ask_user` uses the same OpenClaw prompt-and-reply path as the Codex
|
||||
harness. When the Copilot SDK asks for user input, OpenClaw posts a
|
||||
blocking prompt to the active channel/TUI and the next queued user
|
||||
message resolves the SDK request.
|
||||
|
||||
## Permissions and ask_user
|
||||
|
||||
@@ -328,11 +324,15 @@ the tool bridge. The bridge also forwards the bounded tool-construction
|
||||
controls it can enforce at the SDK boundary: `includeCoreTools`, the
|
||||
runtime tool allowlist, and `toolConstructionPlan`.
|
||||
|
||||
The remaining PI tool-search/code-mode fields are intentionally **not**
|
||||
forwarded at MVP and tracked as follow-ups: `toolSearchCatalogRef`,
|
||||
`includeToolSearchControls`, and `toolSearchCatalogExecutor`. Those
|
||||
controls drive PI's native tool-search UI and have no direct Copilot SDK
|
||||
analog yet.
|
||||
The bridge also uses the shared harness tool-surface helper from
|
||||
`openclaw/plugin-sdk/agent-harness-tool-runtime` for PI parity. When
|
||||
tool-search is enabled, the SDK sees compact control tools plus a hidden
|
||||
catalog executor instead of every OpenClaw tool schema. When code mode is
|
||||
enabled, the helper builds the same code-mode control surface and catalog
|
||||
lifecycle used by other agent harnesses. Local-model lean defaults,
|
||||
runtime-compatible schema filtering, directory hydration, and catalog
|
||||
cleanup all stay in the shared helper so Copilot and Codex-adjacent
|
||||
harnesses do not drift.
|
||||
|
||||
### Session-level GitHub token
|
||||
|
||||
@@ -349,7 +349,10 @@ When the resolved mode is `useLoggedInUser`, the session-level field
|
||||
is omitted so the SDK keeps deriving identity from the logged-in
|
||||
identity.
|
||||
|
||||
`ask_user` is intentionally hidden — see Limitations above.
|
||||
`ask_user` uses `SessionConfig.onUserInputRequest`. The bridge accepts
|
||||
choice indexes or labels for fixed-choice requests, accepts free-form
|
||||
answers when the SDK request allows them, and cancels a pending request
|
||||
when the OpenClaw attempt is aborted.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -191,6 +191,7 @@ or npm install metadata. Those belong in your plugin code and `package.json`.
|
||||
| `skills` | No | `string[]` | Skill directories to load, relative to the plugin root. |
|
||||
| `name` | No | `string` | Human-readable plugin name. |
|
||||
| `description` | No | `string` | Short summary shown in plugin surfaces. |
|
||||
| `icon` | No | `string` | HTTPS image URL for marketplace/catalog cards. ClawHub accepts any valid `https://` URL and falls back to the default plugin icon when this is omitted or invalid. |
|
||||
| `version` | No | `string` | Informational plugin version. |
|
||||
| `uiHints` | No | `Record<string, object>` | UI labels, placeholders, and sensitivity hints for config fields. |
|
||||
|
||||
|
||||
@@ -196,6 +196,23 @@ finish. Both helpers accept the same `{ event, ctx }` payload as
|
||||
`runAgentHarnessAgentEndHook(...)`; their failures do not alter the completed
|
||||
attempt result.
|
||||
|
||||
### User input and tool surfaces
|
||||
|
||||
Native harnesses that expose a runtime-level user-input request should use the
|
||||
user-input helpers from `openclaw/plugin-sdk/agent-harness-runtime` to format
|
||||
the prompt, deliver it through OpenClaw's blocking reply path, and normalize
|
||||
choice/free-form answers back into the runtime's native response shape. The
|
||||
helper keeps channel/TUI presentation consistent while each harness keeps its
|
||||
own protocol parsing and pending-request lifecycle.
|
||||
|
||||
Native harnesses that need PI-like compact tool routing should use
|
||||
`createAgentHarnessToolSurfaceRuntime(...)` from
|
||||
`openclaw/plugin-sdk/agent-harness-tool-runtime`. It owns
|
||||
tool-search/code-mode control selection, local-model lean defaults,
|
||||
runtime-compatible schema filtering, hidden catalog execution, directory
|
||||
hydration, and catalog cleanup. Harnesses still own their SDK-specific tool
|
||||
conversion and native execution callback.
|
||||
|
||||
### Native Codex harness mode
|
||||
|
||||
The bundled `codex` harness is the native Codex mode for embedded OpenClaw
|
||||
|
||||
@@ -84,8 +84,8 @@ Choose the Token Plan auth choice that matches the regional base URL shown in Xi
|
||||
|
||||
| Model ref | Input | Context | Max output | Reasoning | Notes |
|
||||
| --------------------------------- | ----------- | --------- | ---------- | --------- | ------------- |
|
||||
| `xiaomi-token-plan/mimo-v2.5-pro` | text | 1,048,576 | 32,000 | Yes | Default model |
|
||||
| `xiaomi-token-plan/mimo-v2.5` | text, image | 1,048,576 | 32,000 | Yes | Multimodal |
|
||||
| `xiaomi-token-plan/mimo-v2.5-pro` | text | 1,048,576 | 131,072 | Yes | Default model |
|
||||
| `xiaomi-token-plan/mimo-v2.5` | text, image | 1,048,576 | 131,072 | Yes | Multimodal |
|
||||
|
||||
<Tip>
|
||||
Token Plan onboarding validates the key shape and warns when a `tp-...` key is entered into the pay-as-you-go path, or an `sk-...` key is entered into the Token Plan path.
|
||||
@@ -222,7 +222,7 @@ Token Plan:
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
contextWindow: 1048576,
|
||||
maxTokens: 32000,
|
||||
maxTokens: 131072,
|
||||
},
|
||||
{
|
||||
id: "mimo-v2.5",
|
||||
@@ -230,7 +230,7 @@ Token Plan:
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 1048576,
|
||||
maxTokens: 32000,
|
||||
maxTokens: 131072,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -67,7 +67,7 @@ Wraps papla.media TTS and sends results as Telegram voice notes (no annoying aut
|
||||
<img src="/assets/showcase/papla-tts.jpg" alt="Telegram voice note output from TTS" />
|
||||
</Card>
|
||||
|
||||
<Card title="CodexMonitor" icon="eye" href="https://clawhub.ai/odrobnik/codexmonitor">
|
||||
<Card title="CodexMonitor" icon="eye" href="https://clawhub.ai/odrobnik/skills/codexmonitor">
|
||||
**@odrobnik** • `devtools` `codex` `brew`
|
||||
|
||||
Homebrew-installed helper to list, inspect, and watch local OpenAI Codex sessions (CLI + VS Code).
|
||||
@@ -75,7 +75,7 @@ Homebrew-installed helper to list, inspect, and watch local OpenAI Codex session
|
||||
<img src="/assets/showcase/codexmonitor.png" alt="CodexMonitor on ClawHub" />
|
||||
</Card>
|
||||
|
||||
<Card title="Bambu 3D Printer Control" icon="print" href="https://clawhub.ai/tobiasbischoff/bambu-cli">
|
||||
<Card title="Bambu 3D Printer Control" icon="print" href="https://clawhub.ai/tobiasbischoff/skills/bambu-cli">
|
||||
**@tobiasbischoff** • `hardware` `3d-printing` `skill`
|
||||
|
||||
Control and troubleshoot BambuLab printers: status, jobs, camera, AMS, calibration, and more.
|
||||
@@ -83,7 +83,7 @@ Control and troubleshoot BambuLab printers: status, jobs, camera, AMS, calibrati
|
||||
<img src="/assets/showcase/bambu-cli.png" alt="Bambu CLI skill on ClawHub" />
|
||||
</Card>
|
||||
|
||||
<Card title="Vienna transport (Wiener Linien)" icon="train" href="https://clawhub.ai/hjanuschka/wienerlinien">
|
||||
<Card title="Vienna transport (Wiener Linien)" icon="train" href="https://clawhub.ai/hjanuschka/skills/wienerlinien">
|
||||
**@hjanuschka** • `travel` `transport` `skill`
|
||||
|
||||
Real-time departures, disruptions, elevator status, and routing for Vienna's public transport.
|
||||
@@ -97,7 +97,7 @@ Real-time departures, disruptions, elevator status, and routing for Vienna's pub
|
||||
Automated UK school meal booking via ParentPay. Uses mouse coordinates for reliable table cell clicking.
|
||||
</Card>
|
||||
|
||||
<Card title="R2 upload (Send Me My Files)" icon="cloud-arrow-up" href="https://clawhub.ai/skills/r2-upload">
|
||||
<Card title="R2 upload (Send Me My Files)" icon="cloud-arrow-up" href="https://clawhub.ai/julianengel/skills/r2-upload">
|
||||
**@julianengel** • `files` `r2` `presigned-urls`
|
||||
|
||||
Upload to Cloudflare R2/S3 and generate secure presigned download links. Useful for remote OpenClaw instances.
|
||||
@@ -267,7 +267,7 @@ Speech-first entry points, phone bridges, and transcription-heavy workflows.
|
||||
Vapi voice assistant to OpenClaw HTTP bridge. Near real-time phone calls with your agent.
|
||||
</Card>
|
||||
|
||||
<Card title="OpenRouter transcription" icon="microphone" href="https://clawhub.ai/obviyus/openrouter-transcribe">
|
||||
<Card title="OpenRouter transcription" icon="microphone" href="https://clawhub.ai/obviyus/skills/openrouter-transcribe">
|
||||
**@obviyus** • `transcription` `multilingual` `skill`
|
||||
|
||||
Multi-lingual audio transcription via OpenRouter (Gemini, and more). Available on ClawHub.
|
||||
@@ -289,8 +289,8 @@ Packaging, deployment, and integrations that make OpenClaw easier to run and ext
|
||||
OpenClaw gateway running on Home Assistant OS with SSH tunnel support and persistent state.
|
||||
</Card>
|
||||
|
||||
<Card title="Home Assistant skill" icon="toggle-on" href="https://clawhub.ai/skills/homeassistant">
|
||||
**ClawHub** • `homeassistant` `skill` `automation`
|
||||
<Card title="Home Assistant skill" icon="toggle-on" href="https://clawhub.ai/homeofe/skills/openclaw-homeassistant">
|
||||
**@homeofe** • `homeassistant` `skill` `automation`
|
||||
|
||||
Control and automate Home Assistant devices via natural language.
|
||||
|
||||
@@ -303,8 +303,8 @@ Control and automate Home Assistant devices via natural language.
|
||||
Batteries-included nixified OpenClaw configuration for reproducible deployments.
|
||||
</Card>
|
||||
|
||||
<Card title="CalDAV calendar" icon="calendar" href="https://clawhub.ai/skills/caldav-calendar">
|
||||
**ClawHub** • `calendar` `caldav` `skill`
|
||||
<Card title="CalDAV calendar" icon="calendar" href="https://clawhub.ai/asleep123/skills/caldav-calendar">
|
||||
**@asleep123** • `calendar` `caldav` `skill`
|
||||
|
||||
Calendar skill using khal and vdirsyncer. Self-hosted calendar integration.
|
||||
|
||||
|
||||
750
docs/style.css
750
docs/style.css
@@ -135,3 +135,753 @@ html.dark .nav-tabs-underline {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.maturity-hero {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin: 10px 0 38px;
|
||||
padding: 4px 0 26px 20px;
|
||||
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 22%, transparent);
|
||||
border-left: 3px solid rgb(var(--primary));
|
||||
}
|
||||
|
||||
.maturity-hero-compact {
|
||||
margin-bottom: 34px;
|
||||
}
|
||||
|
||||
.maturity-hero h2 {
|
||||
max-width: 46rem;
|
||||
margin: 0;
|
||||
font-size: clamp(26px, 3vw, 38px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.maturity-hero-title {
|
||||
max-width: 46rem;
|
||||
margin: 0;
|
||||
font-size: clamp(26px, 3vw, 38px);
|
||||
font-weight: 750;
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.maturity-hero > p:not(.maturity-kicker):not(.maturity-jump-links) {
|
||||
max-width: 58rem;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.65;
|
||||
opacity: 0.76;
|
||||
}
|
||||
|
||||
.maturity-kicker {
|
||||
margin: 0;
|
||||
color: rgb(var(--primary));
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.1em;
|
||||
line-height: 1.3;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.maturity-jump-links {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.maturity-jump-links a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.maturity-jump-links a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.maturity-score-stable,
|
||||
.maturity-band-stable {
|
||||
color: #4ca574;
|
||||
}
|
||||
|
||||
.maturity-score-beta,
|
||||
.maturity-band-beta {
|
||||
color: #849fd2;
|
||||
}
|
||||
|
||||
.maturity-score-alpha,
|
||||
.maturity-band-alpha {
|
||||
color: #d39a4b;
|
||||
}
|
||||
|
||||
.maturity-score-experimental,
|
||||
.maturity-band-experimental {
|
||||
color: #dc7669;
|
||||
}
|
||||
|
||||
.maturity-score-clawesome,
|
||||
.maturity-band-clawesome {
|
||||
color: #46b59a;
|
||||
}
|
||||
|
||||
.maturity-level-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid color-mix(in oklab, currentColor 32%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, currentColor 10%, transparent);
|
||||
color: inherit;
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.maturity-level-code {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.maturity-level-experimental {
|
||||
color: #dc7669;
|
||||
}
|
||||
|
||||
.maturity-level-alpha {
|
||||
color: #d39a4b;
|
||||
}
|
||||
|
||||
.maturity-level-beta {
|
||||
color: #849fd2;
|
||||
}
|
||||
|
||||
.maturity-level-stable {
|
||||
color: #4ca574;
|
||||
}
|
||||
|
||||
.maturity-level-clawesome {
|
||||
color: #46b59a;
|
||||
}
|
||||
|
||||
.maturity-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin: 14px 0 20px;
|
||||
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 18%, transparent);
|
||||
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 18%, transparent);
|
||||
}
|
||||
|
||||
.maturity-summary-item {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 18px 20px 18px 0;
|
||||
}
|
||||
|
||||
.maturity-summary-item + .maturity-summary-item {
|
||||
padding-left: 20px;
|
||||
border-left: 1px solid color-mix(in oklab, rgb(var(--primary)) 14%, transparent);
|
||||
}
|
||||
|
||||
.maturity-summary-heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.maturity-summary-value {
|
||||
display: inline-block;
|
||||
font-size: 30px;
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.maturity-summary-heading > span:not(.maturity-summary-value) {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.maturity-summary-bar {
|
||||
height: 7px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in oklab, currentColor 14%, transparent);
|
||||
}
|
||||
|
||||
.maturity-summary-bar span {
|
||||
display: block;
|
||||
width: calc(var(--score) * 1%);
|
||||
height: 100%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.maturity-summary-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 10px;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.maturity-summary-meta span:first-child {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.maturity-summary-meta span:last-child {
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.maturity-band-list {
|
||||
display: flex;
|
||||
margin: 12px 0 30px;
|
||||
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
|
||||
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
|
||||
}
|
||||
|
||||
.maturity-band {
|
||||
display: grid;
|
||||
flex: 1 1 0;
|
||||
gap: 3px;
|
||||
padding: 10px 12px 11px 0;
|
||||
}
|
||||
|
||||
.maturity-band + .maturity-band {
|
||||
padding-left: 12px;
|
||||
border-left: 1px solid color-mix(in oklab, rgb(var(--primary)) 12%, transparent);
|
||||
}
|
||||
|
||||
.maturity-band-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.maturity-band-title + span {
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.maturity-band > span:last-child {
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.maturity-band span {
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.maturity-band .maturity-level-pill {
|
||||
font-size: 10px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.maturity-band .maturity-level-pill span {
|
||||
font-size: inherit;
|
||||
opacity: inherit;
|
||||
}
|
||||
|
||||
.maturity-score {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.maturity-score-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.maturity-score-label > span:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.maturity-score-label > span:last-child {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.maturity-score-label .maturity-level-pill {
|
||||
gap: 4px;
|
||||
padding: 2px 6px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.maturity-score-label-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.maturity-summary-meta .maturity-level-pill {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.maturity-meter {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in oklab, currentColor 15%, transparent);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.maturity-meter > span {
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 100%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.maturity-score-unscored,
|
||||
.maturity-lts-none {
|
||||
color: inherit;
|
||||
opacity: 0.52;
|
||||
}
|
||||
|
||||
.maturity-surface-table {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
margin: 8px 0 22px;
|
||||
}
|
||||
|
||||
.maturity-surface-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(190px, 1.55fr) repeat(3, minmax(110px, 1fr)) minmax(72px, 0.55fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 13px 0;
|
||||
border-top: 1px solid color-mix(in oklab, currentColor 14%, transparent);
|
||||
}
|
||||
|
||||
.maturity-surface-row-header {
|
||||
padding: 0 0 9px;
|
||||
border-top: 0;
|
||||
color: inherit;
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.56;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.maturity-surface-name {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
border-bottom: 0 !important;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.maturity-surface-name:hover .maturity-surface-title {
|
||||
color: rgb(var(--primary));
|
||||
}
|
||||
|
||||
.maturity-surface-title {
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.maturity-surface-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.maturity-surface-meta > span:not(.maturity-level-pill) {
|
||||
font-size: 11px;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.maturity-surface-meta .maturity-level-pill {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.maturity-surface-metric {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.maturity-surface-metric-label {
|
||||
display: none;
|
||||
font-size: 10px;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.maturity-surface-support {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.maturity-lts {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.maturity-lts::before {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.maturity-lts-partial {
|
||||
color: #d39a4b;
|
||||
}
|
||||
|
||||
.maturity-lts-full {
|
||||
color: #4ca574;
|
||||
}
|
||||
|
||||
.maturity-evidence-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin: 14px 0 24px;
|
||||
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
|
||||
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
|
||||
}
|
||||
|
||||
.maturity-evidence-card {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
padding: 14px 16px 14px 0;
|
||||
}
|
||||
|
||||
.maturity-evidence-card + .maturity-evidence-card {
|
||||
padding-left: 16px;
|
||||
border-left: 1px solid color-mix(in oklab, rgb(var(--primary)) 12%, transparent);
|
||||
}
|
||||
|
||||
.maturity-evidence-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.maturity-evidence-card span {
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.maturity-readiness-summary {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.maturity-readiness-list {
|
||||
display: grid;
|
||||
margin: 0;
|
||||
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 14%, transparent);
|
||||
}
|
||||
|
||||
.maturity-readiness-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.7fr) minmax(120px, 0.8fr) minmax(110px, 0.7fr);
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding: 11px 0;
|
||||
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 10%, transparent);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.maturity-readiness-row-header {
|
||||
padding: 8px 0;
|
||||
border-bottom-color: color-mix(in oklab, rgb(var(--primary)) 14%, transparent);
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.56;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.maturity-readiness-area {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.maturity-readiness-title {
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.maturity-readiness-status {
|
||||
font-size: 10px;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.maturity-readiness-status-ready {
|
||||
color: #4ca574;
|
||||
}
|
||||
|
||||
.maturity-readiness-status-partially-reviewed {
|
||||
color: #d39a4b;
|
||||
}
|
||||
|
||||
.maturity-readiness-status-needs-review {
|
||||
color: #dc7669;
|
||||
}
|
||||
|
||||
.maturity-category-list {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.maturity-category-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1.55fr) repeat(3, minmax(100px, 1fr)) minmax(140px, 1.2fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 11%, transparent);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.maturity-category-row-header {
|
||||
padding: 8px 0;
|
||||
border-top: 0;
|
||||
color: inherit;
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.56;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.maturity-category-area {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.maturity-category-title {
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.maturity-category-area > span:last-child {
|
||||
font-size: 10px;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.maturity-category-docs {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.maturity-category-docs a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.maturity-category-docs a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.maturity-level-list {
|
||||
display: grid;
|
||||
margin: 12px 0 28px;
|
||||
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
|
||||
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
|
||||
}
|
||||
|
||||
.maturity-level-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(130px, 0.32fr) minmax(0, 1fr);
|
||||
gap: 4px 14px;
|
||||
padding: 13px 0;
|
||||
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 11%, transparent);
|
||||
}
|
||||
|
||||
.maturity-level-row:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.maturity-level-title {
|
||||
grid-row: span 2;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.maturity-level-title .maturity-level-pill {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.maturity-level-row span,
|
||||
.maturity-level-promotion {
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.maturity-surface-link {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
margin: 0;
|
||||
padding: 11px 0;
|
||||
border-bottom: 1px solid color-mix(in oklab, currentColor 14%, transparent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.maturity-surface-link:hover {
|
||||
color: rgb(var(--primary));
|
||||
}
|
||||
|
||||
.maturity-surface-link .maturity-surface-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.maturity-surface-link > .maturity-surface-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.maturity-surface-link > .maturity-surface-meta > span:not(.maturity-level-pill) {
|
||||
font-size: 11px;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.maturity-surface-link > .maturity-surface-meta .maturity-level-pill {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.maturity-surface-rollup {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px 14px;
|
||||
margin: 0 0 14px;
|
||||
padding: 9px 0;
|
||||
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 13%, transparent);
|
||||
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 13%, transparent);
|
||||
}
|
||||
|
||||
.maturity-surface-rollup > span {
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#content table .maturity-score,
|
||||
#content table .maturity-lts {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.maturity-summary-grid,
|
||||
.maturity-evidence-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.maturity-summary-item,
|
||||
.maturity-summary-item + .maturity-summary-item,
|
||||
.maturity-evidence-card,
|
||||
.maturity-evidence-card + .maturity-evidence-card {
|
||||
padding: 14px 0;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.maturity-summary-item + .maturity-summary-item,
|
||||
.maturity-evidence-card + .maturity-evidence-card {
|
||||
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 12%, transparent);
|
||||
}
|
||||
|
||||
.maturity-surface-row {
|
||||
grid-template-columns: minmax(160px, 1.35fr) repeat(3, minmax(98px, 1fr)) minmax(70px, 0.5fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.maturity-category-row {
|
||||
grid-template-columns: minmax(160px, 1.35fr) repeat(3, minmax(86px, 1fr)) minmax(110px, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.maturity-hero {
|
||||
padding-left: 14px;
|
||||
}
|
||||
|
||||
.maturity-surface-row-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.maturity-surface-row {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 9px 12px;
|
||||
}
|
||||
|
||||
.maturity-surface-metric {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.maturity-surface-metric-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.maturity-surface-support {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.maturity-readiness-row,
|
||||
.maturity-category-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.maturity-readiness-row-header,
|
||||
.maturity-category-row-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.maturity-category-docs {
|
||||
padding-top: 3px;
|
||||
}
|
||||
|
||||
.maturity-band-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.maturity-band + .maturity-band {
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.maturity-level-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.maturity-level-title {
|
||||
grid-row: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ See [Skill Workshop](/tools/skill-workshop) for the full proposal lifecycle.
|
||||
metadata:
|
||||
|
||||
```bash
|
||||
openclaw skills install clawhub-publish
|
||||
openclaw skills install @openclaw/clawhub-publish
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
@@ -88,8 +88,9 @@ still returns one synthesized answer with citations rather than an N-result
|
||||
list.
|
||||
|
||||
`freshness` accepts `day`, `week`, `month`, `year`, and the shared shortcuts
|
||||
`pd`, `pw`, `pm`, and `py`. OpenClaw converts these values, or an explicit
|
||||
`date_after`/`date_before` range, into Gemini Google Search grounding's
|
||||
`pd`, `pw`, `pm`, and `py`. `day`/`pd` adds a recency instruction to the Gemini
|
||||
query instead of a hard 24-hour range. `week`, `month`, `year`, and explicit
|
||||
`date_after`/`date_before` ranges set Gemini Google Search grounding's
|
||||
`timeRangeFilter`. `country`, `language`, and `domain_filter` are not supported.
|
||||
|
||||
## Model selection
|
||||
|
||||
@@ -145,12 +145,12 @@ publish and sync.
|
||||
|
||||
| Action | Command |
|
||||
| ---------------------------------- | ------------------------------------------------------ |
|
||||
| Install a skill into the workspace | `openclaw skills install <slug>` |
|
||||
| Install a skill into the workspace | `openclaw skills install @owner/<slug>` |
|
||||
| Install from a Git repository | `openclaw skills install git:owner/repo@ref` |
|
||||
| Install a local skill directory | `openclaw skills install ./path/to/skill --as my-tool` |
|
||||
| Install for all local agents | `openclaw skills install <slug> --global` |
|
||||
| Install for all local agents | `openclaw skills install @owner/<slug> --global` |
|
||||
| Update all workspace skills | `openclaw skills update --all` |
|
||||
| Update a shared managed skill | `openclaw skills update <slug> --global` |
|
||||
| Update a shared managed skill | `openclaw skills update @owner/<slug> --global` |
|
||||
| Update all shared managed skills | `openclaw skills update --all --global` |
|
||||
| Verify a skill's trust envelope | `openclaw skills verify <slug>` |
|
||||
| Print the generated Skill Card | `openclaw skills verify <slug> --card` |
|
||||
@@ -179,7 +179,7 @@ publish and sync.
|
||||
with detail pages for VirusTotal, ClawScan, and static analysis. The
|
||||
command exits non-zero when ClawHub marks verification as failed. Publishers
|
||||
recover false positives through the ClawHub dashboard or
|
||||
`clawhub skill rescan <slug>`.
|
||||
`clawhub skill rescan @owner/<slug>`.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Private archive installs">
|
||||
|
||||
@@ -389,8 +389,8 @@ show the `x_search` prompt.
|
||||
freshness ranges require both start and end dates.
|
||||
Gemini, Grok, and Kimi return one synthesized answer with citations. They
|
||||
accept `count` for shared-tool compatibility, but it does not change the
|
||||
grounded answer shape. Gemini supports `freshness`, `date_after`, and
|
||||
`date_before` by converting them to Google Search grounding time ranges.
|
||||
grounded answer shape. Gemini treats `day` freshness as a recency hint; wider
|
||||
freshness values and explicit dates set Google Search grounding time ranges.
|
||||
Perplexity behaves the same way when you use the Sonar/OpenRouter
|
||||
compatibility path (`plugins.entries.perplexity.config.webSearch.baseUrl` /
|
||||
`model` or `OPENROUTER_API_KEY`).
|
||||
|
||||
60
extensions/acpx/npm-shrinkwrap.json
generated
60
extensions/acpx/npm-shrinkwrap.json
generated
@@ -10,7 +10,7 @@
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/claude-agent-acp": "0.39.0",
|
||||
"@zed-industries/codex-acp": "0.15.0",
|
||||
"acpx": "0.10.0",
|
||||
"acpx": "0.11.2",
|
||||
"zod": "4.4.3"
|
||||
}
|
||||
},
|
||||
@@ -196,9 +196,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@clack/core": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.1.tgz",
|
||||
"integrity": "sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.3.1.tgz",
|
||||
"integrity": "sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-wrap-ansi": "^0.2.0",
|
||||
@@ -209,12 +209,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@clack/prompts": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.5.1.tgz",
|
||||
"integrity": "sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw==",
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.4.0.tgz",
|
||||
"integrity": "sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clack/core": "1.4.1",
|
||||
"@clack/core": "1.3.1",
|
||||
"fast-string-width": "^3.0.2",
|
||||
"fast-wrap-ansi": "^0.2.0",
|
||||
"sisteransi": "^1.0.5"
|
||||
@@ -701,6 +701,7 @@
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@zed-industries/codex-acp/-/codex-acp-0.15.0.tgz",
|
||||
"integrity": "sha512-eAv7sGBeiYrYkOulF729nrM51szS7WIhBtugRj5wWq6csRKZUhAZfoUZlF8xUWdHPtOIzd/eT6MNG6gMHu6z0w==",
|
||||
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"codex-acp": "bin/codex-acp.js"
|
||||
@@ -721,6 +722,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -737,6 +739,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -753,6 +756,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -769,6 +773,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -785,6 +790,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -801,6 +807,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -824,15 +831,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/acpx": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/acpx/-/acpx-0.10.0.tgz",
|
||||
"integrity": "sha512-hd48XV03gG3sd409T1lDrOKJTTz1ap4g0wrndXjxQ590tN85pBYlvfNLyerybvGRrtUGsZjNdt99r1jpIt6ukA==",
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/acpx/-/acpx-0.11.2.tgz",
|
||||
"integrity": "sha512-ksTmfJDVqUAJJXsNDamEno03AMZ/aAZzXk/h5nt61VsLc/jcpoDMfCVpErzuYNJjwCd0V6Zm5o6F8OoqxsjQWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.22.1",
|
||||
"commander": "^14.0.3",
|
||||
"skillflag": "^0.1.4",
|
||||
"tsx": "^4.22.0",
|
||||
"@agentclientprotocol/sdk": "^0.28.1",
|
||||
"commander": "^15.0.0",
|
||||
"skillflag": "^0.2.0",
|
||||
"tsx": "^4.22.4",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"bin": {
|
||||
@@ -842,6 +849,15 @@
|
||||
"node": ">=22.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acpx/node_modules/@agentclientprotocol/sdk": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.28.1.tgz",
|
||||
"integrity": "sha512-Z2Frs6YtPhnZZ+XwFXyQkRDXY0fn8FjCalEs0W4yUhQnY4TztmNq0/RnfzWdFN3vqT3h0jTz5klzYbZHGxCDyQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
@@ -1043,12 +1059,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "14.0.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
|
||||
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
|
||||
"version": "15.0.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
|
||||
"integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
@@ -2045,9 +2061,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/skillflag": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/skillflag/-/skillflag-0.1.4.tgz",
|
||||
"integrity": "sha512-egFg+XCF5sloOWdtzxZivTX7n4UDj5pxQoY33wbT8h+YSDjMQJ76MZUg2rXQIBXmIDtlZhLgirS1g/3R5/qaHA==",
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/skillflag/-/skillflag-0.2.0.tgz",
|
||||
"integrity": "sha512-7ZmEpBeEoPLc+hqZ/StAnCO/hulgEPANzPyZgOM/CZ5zc3b0ApSp3URavY5POM/OKyi5d9+UC/Q21OoiYC2kJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.1",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/claude-agent-acp": "0.39.0",
|
||||
"@zed-industries/codex-acp": "0.15.0",
|
||||
"acpx": "0.10.0",
|
||||
"acpx": "0.11.2",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -251,6 +251,15 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
expect(wrapper).not.toMatch(
|
||||
/forceKillTimer = setTimeout\(\(\) => killChildTree\("SIGKILL"\), 1_500\);\s*forceKillTimer\.unref\?\.\(\);\s*process\.exit\(1\);/s,
|
||||
);
|
||||
// Orphan detection must trigger on any PPID change, not only when the new
|
||||
// PPID is init (1). Systemd user services and container init reparent
|
||||
// orphaned processes to a session manager or container init (PID != 1),
|
||||
// and the older `process.ppid !== 1` guard would silently leak the codex
|
||||
// adapter tree there.
|
||||
expect(wrapper).not.toContain("process.ppid !== 1");
|
||||
expect(wrapper).toMatch(
|
||||
/setInterval\(\(\) => \{[\s\S]*?if \(process\.ppid === originalParentPid\) \{\s*return;\s*\}/,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the bundled Claude ACP dependency by default when it is installed", async () => {
|
||||
|
||||
@@ -475,7 +475,13 @@ const parentWatcher =
|
||||
process.platform === "win32"
|
||||
? undefined
|
||||
: setInterval(() => {
|
||||
if (process.ppid === originalParentPid || process.ppid !== 1) {
|
||||
// Orphan detection: parent PID changed means our original parent died.
|
||||
// The new parent could be PID 1 (init) on bare-metal hosts, OR a
|
||||
// systemd user-session manager, OR a container init, OR a session
|
||||
// leader — depending on environment. Previously this only triggered
|
||||
// on PPID == 1, which missed all systemd-managed deployments and
|
||||
// leaked codex-acp adapter trees on every gateway restart.
|
||||
if (process.ppid === originalParentPid) {
|
||||
return;
|
||||
}
|
||||
if (orphanCleanupStarted) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { RequestedModelUnsupportedError } from "acpx/runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
AcpRuntimeError,
|
||||
@@ -708,6 +709,100 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("retries without a model when ACPX reports missing model capability", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => undefined),
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
const { runtime, delegate } = makeRuntime(baseStore, {
|
||||
agentRegistry: {
|
||||
resolve: (agentName: string) => (agentName === "opencode" ? "opencode acp" : agentName),
|
||||
list: () => ["opencode"],
|
||||
},
|
||||
});
|
||||
const ensure = vi
|
||||
.spyOn(delegate, "ensureSession")
|
||||
.mockRejectedValueOnce(
|
||||
new RequestedModelUnsupportedError(
|
||||
"Cannot apply --model: the ACP agent did not advertise model support",
|
||||
"missing-capability",
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
sessionKey: "agent:opencode:acp:test",
|
||||
backend: "acpx",
|
||||
runtimeSessionName: "opencode",
|
||||
});
|
||||
|
||||
await runtime.ensureSession({
|
||||
sessionKey: "agent:opencode:acp:test",
|
||||
agent: "opencode",
|
||||
mode: "persistent",
|
||||
model: "openrouter/owl-alpha",
|
||||
});
|
||||
|
||||
expect(ensure).toHaveBeenCalledTimes(2);
|
||||
expect(readFirstEnsureSessionInput(ensure)).toMatchObject({
|
||||
model: "openrouter/owl-alpha",
|
||||
sessionOptions: { model: "openrouter/owl-alpha" },
|
||||
});
|
||||
const [, secondCall] = ensure.mock.calls;
|
||||
expect(secondCall?.[0]).not.toHaveProperty("sessionOptions");
|
||||
expect((secondCall?.[0] as { model?: string } | undefined)?.model).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not retry when ACPX rejects an explicitly unsupported model id", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => undefined),
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
const { runtime, delegate } = makeRuntime(baseStore, {
|
||||
agentRegistry: {
|
||||
resolve: (agentName: string) => (agentName === "opencode" ? "opencode acp" : agentName),
|
||||
list: () => ["opencode"],
|
||||
},
|
||||
});
|
||||
const ensure = vi
|
||||
.spyOn(delegate, "ensureSession")
|
||||
.mockRejectedValueOnce(
|
||||
new RequestedModelUnsupportedError(
|
||||
"Cannot apply --model: the ACP agent did not advertise that model",
|
||||
"unadvertised-model",
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
runtime.ensureSession({
|
||||
sessionKey: "agent:opencode:acp:test",
|
||||
agent: "opencode",
|
||||
mode: "persistent",
|
||||
model: "unknown/model",
|
||||
}),
|
||||
).rejects.toThrow("did not advertise that model");
|
||||
expect(ensure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry an unrelated error with similar wording", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => undefined),
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
const { runtime, delegate } = makeRuntime(baseStore);
|
||||
const ensure = vi
|
||||
.spyOn(delegate, "ensureSession")
|
||||
.mockRejectedValueOnce(new Error("the ACP agent did not advertise model support"));
|
||||
|
||||
await expect(
|
||||
runtime.ensureSession({
|
||||
sessionKey: "agent:main:acp:test",
|
||||
agent: "main",
|
||||
mode: "persistent",
|
||||
model: "openrouter/owl-alpha",
|
||||
}),
|
||||
).rejects.toThrow("did not advertise model support");
|
||||
expect(ensure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("injects Codex ACP startup config into the scoped registry", () => {
|
||||
expect(testing.isCodexAcpCommand(CODEX_ACP_COMMAND)).toBe(true);
|
||||
expect(testing.isCodexAcpCommand(CODEX_ACP_WRAPPER_COMMAND)).toBe(true);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
createFileSessionStore,
|
||||
decodeAcpxRuntimeHandleState,
|
||||
encodeAcpxRuntimeHandleState,
|
||||
isRequestedModelUnsupportedError,
|
||||
type AcpAgentRegistry,
|
||||
type AcpRuntimeDoctorReport,
|
||||
type AcpRuntimeEvent,
|
||||
@@ -586,6 +587,26 @@ function withAcpxSessionOptions(input: OpenClawRuntimeEnsureInput): AcpxDelegate
|
||||
} as AcpxDelegateEnsureInput;
|
||||
}
|
||||
|
||||
function isAcpModelCapabilityMissingError(error: unknown): boolean {
|
||||
return isRequestedModelUnsupportedError(error) && error.reason === "missing-capability";
|
||||
}
|
||||
|
||||
// ACPX owns the distinction between missing model capability and an invalid model id.
|
||||
// Retry only the former so explicit model mistakes remain visible to the caller.
|
||||
async function ensureDelegateSessionWithModelFallback(
|
||||
delegate: BaseAcpxRuntime,
|
||||
input: OpenClawRuntimeEnsureInput,
|
||||
): Promise<AcpRuntimeHandle> {
|
||||
try {
|
||||
return await delegate.ensureSession(withAcpxSessionOptions(input));
|
||||
} catch (error) {
|
||||
if (!input.model || !isAcpModelCapabilityMissingError(error)) {
|
||||
throw error;
|
||||
}
|
||||
return await delegate.ensureSession(withAcpxSessionOptions({ ...input, model: undefined }));
|
||||
}
|
||||
}
|
||||
|
||||
function quoteShellArg(value: string): string {
|
||||
if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) {
|
||||
return value;
|
||||
@@ -989,7 +1010,7 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
this.withCodexWrapperDiagnostics({
|
||||
command: stableLaunchCommand,
|
||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||
run: () => delegate.ensureSession(withAcpxSessionOptions(ensureInput)),
|
||||
run: () => ensureDelegateSessionWithModelFallback(delegate, ensureInput),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"id": "alibaba",
|
||||
"icon": "https://cdn.simpleicons.org/alibabacloud/111111",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "anthropic-vertex",
|
||||
"name": "Anthropic Vertex",
|
||||
"description": "OpenClaw Anthropic Vertex provider plugin for Claude models on Google Vertex AI.",
|
||||
"icon": "https://cdn.simpleicons.org/anthropic/111111",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"id": "anthropic",
|
||||
"icon": "https://cdn.simpleicons.org/anthropic/111111",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "brave",
|
||||
"name": "Brave",
|
||||
"description": "OpenClaw Brave Search provider plugin for web search.",
|
||||
"icon": "https://cdn.simpleicons.org/brave/111111",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -43,23 +43,39 @@ afterAll(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
function jsonResponse(payload: unknown, init?: ResponseInit): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
function malformedJsonResponse(): Response {
|
||||
return new Response("{ nope", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function emptyWebSearchResponse(): Response {
|
||||
return jsonResponse({ web: { results: [] } });
|
||||
}
|
||||
|
||||
function installBraveLlmContextFetch() {
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
grounding: {
|
||||
generic: [
|
||||
{
|
||||
url: "https://example.com/context",
|
||||
title: "Context",
|
||||
snippets: ["snippet"],
|
||||
},
|
||||
],
|
||||
},
|
||||
sources: [],
|
||||
}),
|
||||
} as unknown as Response;
|
||||
return jsonResponse({
|
||||
grounding: {
|
||||
generic: [
|
||||
{
|
||||
url: "https://example.com/context",
|
||||
title: "Context",
|
||||
snippets: ["snippet"],
|
||||
},
|
||||
],
|
||||
},
|
||||
sources: [],
|
||||
});
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
return mockFetch;
|
||||
@@ -254,10 +270,7 @@ describe("brave web search provider", () => {
|
||||
it("uses configured Brave baseUrl for web search requests", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ web: { results: [] } }),
|
||||
} as unknown as Response;
|
||||
return emptyWebSearchResponse();
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
|
||||
@@ -310,12 +323,7 @@ describe("brave web search provider", () => {
|
||||
it("reports malformed Brave web search JSON as a provider error", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => {
|
||||
throw new SyntaxError("Unexpected token");
|
||||
},
|
||||
} as unknown as Response;
|
||||
return malformedJsonResponse();
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
|
||||
@@ -339,12 +347,7 @@ describe("brave web search provider", () => {
|
||||
it("reports malformed Brave llm-context JSON as a provider error", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => {
|
||||
throw new SyntaxError("Unexpected token");
|
||||
},
|
||||
} as unknown as Response;
|
||||
return malformedJsonResponse();
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
|
||||
@@ -428,10 +431,7 @@ describe("brave web search provider", () => {
|
||||
it("keeps Brave cache entries isolated by baseUrl", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ web: { results: [] } }),
|
||||
} as unknown as Response;
|
||||
return emptyWebSearchResponse();
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
|
||||
@@ -573,10 +573,7 @@ describe("brave web search provider", () => {
|
||||
it("sends Brave web auth in the X-Subscription-Token header", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ web: { results: [] } }),
|
||||
} as unknown as Response;
|
||||
return emptyWebSearchResponse();
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
|
||||
@@ -732,10 +729,7 @@ describe("brave web search provider", () => {
|
||||
it("falls back unsupported country values before calling Brave", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "test-key");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ web: { results: [] } }),
|
||||
} as unknown as Response;
|
||||
return emptyWebSearchResponse();
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
|
||||
@@ -763,21 +757,17 @@ describe("brave web search provider", () => {
|
||||
it("emits brave.http diagnostics for requests, responses, and cache events", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
web: {
|
||||
results: [
|
||||
{
|
||||
title: "Diagnostics",
|
||||
url: "https://example.com/diagnostics",
|
||||
description: "debug details",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
} as unknown as Response;
|
||||
return jsonResponse({
|
||||
web: {
|
||||
results: [
|
||||
{
|
||||
title: "Diagnostics",
|
||||
url: "https://example.com/diagnostics",
|
||||
description: "debug details",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
|
||||
|
||||
@@ -15,8 +15,12 @@ import { resolvePnpmRunner } from "./pnpm-runner.mjs";
|
||||
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const rootDir = path.resolve(pluginDir, "../..");
|
||||
const require = createRequire(import.meta.url);
|
||||
const hashFile = path.join(pluginDir, "src", "host", "a2ui", ".bundle.hash");
|
||||
const outputFile = path.join(pluginDir, "src", "host", "a2ui", "a2ui.bundle.js");
|
||||
const hashFile =
|
||||
process.env.OPENCLAW_A2UI_BUNDLE_HASH_FILE ??
|
||||
path.join(pluginDir, "src", "host", "a2ui", ".bundle.hash");
|
||||
const outputFile =
|
||||
process.env.OPENCLAW_A2UI_BUNDLE_OUT ??
|
||||
path.join(pluginDir, "src", "host", "a2ui", "a2ui.bundle.js");
|
||||
const a2uiAppDir = path.join(pluginDir, "src", "host", "a2ui-app");
|
||||
const repoInputPaths = getBundleHashRepoInputPaths(rootDir);
|
||||
const relativeRepoInputPaths = repoInputPaths.map((inputPath) =>
|
||||
|
||||
@@ -11,7 +11,9 @@ const repoRoot = path.resolve(here, "../../../../..");
|
||||
const require = createRequire(import.meta.url);
|
||||
const uiRoot = path.resolve(repoRoot, "ui");
|
||||
const fromHere = (p) => path.resolve(here, p);
|
||||
const outputFile = path.resolve(here, "..", "a2ui", "a2ui.bundle.js");
|
||||
const outputFile = process.env.OPENCLAW_A2UI_BUNDLE_OUT
|
||||
? path.resolve(process.env.OPENCLAW_A2UI_BUNDLE_OUT)
|
||||
: path.resolve(here, "..", "a2ui", "a2ui.bundle.js");
|
||||
|
||||
const a2uiLitIndex = require.resolve("@a2ui/lit");
|
||||
const a2uiLitUi = require.resolve("@a2ui/lit/ui");
|
||||
|
||||
@@ -15,6 +15,14 @@ function restoreEnvVar(name: string, value: string | undefined): void {
|
||||
}
|
||||
}
|
||||
|
||||
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
async function runChutesCatalog(params: { apiKey?: string; discoveryApiKey?: string }) {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
const result = await provider.catalog?.run({
|
||||
@@ -44,10 +52,9 @@ async function withRealChutesDiscovery<T>(
|
||||
delete process.env.VITEST;
|
||||
delete process.env.NODE_ENV;
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ data: [{ id: "chutes/private-model" }] }),
|
||||
});
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse({ data: [{ id: "chutes/private-model" }] }));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
try {
|
||||
|
||||
@@ -15,6 +15,14 @@ function restoreEnvVar(name: string, value: string | undefined): void {
|
||||
}
|
||||
}
|
||||
|
||||
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
async function withLiveChutesDiscovery<T>(
|
||||
fetchMock: ReturnType<typeof vi.fn>,
|
||||
run: () => Promise<T>,
|
||||
@@ -45,12 +53,11 @@ async function withLiveChutesDiscovery<T>(
|
||||
function createAuthEchoFetchMock() {
|
||||
return vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
|
||||
const auth = readAuthorizationHeader(init);
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
data: [{ id: auth ? `${auth}-model` : "public-model" }],
|
||||
}),
|
||||
});
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -124,9 +131,8 @@ describe("chutes-models", () => {
|
||||
});
|
||||
|
||||
it("discoverChutesModels correctly maps API response when not in test env", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [
|
||||
{ id: "zai-org/GLM-4.7-TEE" },
|
||||
{
|
||||
@@ -140,7 +146,7 @@ describe("chutes-models", () => {
|
||||
{ id: "new-provider/simple-model" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
);
|
||||
await withLiveChutesDiscovery(mockFetch, async () => {
|
||||
const models = await discoverChutesModels("test-token-real-fetch");
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
@@ -158,9 +164,8 @@ describe("chutes-models", () => {
|
||||
});
|
||||
|
||||
it("falls back from malformed live token metadata", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [
|
||||
{
|
||||
id: "provider/bad-window",
|
||||
@@ -174,7 +179,7 @@ describe("chutes-models", () => {
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
);
|
||||
|
||||
await withLiveChutesDiscovery(mockFetch, async () => {
|
||||
const models = await discoverChutesModels("malformed-token-metadata");
|
||||
@@ -195,14 +200,10 @@ describe("chutes-models", () => {
|
||||
it("discoverChutesModels retries without auth on 401", async () => {
|
||||
const mockFetch = vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
|
||||
if (readAuthorizationHeader(init) === "Bearer test-token-error") {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
});
|
||||
return Promise.resolve(new Response("", { status: 401 }));
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
data: [
|
||||
{
|
||||
id: "Qwen/Qwen3-32B",
|
||||
@@ -232,7 +233,7 @@ describe("chutes-models", () => {
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
);
|
||||
});
|
||||
await withLiveChutesDiscovery(mockFetch, async () => {
|
||||
const models = await discoverChutesModels("test-token-error");
|
||||
@@ -242,10 +243,7 @@ describe("chutes-models", () => {
|
||||
});
|
||||
|
||||
it("does not cache fallback static catalog for non-OK responses", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 503,
|
||||
});
|
||||
const mockFetch = vi.fn().mockResolvedValue(new Response("", { status: 503 }));
|
||||
|
||||
await withLiveChutesDiscovery(mockFetch, async () => {
|
||||
const first = await discoverChutesModels("chutes-fallback-token");
|
||||
@@ -260,27 +258,24 @@ describe("chutes-models", () => {
|
||||
const mockFetch = vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
|
||||
const auth = readAuthorizationHeader(init);
|
||||
if (auth === "Bearer chutes-token-a") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
data: [{ id: "private/model-a" }],
|
||||
}),
|
||||
});
|
||||
);
|
||||
}
|
||||
if (auth === "Bearer chutes-token-b") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
data: [{ id: "private/model-b" }],
|
||||
}),
|
||||
});
|
||||
);
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
data: [{ id: "public/model" }],
|
||||
}),
|
||||
});
|
||||
);
|
||||
});
|
||||
await withLiveChutesDiscovery(mockFetch, async () => {
|
||||
const modelsA = await discoverChutesModels("chutes-token-a");
|
||||
@@ -325,17 +320,13 @@ describe("chutes-models", () => {
|
||||
it("does not cache 401 fallback under the failed token key", async () => {
|
||||
const mockFetch = vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
|
||||
if (readAuthorizationHeader(init) === "Bearer failed-token") {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
});
|
||||
return Promise.resolve(new Response("", { status: 401 }));
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
data: [{ id: "public/model" }],
|
||||
}),
|
||||
});
|
||||
);
|
||||
});
|
||||
await withLiveChutesDiscovery(mockFetch, async () => {
|
||||
await discoverChutesModels("failed-token");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"id": "cloudflare-ai-gateway",
|
||||
"icon": "https://cdn.simpleicons.org/cloudflare/111111",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -1102,362 +1102,6 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks delivered message-tool-only source replies as terminal", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", { messageId: "imessage-6264" }),
|
||||
{ sourceReplyDeliveryMode: "message_tool_only" },
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal when middleware redacts receipt details", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.agentToolResultMiddlewares.push({
|
||||
pluginId: "receipt-redactor",
|
||||
pluginName: "Receipt redactor",
|
||||
rawHandler: () => undefined,
|
||||
handler: (event: { result: AgentToolResult<unknown> }) => ({
|
||||
result: {
|
||||
content: event.result.content,
|
||||
details: { redacted: true },
|
||||
},
|
||||
}),
|
||||
runtimes: ["codex"],
|
||||
source: "test",
|
||||
});
|
||||
setActivePluginRegistry(registry);
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", {
|
||||
receipt: {
|
||||
primaryPlatformMessageId: "imessage-6264",
|
||||
platformMessageIds: ["imessage-6264"],
|
||||
},
|
||||
}),
|
||||
{ sourceReplyDeliveryMode: "message_tool_only" },
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("does not treat target telemetry alone as delivered message-tool-only source reply evidence", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "chat-1",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
|
||||
expect.objectContaining({
|
||||
tool: "message",
|
||||
provider: "imessage",
|
||||
to: "chat-1",
|
||||
text: "visible reply",
|
||||
}),
|
||||
]);
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal for explicit current source routes", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", { ok: true, messageId: "imessage-853" }),
|
||||
{
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:+12069106512",
|
||||
currentMessagingTarget: "+12069106512",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "853",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal when the reply receipt matches the current message id", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", {
|
||||
ok: true,
|
||||
messageId: "provider-message-1",
|
||||
repliedTo: "provider-guid-857",
|
||||
}),
|
||||
{
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
currentMessageId: "provider-guid-857",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "857",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
|
||||
expect.objectContaining({
|
||||
tool: "message",
|
||||
provider: "imessage",
|
||||
to: "+12069106512",
|
||||
text: "visible reply",
|
||||
}),
|
||||
]);
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal when a text receipt matches the current message id", async () => {
|
||||
const receiptText = JSON.stringify({
|
||||
ok: true,
|
||||
messageId: "provider-message-1",
|
||||
repliedTo: "provider-guid-861",
|
||||
});
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult(receiptText), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
currentMessageId: "provider-guid-861",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "861",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText(receiptText));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal for explicit native target segments", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "863",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal when the provider is only in the current channel id", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "865",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("records message-tool-owned terminal replies as delivered source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
{
|
||||
...textToolResult("Sent.", { ok: true }),
|
||||
terminate: true,
|
||||
} as AgentToolResult<unknown>,
|
||||
{ sourceReplyDeliveryMode: "message_tool_only" },
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "867",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("does not treat bare send telemetry as delivered message-tool-only source reply evidence", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(bridge.telemetry.didSendViaMessagingTool).toBe(true);
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let prior message-send telemetry terminate a later non-delivery tool result", async () => {
|
||||
const execute = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(textToolResult("Sent.", { messageId: "source-reply-1" }))
|
||||
.mockResolvedValueOnce(textToolResult("No message sent.", { ok: true }));
|
||||
const bridge = createCodexDynamicToolBridge({
|
||||
tools: [createTool({ name: "message", execute })],
|
||||
signal: new AbortController().signal,
|
||||
hookContext: { sourceReplyDeliveryMode: "message_tool_only" },
|
||||
});
|
||||
|
||||
const firstResult = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
const secondResult = await bridge.handleToolCall({
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-2",
|
||||
namespace: null,
|
||||
tool: "message",
|
||||
arguments: { action: "inspect" },
|
||||
});
|
||||
|
||||
expect(firstResult.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didSendViaMessagingTool).toBe(true);
|
||||
expect(secondResult).toEqual(expectInputText("No message sent."));
|
||||
expect(secondResult.terminate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not mark explicit message-tool sends as terminal source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", { messageId: "other-chat-message" }),
|
||||
{ sourceReplyDeliveryMode: "message_tool_only" },
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
target: "channel:other",
|
||||
message: "cross-channel reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mark mismatched explicit message-tool sends as terminal source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:+12069106512",
|
||||
currentMessagingTarget: "+12069106512",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "slack",
|
||||
target: "+12069106512",
|
||||
messageId: "853",
|
||||
message: "cross-provider reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let matching reply receipts override explicit non-source routes", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", {
|
||||
ok: true,
|
||||
messageId: "other-chat-message",
|
||||
repliedTo: "provider-guid-853",
|
||||
}),
|
||||
{
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:+12069106512",
|
||||
currentMessagingTarget: "+12069106512",
|
||||
currentMessageId: "provider-guid-853",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "other-chat",
|
||||
message: "cross-channel reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not record messaging side effects when the send fails", async () => {
|
||||
const tool = createTool({
|
||||
name: "message",
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
getChannelAgentToolMeta,
|
||||
getPluginToolMeta,
|
||||
type EmbeddedRunAttemptParams,
|
||||
isDeliveredMessageToolOnlySourceReplyResult,
|
||||
isReplaySafeToolCall,
|
||||
isToolWrappedWithBeforeToolCallHook,
|
||||
isToolResultError,
|
||||
@@ -64,11 +63,9 @@ type CodexDynamicToolHookContext = {
|
||||
currentChannelProvider?: string;
|
||||
currentChannelId?: string;
|
||||
currentMessagingTarget?: string;
|
||||
currentMessageId?: string | number;
|
||||
currentThreadId?: string;
|
||||
replyToMode?: "off" | "first" | "all" | "batched";
|
||||
hasRepliedRef?: { value: boolean };
|
||||
sourceReplyDeliveryMode?: EmbeddedRunAttemptParams["sourceReplyDeliveryMode"];
|
||||
onToolOutcome?: EmbeddedRunAttemptParams["onToolOutcome"];
|
||||
allocateToolOutcomeOrdinal?: EmbeddedRunAttemptParams["allocateToolOutcomeOrdinal"];
|
||||
};
|
||||
@@ -103,166 +100,6 @@ function applyCurrentMessageProvider(
|
||||
return { ...args, provider };
|
||||
}
|
||||
|
||||
function normalizeRouteToken(value: string | number | undefined): string | undefined {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? String(value) : undefined;
|
||||
}
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
function sourceRouteTokens(hookContext: CodexDynamicToolHookContext | undefined): Set<string> {
|
||||
const tokens = new Set<string>();
|
||||
const currentTarget = normalizeRouteToken(hookContext?.currentMessagingTarget);
|
||||
const currentChannel = normalizeRouteToken(hookContext?.currentChannelId);
|
||||
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
|
||||
if (currentTarget) {
|
||||
tokens.add(currentTarget);
|
||||
}
|
||||
if (currentChannel) {
|
||||
tokens.add(currentChannel);
|
||||
}
|
||||
const channelPrefixIndex = currentChannel?.indexOf(":") ?? -1;
|
||||
if (channelPrefixIndex >= 0 && currentChannel) {
|
||||
const unprefixedChannel = currentChannel.slice(channelPrefixIndex + 1);
|
||||
if (unprefixedChannel) {
|
||||
tokens.add(unprefixedChannel);
|
||||
for (const segment of unprefixedChannel.split(/[;,]/u)) {
|
||||
const token = normalizeRouteToken(segment);
|
||||
if (token) {
|
||||
tokens.add(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentProvider && currentChannel?.startsWith(`${currentProvider}:`)) {
|
||||
const unprefixedChannel = currentChannel.slice(currentProvider.length + 1);
|
||||
if (unprefixedChannel) {
|
||||
tokens.add(unprefixedChannel);
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function routeTokenMatchesSource(
|
||||
token: string | undefined,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
): boolean {
|
||||
const normalized = normalizeRouteToken(token);
|
||||
return normalized !== undefined && sourceRouteTokens(hookContext).has(normalized);
|
||||
}
|
||||
|
||||
function routeProviderMatchesSource(
|
||||
provider: string | undefined,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
): boolean {
|
||||
const normalized = normalizeRouteToken(provider);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
|
||||
const currentChannel = normalizeRouteToken(hookContext?.currentChannelId);
|
||||
return currentProvider === normalized || currentChannel?.startsWith(`${normalized}:`) === true;
|
||||
}
|
||||
|
||||
function routeTokenMatchesCurrentMessage(
|
||||
token: string | undefined,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
): boolean {
|
||||
const normalized = normalizeRouteToken(token);
|
||||
return (
|
||||
normalized !== undefined && normalized === normalizeRouteToken(hookContext?.currentMessageId)
|
||||
);
|
||||
}
|
||||
|
||||
function replyReceiptMatchesCurrentMessage(
|
||||
value: unknown,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
depth = 0,
|
||||
): boolean {
|
||||
if (depth > 4 || value === null) {
|
||||
return false;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || !["{", "["].includes(trimmed[0] ?? "")) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return replyReceiptMatchesCurrentMessage(JSON.parse(trimmed), hookContext, depth + 1);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((item) => replyReceiptMatchesCurrentMessage(item, hookContext, depth + 1));
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
for (const key of ["repliedTo", "replyTo", "replyToId", "replyToIdFull"]) {
|
||||
if (
|
||||
routeTokenMatchesCurrentMessage(
|
||||
typeof record[key] === "string" ? record[key] : undefined,
|
||||
hookContext,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (const key of [
|
||||
"content",
|
||||
"details",
|
||||
"payload",
|
||||
"receipt",
|
||||
"result",
|
||||
"results",
|
||||
"sendResult",
|
||||
"text",
|
||||
]) {
|
||||
if (replyReceiptMatchesCurrentMessage(record[key], hookContext, depth + 1)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasExplicitNonSourceMessageRoute(
|
||||
args: Record<string, unknown>,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
messagingTarget: MessagingToolSend | undefined,
|
||||
): boolean {
|
||||
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
|
||||
for (const key of EXPLICIT_MESSAGE_PROVIDER_KEYS) {
|
||||
const provider = normalizeRouteToken(typeof args[key] === "string" ? args[key] : undefined);
|
||||
if (
|
||||
provider &&
|
||||
currentProvider !== provider &&
|
||||
!routeProviderMatchesSource(provider, hookContext)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const targetValues = [
|
||||
...EXPLICIT_MESSAGE_TARGET_KEYS.map((key) =>
|
||||
typeof args[key] === "string" ? args[key] : undefined,
|
||||
),
|
||||
...(Array.isArray(args.targets)
|
||||
? args.targets.map((value) => (typeof value === "string" ? value : undefined))
|
||||
: []),
|
||||
].filter((value): value is string => normalizeRouteToken(value) !== undefined);
|
||||
if (targetValues.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (targetValues.some((value) => !routeTokenMatchesSource(value, hookContext))) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
messagingTarget?.to !== undefined && !routeTokenMatchesSource(messagingTarget.to, hookContext)
|
||||
);
|
||||
}
|
||||
|
||||
/** Runtime bridge returned to Codex app-server attempt code. */
|
||||
export type CodexDynamicToolBridge = {
|
||||
availableSpecs: CodexDynamicToolSpec[];
|
||||
@@ -277,7 +114,6 @@ export type CodexDynamicToolBridge = {
|
||||
) => Promise<CodexDynamicToolCallResponse>;
|
||||
telemetry: {
|
||||
didSendViaMessagingTool: boolean;
|
||||
didDeliverSourceReplyViaMessageTool: boolean;
|
||||
messagingToolSentTexts: string[];
|
||||
messagingToolSentMediaUrls: string[];
|
||||
messagingToolSentTargets: MessagingToolSend[];
|
||||
@@ -296,8 +132,6 @@ export const CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE = "openclaw";
|
||||
// Keep OpenClaw session spawning searchable in Codex mode so Codex's native
|
||||
// spawn_agent remains the primary Codex subagent surface.
|
||||
const ALWAYS_DIRECT_DYNAMIC_TOOL_NAMES = new Set(["sessions_yield"]);
|
||||
const EXPLICIT_MESSAGE_PROVIDER_KEYS = ["channel", "provider"];
|
||||
const EXPLICIT_MESSAGE_TARGET_KEYS = ["target", "to", "channelId"];
|
||||
const DEFAULT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS = 16_000;
|
||||
|
||||
/**
|
||||
@@ -342,7 +176,6 @@ export function createCodexDynamicToolBridge(params: {
|
||||
emitQuarantinedDynamicToolDiagnostics(quarantinedTools, params.hookContext);
|
||||
const telemetry: CodexDynamicToolBridge["telemetry"] = {
|
||||
didSendViaMessagingTool: false,
|
||||
didDeliverSourceReplyViaMessageTool: false,
|
||||
messagingToolSentTexts: [],
|
||||
messagingToolSentMediaUrls: [],
|
||||
messagingToolSentTargets: [],
|
||||
@@ -500,9 +333,10 @@ export function createCodexDynamicToolBridge(params: {
|
||||
executedArgs,
|
||||
params.hookContext?.currentChannelProvider,
|
||||
);
|
||||
const messagingTarget = isMessagingTool(toolName)
|
||||
? extractMessagingToolSend(toolName, messagingTelemetryArgs, messagingContext)
|
||||
: undefined;
|
||||
const messagingTarget =
|
||||
isMessagingTool(toolName) && isMessagingToolSendAction(toolName, executedArgs)
|
||||
? extractMessagingToolSend(toolName, messagingTelemetryArgs, messagingContext)
|
||||
: undefined;
|
||||
const confirmedMessagingTarget =
|
||||
!rawIsError && messagingTarget
|
||||
? extractMessagingToolSendResult(messagingTarget, telemetryRawResult)
|
||||
@@ -524,46 +358,12 @@ export function createCodexDynamicToolBridge(params: {
|
||||
},
|
||||
terminalType,
|
||||
);
|
||||
const blocksSourceReplyTermination = hasExplicitNonSourceMessageRoute(
|
||||
executedArgs,
|
||||
params.hookContext,
|
||||
confirmedMessagingTarget,
|
||||
);
|
||||
const deliveredSourceReply = isDeliveredMessageToolOnlySourceReplyResult({
|
||||
sourceReplyDeliveryMode: params.hookContext?.sourceReplyDeliveryMode,
|
||||
toolName,
|
||||
args: executedArgs,
|
||||
result,
|
||||
hookResult: rawResult,
|
||||
isError: resultIsError,
|
||||
allowExplicitSourceRoute: !blocksSourceReplyTermination,
|
||||
});
|
||||
const receiptConfirmedSourceReply =
|
||||
params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" &&
|
||||
toolName === "message" &&
|
||||
normalizeRouteToken(
|
||||
typeof executedArgs.action === "string" ? executedArgs.action : undefined,
|
||||
) === "reply" &&
|
||||
!resultIsError &&
|
||||
!blocksSourceReplyTermination &&
|
||||
(replyReceiptMatchesCurrentMessage(rawResult, params.hookContext) ||
|
||||
replyReceiptMatchesCurrentMessage(result, params.hookContext));
|
||||
const toolConfirmedSourceReply =
|
||||
params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" &&
|
||||
toolName === "message" &&
|
||||
!resultIsError &&
|
||||
(rawResult.terminate === true || result.terminate === true);
|
||||
if (deliveredSourceReply || receiptConfirmedSourceReply || toolConfirmedSourceReply) {
|
||||
telemetry.didDeliverSourceReplyViaMessageTool = true;
|
||||
}
|
||||
withDynamicToolTermination(
|
||||
response,
|
||||
rawResult.terminate === true ||
|
||||
result.terminate === true ||
|
||||
isToolResultYield(rawResult) ||
|
||||
isToolResultYield(result) ||
|
||||
deliveredSourceReply ||
|
||||
receiptConfirmedSourceReply,
|
||||
isToolResultYield(result),
|
||||
);
|
||||
const asyncStarted =
|
||||
isAsyncStartedToolResult(rawResult) || isAsyncStartedToolResult(result);
|
||||
@@ -1003,7 +803,7 @@ function collectToolTelemetry(params: {
|
||||
}
|
||||
if (
|
||||
!isMessagingTool(params.toolName) ||
|
||||
(!isMessagingToolSendAction(params.toolName, params.args) && !params.messagingTarget)
|
||||
!isMessagingToolSendAction(params.toolName, params.args)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -794,19 +794,6 @@ describe("CodexAppServerEventProjector", () => {
|
||||
expect(result.toolMediaUrls).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("propagates message-tool-only source reply delivery telemetry", async () => {
|
||||
const projector = await createProjector();
|
||||
|
||||
const result = projector.buildResult({
|
||||
...buildEmptyToolTelemetry(),
|
||||
didSendViaMessagingTool: true,
|
||||
didDeliverSourceReplyViaMessageTool: true,
|
||||
});
|
||||
|
||||
expect(result.didSendViaMessagingTool).toBe(true);
|
||||
expect(result.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
});
|
||||
|
||||
it("does not promote repeated tool progress text to the final assistant reply", async () => {
|
||||
const onToolResult = vi.fn();
|
||||
const projector = await createProjector({
|
||||
|
||||
@@ -53,7 +53,6 @@ import { attachCodexMirrorIdentity, buildCodexUserPromptMessage } from "./transc
|
||||
|
||||
export type CodexAppServerToolTelemetry = {
|
||||
didSendViaMessagingTool: boolean;
|
||||
didDeliverSourceReplyViaMessageTool?: boolean;
|
||||
messagingToolSentTexts: string[];
|
||||
messagingToolSentMediaUrls: string[];
|
||||
messagingToolSentTargets: MessagingToolSend[];
|
||||
@@ -413,8 +412,6 @@ export class CodexAppServerEventProjector {
|
||||
currentAttemptAssistant,
|
||||
...(this.lastNativeToolError ? { lastToolError: this.lastNativeToolError } : {}),
|
||||
didSendViaMessagingTool: toolTelemetry.didSendViaMessagingTool,
|
||||
didDeliverSourceReplyViaMessageTool:
|
||||
toolTelemetry.didDeliverSourceReplyViaMessageTool === true,
|
||||
messagingToolSentTexts: toolTelemetry.messagingToolSentTexts,
|
||||
messagingToolSentMediaUrls: toolTelemetry.messagingToolSentMediaUrls,
|
||||
messagingToolSentTargets: toolTelemetry.messagingToolSentTargets,
|
||||
|
||||
@@ -841,11 +841,9 @@ export async function runCodexAppServerAttempt(
|
||||
currentChannelProvider: resolveCodexMessageToolProvider(params),
|
||||
currentChannelId: params.currentChannelId,
|
||||
currentMessagingTarget: params.currentMessagingTarget,
|
||||
currentMessageId: params.currentMessageId,
|
||||
currentThreadId: params.currentThreadTs,
|
||||
replyToMode: params.replyToMode,
|
||||
hasRepliedRef: params.hasRepliedRef,
|
||||
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
|
||||
onToolOutcome: onCodexToolOutcome,
|
||||
allocateToolOutcomeOrdinal: allocateCodexToolOutcomeOrdinal,
|
||||
},
|
||||
|
||||
@@ -360,6 +360,15 @@ export async function mirrorCodexAppServerTranscript(params: {
|
||||
sessionFile: params.sessionFile,
|
||||
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
...(params.sessionId && params.sessionKey && params.agentId
|
||||
? {
|
||||
target: {
|
||||
agentId: params.agentId,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
message: update.message,
|
||||
messageId: update.messageId,
|
||||
messageSeq: update.messageSeq,
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
* turns replies into app-server answer payloads.
|
||||
*/
|
||||
import {
|
||||
buildAgentHarnessUserInputAnswers,
|
||||
deliverAgentHarnessUserInputPrompt,
|
||||
embeddedAgentLog,
|
||||
emptyAgentHarnessUserInputAnswers,
|
||||
type AgentHarnessUserInputOption,
|
||||
type AgentHarnessUserInputQuestion,
|
||||
type EmbeddedRunAttemptParams,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { formatCodexDisplayText } from "../command-formatters.js";
|
||||
@@ -19,25 +24,11 @@ type PendingUserInput = {
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
itemId: string;
|
||||
questions: UserInputQuestion[];
|
||||
questions: AgentHarnessUserInputQuestion[];
|
||||
resolve: (value: JsonValue) => void;
|
||||
cleanup: () => void;
|
||||
};
|
||||
|
||||
type UserInputQuestion = {
|
||||
id: string;
|
||||
header: string;
|
||||
question: string;
|
||||
isOther: boolean;
|
||||
isSecret: boolean;
|
||||
options: UserInputOption[] | null;
|
||||
};
|
||||
|
||||
type UserInputOption = {
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type CodexUserInputBridge = {
|
||||
handleRequest: (request: {
|
||||
id: number | string;
|
||||
@@ -142,7 +133,7 @@ function readUserInputParams(value: JsonValue | undefined):
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
itemId: string;
|
||||
questions: UserInputQuestion[];
|
||||
questions: AgentHarnessUserInputQuestion[];
|
||||
}
|
||||
| undefined {
|
||||
if (!isJsonObject(value)) {
|
||||
@@ -157,11 +148,11 @@ function readUserInputParams(value: JsonValue | undefined):
|
||||
}
|
||||
const questions = questionsRaw
|
||||
.map(readQuestion)
|
||||
.filter((question): question is UserInputQuestion => Boolean(question));
|
||||
.filter((question): question is AgentHarnessUserInputQuestion => Boolean(question));
|
||||
return { threadId, turnId, itemId, questions };
|
||||
}
|
||||
|
||||
function readQuestion(value: JsonValue): UserInputQuestion | undefined {
|
||||
function readQuestion(value: JsonValue): AgentHarnessUserInputQuestion | undefined {
|
||||
if (!isJsonObject(value)) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -181,17 +172,17 @@ function readQuestion(value: JsonValue): UserInputQuestion | undefined {
|
||||
};
|
||||
}
|
||||
|
||||
function readOptions(value: JsonValue | undefined): UserInputOption[] | null {
|
||||
function readOptions(value: JsonValue | undefined): AgentHarnessUserInputOption[] | null {
|
||||
if (!Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const options = value
|
||||
.map(readOption)
|
||||
.filter((option): option is UserInputOption => Boolean(option));
|
||||
.filter((option): option is AgentHarnessUserInputOption => Boolean(option));
|
||||
return options.length > 0 ? options : null;
|
||||
}
|
||||
|
||||
function readOption(value: JsonValue): UserInputOption | undefined {
|
||||
function readOption(value: JsonValue): AgentHarnessUserInputOption | undefined {
|
||||
if (!isJsonObject(value)) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -202,116 +193,25 @@ function readOption(value: JsonValue): UserInputOption | undefined {
|
||||
|
||||
async function deliverUserInputPrompt(
|
||||
params: EmbeddedRunAttemptParams,
|
||||
questions: UserInputQuestion[],
|
||||
questions: AgentHarnessUserInputQuestion[],
|
||||
): Promise<void> {
|
||||
const text = formatUserInputPrompt(questions);
|
||||
if (params.onBlockReply) {
|
||||
await params.onBlockReply({ text });
|
||||
return;
|
||||
}
|
||||
await params.onPartialReply?.({ text });
|
||||
}
|
||||
|
||||
function formatUserInputPrompt(questions: UserInputQuestion[]): string {
|
||||
const lines = ["Codex needs input:"];
|
||||
questions.forEach((question, index) => {
|
||||
if (questions.length > 1) {
|
||||
lines.push(
|
||||
"",
|
||||
`${index + 1}. ${formatCodexDisplayText(question.header)}`,
|
||||
formatCodexDisplayText(question.question),
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
"",
|
||||
formatCodexDisplayText(question.header),
|
||||
formatCodexDisplayText(question.question),
|
||||
);
|
||||
}
|
||||
if (question.isSecret) {
|
||||
lines.push("This channel may show your reply to other participants.");
|
||||
}
|
||||
question.options?.forEach((option, optionIndex) => {
|
||||
lines.push(
|
||||
`${optionIndex + 1}. ${formatCodexDisplayText(option.label)}${
|
||||
option.description ? ` - ${formatCodexDisplayText(option.description)}` : ""
|
||||
}`,
|
||||
);
|
||||
});
|
||||
if (question.isOther) {
|
||||
lines.push("Other: reply with your own answer.");
|
||||
}
|
||||
await deliverAgentHarnessUserInputPrompt(params, questions, {
|
||||
formatText: formatCodexDisplayText,
|
||||
intro: "Codex needs input:",
|
||||
});
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function buildUserInputResponse(questions: UserInputQuestion[], inputText: string): JsonObject {
|
||||
function buildUserInputResponse(
|
||||
questions: AgentHarnessUserInputQuestion[],
|
||||
inputText: string,
|
||||
): JsonObject {
|
||||
// Multi-question replies may use "header: answer" or numbered lines. Keep the
|
||||
// parser permissive so chat-channel replies remain ergonomic.
|
||||
const answers: JsonObject = {};
|
||||
if (questions.length === 1) {
|
||||
const question = questions[0];
|
||||
if (question) {
|
||||
const answer = normalizeAnswer(inputText, question);
|
||||
answers[question.id] = { answers: answer ? [answer] : [] };
|
||||
}
|
||||
return { answers };
|
||||
}
|
||||
|
||||
const keyed = parseKeyedAnswers(inputText);
|
||||
const fallbackLines = inputText
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
questions.forEach((question, index) => {
|
||||
const key =
|
||||
keyed.get(question.id.toLowerCase()) ??
|
||||
keyed.get(question.header.toLowerCase()) ??
|
||||
keyed.get(question.question.toLowerCase()) ??
|
||||
keyed.get(String(index + 1));
|
||||
const answer = key ?? fallbackLines[index] ?? "";
|
||||
const normalized = answer ? normalizeAnswer(answer, question) : undefined;
|
||||
answers[question.id] = { answers: normalized ? [normalized] : [] };
|
||||
});
|
||||
return { answers };
|
||||
}
|
||||
|
||||
function normalizeAnswer(answer: string, question: UserInputQuestion): string | undefined {
|
||||
const trimmed = answer.trim();
|
||||
const options = question.options ?? [];
|
||||
const optionIndex = /^\d+$/.test(trimmed) ? Number(trimmed) - 1 : -1;
|
||||
const indexed = optionIndex >= 0 ? options[optionIndex] : undefined;
|
||||
if (indexed) {
|
||||
return indexed.label;
|
||||
}
|
||||
const exact = options.find((option) => option.label.toLowerCase() === trimmed.toLowerCase());
|
||||
if (exact) {
|
||||
return exact.label;
|
||||
}
|
||||
if (options.length > 0 && !question.isOther) {
|
||||
return undefined;
|
||||
}
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function parseKeyedAnswers(inputText: string): Map<string, string> {
|
||||
const answers = new Map<string, string>();
|
||||
for (const line of inputText.split(/\r?\n/)) {
|
||||
const match = line.match(/^\s*([^:=-]+?)\s*[:=-]\s*(.+?)\s*$/);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const key = match[1]?.trim().toLowerCase();
|
||||
const value = match[2]?.trim();
|
||||
if (key && value) {
|
||||
answers.set(key, value);
|
||||
}
|
||||
}
|
||||
return answers;
|
||||
return buildAgentHarnessUserInputAnswers(questions, inputText) as unknown as JsonObject;
|
||||
}
|
||||
|
||||
function emptyUserInputResponse(): JsonObject {
|
||||
return { answers: {} };
|
||||
return emptyAgentHarnessUserInputAnswers() as unknown as JsonObject;
|
||||
}
|
||||
|
||||
function readString(record: JsonObject, key: string): string | undefined {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"id": "copilot-proxy",
|
||||
"icon": "https://cdn.simpleicons.org/githubcopilot/111111",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -1609,6 +1609,12 @@ describe("createCopilotAgentHarness", () => {
|
||||
success: true,
|
||||
tokensRemoved: 123,
|
||||
messagesRemoved: 4,
|
||||
summaryContent: "compacted summary",
|
||||
contextWindow: {
|
||||
tokenLimit: 1000,
|
||||
currentTokens: 777,
|
||||
messagesLength: 12,
|
||||
},
|
||||
}));
|
||||
const disconnect = vi.fn(async () => {
|
||||
throw new Error("disconnect failed");
|
||||
@@ -1649,6 +1655,7 @@ describe("createCopilotAgentHarness", () => {
|
||||
model: "gpt-4.1",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionId: "oc-sess-compact-1",
|
||||
currentTokenCount: 900,
|
||||
workspaceDir: "/this\u0000is/illegal",
|
||||
customInstructions: "Keep decisions.",
|
||||
});
|
||||
@@ -1684,6 +1691,25 @@ describe("createCopilotAgentHarness", () => {
|
||||
ok: true,
|
||||
compacted: true,
|
||||
reason: "copilot-sdk-history-compacted",
|
||||
result: {
|
||||
summary: "compacted summary",
|
||||
firstKeptEntryId: "",
|
||||
tokensBefore: 900,
|
||||
tokensAfter: 777,
|
||||
details: {
|
||||
success: true,
|
||||
tokensRemoved: 123,
|
||||
messagesRemoved: 4,
|
||||
summaryContent: "compacted summary",
|
||||
contextWindow: {
|
||||
tokenLimit: 1000,
|
||||
currentTokens: 777,
|
||||
messagesLength: 12,
|
||||
},
|
||||
},
|
||||
sessionId: "oc-sess-compact-1",
|
||||
sessionFile: "/session.json",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -62,6 +62,14 @@ interface CopilotHistoryCompactResult {
|
||||
tokensRemoved: number;
|
||||
messagesRemoved: number;
|
||||
summaryContent?: string;
|
||||
contextWindow?: {
|
||||
tokenLimit: number;
|
||||
currentTokens: number;
|
||||
messagesLength: number;
|
||||
systemTokens?: number;
|
||||
conversationTokens?: number;
|
||||
toolDefinitionsTokens?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface CopilotHistoryCompactSession {
|
||||
@@ -872,6 +880,21 @@ export function createCopilotAgentHarness(
|
||||
ok: true,
|
||||
compacted,
|
||||
reason: compacted ? "copilot-sdk-history-compacted" : "already under target",
|
||||
...(compacted
|
||||
? {
|
||||
result: {
|
||||
summary: compactResult.summaryContent ?? "",
|
||||
firstKeptEntryId: "",
|
||||
tokensBefore:
|
||||
params.currentTokenCount ??
|
||||
(compactResult.contextWindow?.currentTokens ?? 0) + compactResult.tokensRemoved,
|
||||
tokensAfter: compactResult.contextWindow?.currentTokens,
|
||||
details: compactResult,
|
||||
sessionId: params.sessionId,
|
||||
sessionFile: params.sessionFile,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "copilot",
|
||||
"name": "GitHub Copilot agent runtime",
|
||||
"description": "Registers the GitHub Copilot agent runtime.",
|
||||
"icon": "https://cdn.simpleicons.org/githubcopilot/111111",
|
||||
"version": "2026.6.2",
|
||||
"activation": {
|
||||
"onStartup": false,
|
||||
|
||||
@@ -3,9 +3,11 @@ import fsp from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { CopilotClient, Tool as SdkTool } from "@github/copilot-sdk";
|
||||
import type {
|
||||
AgentHarnessAttemptParams,
|
||||
AgentHarnessAttemptResult,
|
||||
import {
|
||||
abortAgentHarnessRun,
|
||||
queueAgentHarnessMessage,
|
||||
type AgentHarnessAttemptParams,
|
||||
type AgentHarnessAttemptResult,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import type { SandboxContext } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
@@ -1171,6 +1173,36 @@ describe("runCopilotAttempt", () => {
|
||||
expect(result.externalAbort).toBe(true);
|
||||
});
|
||||
|
||||
it("active-run abort path marks the attempt as externally aborted", async () => {
|
||||
const sendDeferred = createDeferred<SessionEventShape | undefined>();
|
||||
const sessionCreated = createDeferred<FakeSession>();
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
session.sendAndWait.mockReturnValue(sendDeferred.promise);
|
||||
session.abort.mockImplementationOnce(async () => {
|
||||
sendDeferred.resolve(undefined);
|
||||
});
|
||||
sessionCreated.resolve(session);
|
||||
},
|
||||
});
|
||||
const pool = makeFakePool(sdk);
|
||||
const createToolBridge = vi.fn(async () => ({ sdkTools: [], sourceTools: [] }));
|
||||
|
||||
const runPromise = runCopilotAttempt(makeParams(), {
|
||||
createToolBridge,
|
||||
pool,
|
||||
});
|
||||
const session = await sessionCreated.promise;
|
||||
await vi.waitFor(() => expect(session.sendAndWait).toHaveBeenCalledTimes(1));
|
||||
|
||||
expect(abortAgentHarnessRun("session-1")).toBe(true);
|
||||
const result = await runPromise;
|
||||
|
||||
expect(session.abort).toHaveBeenCalledTimes(1);
|
||||
expect(result.aborted).toBe(true);
|
||||
expect(result.externalAbort).toBe(true);
|
||||
});
|
||||
|
||||
it("abort path (signal already aborted)", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
@@ -1447,18 +1479,42 @@ describe("runCopilotAttempt", () => {
|
||||
expect(result.feedback).toContain("no permission policy installed");
|
||||
});
|
||||
|
||||
it("does not register onUserInputRequest (ask_user hidden from the model in MVP)", async () => {
|
||||
const sdk = makeFakeSdk();
|
||||
it("registers ask_user and resolves it from the active OpenClaw queue", async () => {
|
||||
const onBlockReply = vi.fn();
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session, cfg) => {
|
||||
session.sendAndWait.mockImplementationOnce(async () => {
|
||||
const handler = cfg.onUserInputRequest;
|
||||
if (typeof handler !== "function") {
|
||||
throw new Error("expected onUserInputRequest handler");
|
||||
}
|
||||
const response = await handler(
|
||||
{
|
||||
question: "Pick a mode",
|
||||
choices: ["Fast", "Deep"],
|
||||
allowFreeform: false,
|
||||
},
|
||||
{ sessionId: session.sessionId },
|
||||
);
|
||||
return makeAssistantMessageEvent(`selected ${response.answer}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
const pool = makeFakePool(sdk);
|
||||
|
||||
await runCopilotAttempt(makeParams(), { pool });
|
||||
const attempt = runCopilotAttempt(makeParams({ onBlockReply }), { pool });
|
||||
|
||||
await vi.waitFor(() => expect(onBlockReply).toHaveBeenCalledTimes(1));
|
||||
expect(queueAgentHarnessMessage("session-1", "2")).toBe(true);
|
||||
const result = await attempt;
|
||||
|
||||
const cfg = sdk.createSession.mock.calls[0]?.[0];
|
||||
// Per the SDK contract (types.d.ts: `When provided, enables the
|
||||
// ask_user tool allowing the agent to ask questions`), omitting the
|
||||
// handler hides ask_user from the model entirely. The MVP keeps it
|
||||
// hidden until a real channel/TUI prompt bridge exists.
|
||||
expect("onUserInputRequest" in cfg).toBe(false);
|
||||
expect(typeof cfg.onUserInputRequest).toBe("function");
|
||||
expect(onBlockReply.mock.calls[0]?.[0]).toEqual(
|
||||
expect.objectContaining({ text: expect.stringContaining("Pick a mode") }),
|
||||
);
|
||||
expect(result.assistantTexts).toEqual(["selected Deep"]);
|
||||
expect(queueAgentHarnessMessage("session-1", "late")).toBe(false);
|
||||
});
|
||||
|
||||
it("enableSessionTelemetry is omitted from createSession when undefined (SDK default)", async () => {
|
||||
@@ -1854,6 +1910,7 @@ describe("runCopilotAttempt", () => {
|
||||
it("retains a timed-out session until later compaction reaches session.idle", async () => {
|
||||
const afterCompaction = vi.fn();
|
||||
const onDeferredCompaction = vi.fn();
|
||||
const cleanupToolBridge = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "after_compaction", handler: afterCompaction }]),
|
||||
);
|
||||
@@ -1866,8 +1923,14 @@ describe("runCopilotAttempt", () => {
|
||||
);
|
||||
},
|
||||
});
|
||||
const createToolBridge = vi.fn(async () => ({
|
||||
cleanup: cleanupToolBridge,
|
||||
sdkTools: [],
|
||||
sourceTools: [],
|
||||
}));
|
||||
|
||||
const result = await runCopilotAttempt(makeParams(), {
|
||||
createToolBridge,
|
||||
onDeferredCompaction,
|
||||
pool: makeFakePool(sdk),
|
||||
});
|
||||
@@ -1877,6 +1940,7 @@ describe("runCopilotAttempt", () => {
|
||||
expect(onDeferredCompaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sdkSessionId: "sess-1" }),
|
||||
);
|
||||
expect(cleanupToolBridge).not.toHaveBeenCalled();
|
||||
expect(activeSession?.disconnect).not.toHaveBeenCalled();
|
||||
|
||||
activeSession?.emit("session.compaction_start", {});
|
||||
@@ -1890,6 +1954,7 @@ describe("runCopilotAttempt", () => {
|
||||
await vi.waitFor(() => {
|
||||
expect(activeSession?.disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(cleanupToolBridge).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not mark a timeout after SDK compaction has completed as active compaction", async () => {
|
||||
@@ -3066,7 +3131,8 @@ describe("runCopilotAttempt", () => {
|
||||
// permission policy and pollute the catalog under the default reject
|
||||
// policy. `createSessionConfig` derives `availableTools` from the
|
||||
// post-filter `sdkTools` so create- and resume-session always carry
|
||||
// exactly the names of the tools the bridge actually exposed.
|
||||
// exactly the names of the tools the bridge actually exposed plus the
|
||||
// built-in `ask_user` tool owned by the registered user-input handler.
|
||||
describe("availableTools surface restriction (PR #86155 [P1] round-8)", () => {
|
||||
function makeFakeSdkTool(name: string): SdkTool {
|
||||
return {
|
||||
@@ -3090,7 +3156,11 @@ describe("runCopilotAttempt", () => {
|
||||
|
||||
await runCopilotAttempt(makeParams(), { createToolBridge, pool });
|
||||
|
||||
expect(readAvailableTools(sdk.createSession.mock.calls[0])).toEqual(["read", "edit"]);
|
||||
expect(readAvailableTools(sdk.createSession.mock.calls[0])).toEqual([
|
||||
"read",
|
||||
"edit",
|
||||
"builtin:ask_user",
|
||||
]);
|
||||
});
|
||||
|
||||
it("forwards `[]` to the SDK when the bridge returns no tools (disable / raw / fully filtered)", async () => {
|
||||
@@ -3100,12 +3170,13 @@ describe("runCopilotAttempt", () => {
|
||||
// (`modelRun: true` or `promptMode: "none"`), an empty
|
||||
// `toolsAllow: []`, and an unsupported provider to `sdkTools: []`.
|
||||
// Whatever the upstream reason, `availableTools` must be the same
|
||||
// empty list so the SDK cannot fall back to its native catalog.
|
||||
// ask_user-only list so the SDK cannot fall back to its native
|
||||
// catalog while the registered user-input handler remains usable.
|
||||
const createToolBridge = vi.fn(async () => ({ sdkTools: [], sourceTools: [] }));
|
||||
|
||||
await runCopilotAttempt(makeParams(), { createToolBridge, pool });
|
||||
|
||||
expect(readAvailableTools(sdk.createSession.mock.calls[0])).toEqual([]);
|
||||
expect(readAvailableTools(sdk.createSession.mock.calls[0])).toEqual(["builtin:ask_user"]);
|
||||
});
|
||||
|
||||
it("forwards the full bridged set when the run is unrestricted (no toolsAllow)", async () => {
|
||||
@@ -3131,6 +3202,7 @@ describe("runCopilotAttempt", () => {
|
||||
"edit",
|
||||
"exec",
|
||||
"message",
|
||||
"builtin:ask_user",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -3156,7 +3228,7 @@ describe("runCopilotAttempt", () => {
|
||||
|
||||
const resumeCall = sdk.resumeSession.mock.calls[0] as unknown[] | undefined;
|
||||
const resumeCfg = resumeCall?.[1] as { availableTools?: string[] };
|
||||
expect(resumeCfg?.availableTools).toEqual(["read"]);
|
||||
expect(resumeCfg?.availableTools).toEqual(["read", "builtin:ask_user"]);
|
||||
});
|
||||
|
||||
it("forwards `[]` to resumeSession when the bridge returns no tools", async () => {
|
||||
@@ -3175,7 +3247,7 @@ describe("runCopilotAttempt", () => {
|
||||
|
||||
const resumeCall = sdk.resumeSession.mock.calls[0] as unknown[] | undefined;
|
||||
const resumeCfg = resumeCall?.[1] as { availableTools?: string[] };
|
||||
expect(resumeCfg?.availableTools).toEqual([]);
|
||||
expect(resumeCfg?.availableTools).toEqual(["builtin:ask_user"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
runAgentEndSideEffects,
|
||||
runAgentHarnessLlmInputHook,
|
||||
runAgentHarnessLlmOutputHook,
|
||||
clearActiveEmbeddedRun,
|
||||
setActiveEmbeddedRun,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { resolveCopilotAuth } from "./auth-bridge.js";
|
||||
import {
|
||||
@@ -42,6 +44,7 @@ import {
|
||||
type SessionLike,
|
||||
} from "./event-bridge.js";
|
||||
import { createHooksBridge, type CopilotHooksConfig } from "./hooks-bridge.js";
|
||||
import { createCopilotNativeSubagentTaskMirror } from "./native-subagent-task-mirror.js";
|
||||
import {
|
||||
createPermissionBridge,
|
||||
rejectAllPolicy,
|
||||
@@ -55,10 +58,12 @@ import {
|
||||
} from "./replay-shim.js";
|
||||
import type { ClientCreateOptions, CopilotClientPool, PoolKey, PooledClient } from "./runtime.js";
|
||||
import { createCopilotToolBridge } from "./tool-bridge.js";
|
||||
import { createCopilotUserInputBridge } from "./user-input-bridge.js";
|
||||
import { resolveCopilotWorkspaceBootstrapContext } from "./workspace-bootstrap.js";
|
||||
|
||||
const SUPPORTED_PROVIDERS = new Set(["github-copilot"]);
|
||||
const BACKGROUND_COMPACTION_CANCEL_TIMEOUT_MS = 5_000;
|
||||
const COPILOT_ASK_USER_AVAILABLE_TOOLS = ["builtin:ask_user"] as const;
|
||||
|
||||
type AttemptResultWithSdkSessionId = AgentHarnessAttemptResult & { sdkSessionId?: string };
|
||||
type PromptErrorWithCode = Error & { code?: string; cause?: unknown };
|
||||
@@ -73,6 +78,7 @@ export type CopilotSessionConfig = Pick<
|
||||
| "infiniteSessions"
|
||||
| "model"
|
||||
| "onPermissionRequest"
|
||||
| "onUserInputRequest"
|
||||
| "reasoningEffort"
|
||||
| "systemMessage"
|
||||
| "tools"
|
||||
@@ -222,6 +228,8 @@ function deferBackgroundCompactionCleanup(params: {
|
||||
bridge: ReturnType<typeof attachEventBridge>;
|
||||
handle: PooledClient;
|
||||
pool: CopilotClientPool;
|
||||
cleanupToolBridge?: () => void;
|
||||
finalizeNativeSubagents?: () => void;
|
||||
sdkSessionId?: string;
|
||||
session: SessionLike;
|
||||
timeoutMs: number;
|
||||
@@ -244,12 +252,14 @@ function deferBackgroundCompactionCleanup(params: {
|
||||
await cancelBackgroundCompactionBeforeTeardown(params.session);
|
||||
params.bridge.settleCompactionWait();
|
||||
}
|
||||
params.finalizeNativeSubagents?.();
|
||||
params.bridge.detach();
|
||||
try {
|
||||
await params.session.disconnect();
|
||||
} catch {
|
||||
// The attempt has already returned its timeout result.
|
||||
}
|
||||
params.cleanupToolBridge?.();
|
||||
if (outcome !== "completed" && params.sdkSessionId) {
|
||||
try {
|
||||
await params.handle.client.deleteSession(params.sdkSessionId);
|
||||
@@ -403,6 +413,14 @@ export async function runCopilotAttempt(
|
||||
let handle: PooledClient | undefined;
|
||||
let session: SessionLike | undefined;
|
||||
let bridge: ReturnType<typeof attachEventBridge> | undefined;
|
||||
const nativeSubagentTaskMirror = createCopilotNativeSubagentTaskMirror({
|
||||
agentId: sessionAgentId,
|
||||
now,
|
||||
scope: input.agentHarnessTaskRuntimeScope,
|
||||
});
|
||||
let activeRunHandleRef: Parameters<typeof clearActiveEmbeddedRun>[1] | undefined;
|
||||
let userInputBridgeRef: ReturnType<typeof createCopilotUserInputBridge> | undefined;
|
||||
let cleanupToolBridge: (() => void) | undefined;
|
||||
let releaseError: Error | undefined;
|
||||
let downgradedFromResume = false;
|
||||
let resumeFailureRecovered = false;
|
||||
@@ -415,16 +433,24 @@ export async function runCopilotAttempt(
|
||||
// `src/agents/pi-embedded-runner/run/types.ts:139`.
|
||||
let yieldDetected = false;
|
||||
|
||||
const onAbort = () => {
|
||||
const markExternalAbort = () => {
|
||||
abortRequested = true;
|
||||
externalAbort = true;
|
||||
aborted = true;
|
||||
};
|
||||
|
||||
const abortActiveSession = () => {
|
||||
markExternalAbort();
|
||||
if (settled || !sentTurnStarted || !session) {
|
||||
return;
|
||||
}
|
||||
void session.abort().catch(() => undefined);
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
abortActiveSession();
|
||||
};
|
||||
|
||||
params.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
// Sandbox parity with PI (`src/agents/pi-embedded-runner/run/attempt.ts:1232-1244`):
|
||||
@@ -575,6 +601,7 @@ export async function runCopilotAttempt(
|
||||
startedAt,
|
||||
}),
|
||||
});
|
||||
cleanupToolBridge = toolBridge.cleanup;
|
||||
sdkTools = toolBridge.sdkTools;
|
||||
} catch (error: unknown) {
|
||||
const result = createResult(input, {
|
||||
@@ -655,6 +682,11 @@ export async function runCopilotAttempt(
|
||||
});
|
||||
};
|
||||
const hasNativePromptHook = Boolean(attemptInput.hooksConfig?.onUserPromptSubmitted);
|
||||
const userInputBridge = createCopilotUserInputBridge({
|
||||
paramsForRun: attemptInput,
|
||||
signal: params.abortSignal,
|
||||
});
|
||||
userInputBridgeRef = userInputBridge;
|
||||
const sessionConfig = createSessionConfig(
|
||||
attemptInput,
|
||||
modelRef.id,
|
||||
@@ -663,6 +695,7 @@ export async function runCopilotAttempt(
|
||||
promptBuild.developerInstructions || undefined,
|
||||
effectiveWorkspaceDir,
|
||||
effectiveCwd,
|
||||
userInputBridge.onUserInputRequest,
|
||||
hasNativePromptHook
|
||||
? {
|
||||
onUserPromptSubmitted: ({ additionalContext, prompt }) =>
|
||||
@@ -723,6 +756,8 @@ export async function runCopilotAttempt(
|
||||
}
|
||||
bridge = attachEventBridge(session, {
|
||||
onAssistantDelta: input.onAssistantDelta,
|
||||
onAgentEvent: input.onAgentEvent,
|
||||
onNativeSubagentEvent: (event) => nativeSubagentTaskMirror?.handleEvent(event),
|
||||
onCompactionStart: async () => {
|
||||
const sessionFile = readString(input.sessionFile);
|
||||
if (!sessionFile) {
|
||||
@@ -748,6 +783,29 @@ export async function runCopilotAttempt(
|
||||
isAborted: () => aborted,
|
||||
});
|
||||
|
||||
const activeRunHandle = {
|
||||
kind: "embedded" as const,
|
||||
queueMessage: async (text: string) => {
|
||||
if (userInputBridge.handleQueuedMessage(text)) {
|
||||
return;
|
||||
}
|
||||
throw new Error("Copilot runtime is not waiting for user input.");
|
||||
},
|
||||
isStreaming: () => !settled && !aborted,
|
||||
isCompacting: () => bridge?.isCompacting() ?? false,
|
||||
sourceReplyDeliveryMode: input.sourceReplyDeliveryMode,
|
||||
cancel: () => {
|
||||
userInputBridge.cancelPending();
|
||||
abortActiveSession();
|
||||
},
|
||||
abort: () => {
|
||||
userInputBridge.cancelPending();
|
||||
abortActiveSession();
|
||||
},
|
||||
};
|
||||
setActiveEmbeddedRun(input.sessionId, activeRunHandle, input.sessionKey, input.sessionFile);
|
||||
activeRunHandleRef = activeRunHandle;
|
||||
|
||||
const messageOptions = await createMessageOptions(attemptInput, {
|
||||
effectiveCwd,
|
||||
effectiveWorkspaceDir,
|
||||
@@ -765,6 +823,7 @@ export async function runCopilotAttempt(
|
||||
}
|
||||
const result = await session.sendAndWait(messageOptions, input.timeoutMs);
|
||||
await bridge.awaitDeltaChain();
|
||||
await bridge.awaitAgentEventChain();
|
||||
if (!bridge.recordSendResult(result) && !aborted) {
|
||||
// SDK sendAndWait returning undefined is treated as a timeout by the
|
||||
// capability inventory. Do not call session.abort() here: OpenClaw may
|
||||
@@ -800,12 +859,22 @@ export async function runCopilotAttempt(
|
||||
} catch {
|
||||
// delta-flush failure must not mask the timeout state
|
||||
}
|
||||
await bridge?.awaitAgentEventChain();
|
||||
} else {
|
||||
promptError = toError(error);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
settled = true;
|
||||
userInputBridgeRef?.cancelPending();
|
||||
if (activeRunHandleRef) {
|
||||
clearActiveEmbeddedRun(
|
||||
input.sessionId,
|
||||
activeRunHandleRef,
|
||||
input.sessionKey,
|
||||
input.sessionFile,
|
||||
);
|
||||
}
|
||||
const retainSessionForDeferredCleanup =
|
||||
bridge?.hasObservedCompaction() || (timedOut && bridge?.hasObservedSessionIdle() === false);
|
||||
if (retainSessionForDeferredCleanup && bridge && session && handle) {
|
||||
@@ -820,6 +889,8 @@ export async function runCopilotAttempt(
|
||||
abortSignal: cleanupAbort.signal,
|
||||
awaitSessionIdle: !bridge.hasObservedSessionIdle(),
|
||||
bridge,
|
||||
cleanupToolBridge,
|
||||
finalizeNativeSubagents: () => nativeSubagentTaskMirror?.finalizeActiveRuns(),
|
||||
handle,
|
||||
pool: deps.pool,
|
||||
sdkSessionId,
|
||||
@@ -848,6 +919,9 @@ export async function runCopilotAttempt(
|
||||
// defines as no background agents in flight. Timeouts retain the bridge
|
||||
// until that event so compaction that starts after the timer still completes.
|
||||
await bridge?.awaitCompactionChain();
|
||||
await bridge?.awaitAgentEventChain();
|
||||
nativeSubagentTaskMirror?.finalizeActiveRuns();
|
||||
cleanupToolBridge?.();
|
||||
bridge?.detach();
|
||||
params.abortSignal?.removeEventListener("abort", onAbort);
|
||||
|
||||
@@ -959,6 +1033,7 @@ export async function runCopilotAttempt(
|
||||
await dualWriteCopilotTranscriptBestEffort({
|
||||
sessionFile: sessionFileForMirror,
|
||||
sessionKey: readString((input as { sessionKey?: unknown }).sessionKey),
|
||||
sessionId: readString(input.sessionId),
|
||||
agentId: readString(input.agentId),
|
||||
messages: taggedMessages,
|
||||
idempotencyScope: sessionIdForScope ? `copilot:${sessionIdForScope}` : undefined,
|
||||
@@ -1118,6 +1193,7 @@ function createSessionConfig(
|
||||
systemMessageContent: string | undefined,
|
||||
effectiveWorkspaceDir: string | undefined,
|
||||
effectiveCwd: string | undefined,
|
||||
onUserInputRequest: NonNullable<SessionConfig["onUserInputRequest"]>,
|
||||
hooksBridgeOptions?: Parameters<typeof createHooksBridge>[1],
|
||||
): CopilotSessionConfig {
|
||||
const permissionPolicy = params.permissionPolicy ?? rejectAllPolicy;
|
||||
@@ -1145,10 +1221,9 @@ function createSessionConfig(
|
||||
// tool wrapper, and the SDK gate is a safety net for kinds we
|
||||
// don't surface. See permission-bridge.ts and docs/plugins/copilot.md.
|
||||
onPermissionRequest: createPermissionBridge(permissionPolicy),
|
||||
// `onUserInputRequest` is intentionally NOT registered: per the SDK
|
||||
// contract, omitting the handler hides the `ask_user` tool from the
|
||||
// model entirely. Interactive ask_user will need a real channel/TUI
|
||||
// prompt bridge before this runtime can expose the handler.
|
||||
// Registers the SDK ask_user bridge. The bridge itself owns pending
|
||||
// reply routing so generic mid-run steering still fails closed.
|
||||
onUserInputRequest,
|
||||
// Preserve the shipped native SDK hook contract. These callbacks expose
|
||||
// Copilot-specific events and decisions that generic lifecycle hooks do
|
||||
// not model.
|
||||
@@ -1166,8 +1241,9 @@ function createSessionConfig(
|
||||
...(infiniteSessions ? { infiniteSessions } : {}),
|
||||
reasoningEffort: params.reasoningEffort,
|
||||
tools: sdkTools,
|
||||
// Restrict the SDK's tool catalog to exactly the bridged tool names
|
||||
// returned by `createCopilotToolBridge`. Without this, the SDK
|
||||
// Restrict the SDK's tool catalog to the bridged tool names returned
|
||||
// by `createCopilotToolBridge` plus the built-in `ask_user` tool owned
|
||||
// by `onUserInputRequest`. Without this, the SDK
|
||||
// would still expose its native read/write/shell/url/mcp/memory/
|
||||
// hook tools to the model alongside our overrides, which would
|
||||
// bypass OpenClaw's wrapped-tool enforcement under any permissive
|
||||
@@ -1182,7 +1258,7 @@ function createSessionConfig(
|
||||
// `@github/copilot-sdk/dist/types.d.ts:1198` (it picks
|
||||
// `availableTools`, so the spread into `resumeSession` covers
|
||||
// the resume path too).
|
||||
availableTools: sdkTools.map((tool) => tool.name),
|
||||
availableTools: buildCopilotAvailableTools(sdkTools),
|
||||
workingDirectory:
|
||||
effectiveCwd ?? effectiveWorkspaceDir ?? readResolvedAttemptPath(params.workspaceDir),
|
||||
// When a task runs from a sub-cwd, keep SDK-native project docs
|
||||
@@ -1228,6 +1304,10 @@ function createSessionConfig(
|
||||
};
|
||||
}
|
||||
|
||||
function buildCopilotAvailableTools(sdkTools: SdkTool[]): string[] {
|
||||
return [...new Set([...sdkTools.map((tool) => tool.name), ...COPILOT_ASK_USER_AVAILABLE_TOOLS])];
|
||||
}
|
||||
|
||||
async function createMessageOptions(
|
||||
params: AttemptParamsLike,
|
||||
context: {
|
||||
|
||||
@@ -96,6 +96,7 @@ function buildMirrorDedupeIdentity(message: MirroredAgentMessage): string {
|
||||
export interface MirrorCopilotTranscriptParams {
|
||||
sessionFile: string;
|
||||
sessionKey?: string;
|
||||
sessionId?: string;
|
||||
agentId?: string;
|
||||
messages: AgentMessage[];
|
||||
/**
|
||||
@@ -168,7 +169,20 @@ export async function mirrorCopilotTranscript(
|
||||
}
|
||||
|
||||
if (params.sessionKey) {
|
||||
emitSessionTranscriptUpdate({ sessionFile: params.sessionFile, sessionKey: params.sessionKey });
|
||||
emitSessionTranscriptUpdate({
|
||||
sessionFile: params.sessionFile,
|
||||
sessionKey: params.sessionKey,
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
...(params.sessionId && params.agentId
|
||||
? {
|
||||
target: {
|
||||
agentId: params.agentId,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
} else {
|
||||
emitSessionTranscriptUpdate(params.sessionFile);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,11 @@ const REGISTERED_EVENT_TYPES = [
|
||||
"assistant.usage",
|
||||
"tool.execution_start",
|
||||
"tool.execution_complete",
|
||||
"session.plan_changed",
|
||||
"exit_plan_mode.requested",
|
||||
"subagent.started",
|
||||
"subagent.completed",
|
||||
"subagent.failed",
|
||||
"session.compaction_start",
|
||||
"session.compaction_complete",
|
||||
"session.idle",
|
||||
@@ -455,6 +460,78 @@ describe("attachEventBridge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("projects Copilot plan events through the generic plan stream", async () => {
|
||||
const session = createFakeSession();
|
||||
const onAgentEvent = vi.fn().mockResolvedValue(undefined);
|
||||
const bridge = attachEventBridge(session, {
|
||||
getSdkSessionId: () => "sdk-session-id",
|
||||
isAborted: () => false,
|
||||
onAgentEvent,
|
||||
});
|
||||
|
||||
session.emit(
|
||||
"session.plan_changed",
|
||||
makeEvent("session.plan_changed", { operation: "update" }),
|
||||
);
|
||||
session.emit(
|
||||
"exit_plan_mode.requested",
|
||||
makeEvent("exit_plan_mode.requested", {
|
||||
actions: ["approve", "edit"],
|
||||
planContent: "# Plan\n- inspect\n- patch",
|
||||
recommendedAction: "approve",
|
||||
requestId: "request-1",
|
||||
summary: "Plan ready",
|
||||
}),
|
||||
);
|
||||
|
||||
await bridge.awaitAgentEventChain();
|
||||
|
||||
expect(onAgentEvent).toHaveBeenCalledTimes(2);
|
||||
expect(onAgentEvent).toHaveBeenNthCalledWith(1, {
|
||||
stream: "plan",
|
||||
data: {
|
||||
phase: "update",
|
||||
title: "Plan updated",
|
||||
source: "copilot-sdk",
|
||||
operation: "update",
|
||||
},
|
||||
});
|
||||
expect(onAgentEvent).toHaveBeenNthCalledWith(2, {
|
||||
stream: "plan",
|
||||
data: {
|
||||
phase: "update",
|
||||
title: "Plan updated",
|
||||
source: "copilot-sdk",
|
||||
explanation: "Plan ready",
|
||||
steps: ["# Plan", "inspect", "patch"],
|
||||
actions: ["approve", "edit"],
|
||||
requestId: "request-1",
|
||||
recommendedAction: "approve",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards native Copilot subagent lifecycle events to the adapter", () => {
|
||||
const session = createFakeSession();
|
||||
const onNativeSubagentEvent = vi.fn();
|
||||
const bridge = attachEventBridge(session, {
|
||||
getSdkSessionId: () => "sdk-session-id",
|
||||
isAborted: () => false,
|
||||
onNativeSubagentEvent,
|
||||
});
|
||||
const event = makeEvent("subagent.started", {
|
||||
agentDescription: "inspect the repository",
|
||||
agentDisplayName: "Researcher",
|
||||
agentName: "researcher",
|
||||
toolCallId: "call-1",
|
||||
});
|
||||
|
||||
session.emit("subagent.started", event);
|
||||
|
||||
expect(onNativeSubagentEvent).toHaveBeenCalledWith(event);
|
||||
bridge.detach();
|
||||
});
|
||||
|
||||
it("preserves all-zero usage snapshot after an invalid assistant.usage event", () => {
|
||||
const session = createFakeSession();
|
||||
const bridge = attachEventBridge(session, {
|
||||
|
||||
@@ -41,6 +41,16 @@ export interface SessionLike {
|
||||
|
||||
export interface EventBridgeOptions {
|
||||
onAssistantDelta?: (payload: OnAssistantDeltaPayload) => void | Promise<void>;
|
||||
onAgentEvent?: (event: {
|
||||
stream: "item" | "plan";
|
||||
data: Record<string, unknown>;
|
||||
}) => void | Promise<void>;
|
||||
onNativeSubagentEvent?: (
|
||||
event: Extract<
|
||||
SessionEvent,
|
||||
{ type: "subagent.started" | "subagent.completed" | "subagent.failed" }
|
||||
>,
|
||||
) => void;
|
||||
onCompactionComplete?: (payload: {
|
||||
messagesRemoved?: number;
|
||||
success: boolean;
|
||||
@@ -72,6 +82,7 @@ export interface EventBridgeController {
|
||||
awaitSessionIdle(): Promise<void>;
|
||||
settleCompactionWait(): void;
|
||||
awaitDeltaChain(): Promise<void>;
|
||||
awaitAgentEventChain(): Promise<void>;
|
||||
hasObservedCompaction(): boolean;
|
||||
hasObservedSessionIdle(): boolean;
|
||||
isCompacting(): boolean;
|
||||
@@ -103,6 +114,7 @@ export function attachEventBridge(
|
||||
let observedCompaction = false;
|
||||
let deltaQueue = Promise.resolve();
|
||||
let deltaChain = Promise.resolve();
|
||||
let agentEventChain = Promise.resolve();
|
||||
let compactionChain = Promise.resolve();
|
||||
let compactionIdle = Promise.resolve();
|
||||
let resolveCompactionIdle: (() => void) | undefined;
|
||||
@@ -191,6 +203,51 @@ export function attachEventBridge(
|
||||
}
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "session.plan_changed", (event) => {
|
||||
enqueueAgentEvent({
|
||||
stream: "plan",
|
||||
data: {
|
||||
phase: "update",
|
||||
title: "Plan updated",
|
||||
source: "copilot-sdk",
|
||||
operation: event.data.operation,
|
||||
...(event.agentId ? { agentId: event.agentId } : {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "exit_plan_mode.requested", (event) => {
|
||||
const steps = splitPlanText(event.data.planContent);
|
||||
enqueueAgentEvent({
|
||||
stream: "plan",
|
||||
data: {
|
||||
phase: "update",
|
||||
title: "Plan updated",
|
||||
source: "copilot-sdk",
|
||||
...(event.data.summary ? { explanation: event.data.summary } : {}),
|
||||
...(steps.length > 0 ? { steps } : {}),
|
||||
...(event.data.actions.length > 0 ? { actions: event.data.actions } : {}),
|
||||
...(event.data.requestId ? { requestId: event.data.requestId } : {}),
|
||||
...(event.data.recommendedAction
|
||||
? { recommendedAction: event.data.recommendedAction }
|
||||
: {}),
|
||||
...(event.agentId ? { agentId: event.agentId } : {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "subagent.started", (event) => {
|
||||
forwardNativeSubagentEvent(event);
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "subagent.completed", (event) => {
|
||||
forwardNativeSubagentEvent(event);
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "subagent.failed", (event) => {
|
||||
forwardNativeSubagentEvent(event);
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "session.compaction_start", (event) => {
|
||||
if (!isRootCompactionEvent(event)) {
|
||||
return;
|
||||
@@ -276,6 +333,9 @@ export function attachEventBridge(
|
||||
awaitDeltaChain() {
|
||||
return deltaChain;
|
||||
},
|
||||
awaitAgentEventChain() {
|
||||
return agentEventChain;
|
||||
},
|
||||
hasObservedCompaction() {
|
||||
return observedCompaction;
|
||||
},
|
||||
@@ -334,6 +394,31 @@ export function attachEventBridge(
|
||||
compactionChain = queued.catch(() => undefined);
|
||||
}
|
||||
|
||||
function enqueueAgentEvent(event: {
|
||||
stream: "item" | "plan";
|
||||
data: Record<string, unknown>;
|
||||
}): void {
|
||||
const callback = options.onAgentEvent;
|
||||
if (!callback) {
|
||||
return;
|
||||
}
|
||||
const invoke = () => callback(event);
|
||||
agentEventChain = agentEventChain.then(invoke, invoke).catch(() => undefined);
|
||||
}
|
||||
|
||||
function forwardNativeSubagentEvent(
|
||||
event: Extract<
|
||||
SessionEvent,
|
||||
{ type: "subagent.started" | "subagent.completed" | "subagent.failed" }
|
||||
>,
|
||||
): void {
|
||||
try {
|
||||
options.onNativeSubagentEvent?.(event);
|
||||
} catch {
|
||||
// Native task mirroring must not corrupt the Copilot turn.
|
||||
}
|
||||
}
|
||||
|
||||
async function awaitStableCompaction(): Promise<void> {
|
||||
const idle = activeCompactionCount > 0 ? compactionIdle : undefined;
|
||||
if (idle) {
|
||||
@@ -456,6 +541,13 @@ function joinReasoning(order: string[], reasoningById: Map<string, string>): str
|
||||
return order.map((reasoningId) => reasoningById.get(reasoningId) ?? "").join("");
|
||||
}
|
||||
|
||||
function splitPlanText(text: string | undefined): string[] {
|
||||
return (text ?? "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim().replace(/^[-*]\s+/, ""))
|
||||
.filter((line) => line.length > 0);
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
200
extensions/copilot/src/native-subagent-task-mirror.test.ts
Normal file
200
extensions/copilot/src/native-subagent-task-mirror.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import type { SessionEvent } from "@github/copilot-sdk";
|
||||
import type {
|
||||
AgentHarnessTaskRecord,
|
||||
AgentHarnessTaskRuntime,
|
||||
} from "openclaw/plugin-sdk/agent-harness-task-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CopilotNativeSubagentTaskMirror,
|
||||
createCopilotNativeSubagentTaskMirror,
|
||||
} from "./native-subagent-task-mirror.js";
|
||||
|
||||
type NativeSubagentEventType = "subagent.started" | "subagent.completed" | "subagent.failed";
|
||||
|
||||
function makeEvent<T extends NativeSubagentEventType>(
|
||||
type: T,
|
||||
data: Extract<SessionEvent, { type: T }>["data"],
|
||||
agentId?: string,
|
||||
): Extract<SessionEvent, { type: T }> {
|
||||
return {
|
||||
data,
|
||||
id: `${type}-id`,
|
||||
parentId: null,
|
||||
timestamp: "2024-01-01T00:00:00.000Z",
|
||||
type,
|
||||
...(agentId ? { agentId } : {}),
|
||||
} as Extract<SessionEvent, { type: T }>;
|
||||
}
|
||||
|
||||
function createRuntime() {
|
||||
const task = {} as AgentHarnessTaskRecord;
|
||||
return {
|
||||
tryCreateRunningTaskRun: vi.fn(() => task),
|
||||
recordTaskRunProgressByRunId: vi.fn(() => []),
|
||||
finalizeTaskRunByRunId: vi.fn(() => []),
|
||||
} satisfies Pick<
|
||||
AgentHarnessTaskRuntime,
|
||||
"tryCreateRunningTaskRun" | "recordTaskRunProgressByRunId" | "finalizeTaskRunByRunId"
|
||||
>;
|
||||
}
|
||||
|
||||
describe("CopilotNativeSubagentTaskMirror", () => {
|
||||
it("does not create a mirror without a host-issued task scope", () => {
|
||||
expect(createCopilotNativeSubagentTaskMirror({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("mirrors start and completion using agentId with toolCallId fallback", () => {
|
||||
const runtime = createRuntime();
|
||||
const mirror = new CopilotNativeSubagentTaskMirror(
|
||||
{ agentId: "parent-agent", now: () => 100 },
|
||||
runtime,
|
||||
);
|
||||
|
||||
mirror.handleEvent(
|
||||
makeEvent(
|
||||
"subagent.started",
|
||||
{
|
||||
agentDescription: "inspect the repository",
|
||||
agentDisplayName: "Researcher",
|
||||
agentName: "researcher",
|
||||
toolCallId: "call-1",
|
||||
},
|
||||
"child-1",
|
||||
),
|
||||
);
|
||||
mirror.handleEvent(
|
||||
makeEvent(
|
||||
"subagent.completed",
|
||||
{
|
||||
agentDisplayName: "Researcher",
|
||||
agentName: "researcher",
|
||||
toolCallId: "call-1",
|
||||
totalToolCalls: 2,
|
||||
totalTokens: 30,
|
||||
},
|
||||
"child-1",
|
||||
),
|
||||
);
|
||||
|
||||
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledWith({
|
||||
sourceId: "call-1",
|
||||
agentId: "parent-agent",
|
||||
runId: "copilot-agent:child-1",
|
||||
label: "Researcher",
|
||||
task: "inspect the repository",
|
||||
notifyPolicy: "silent",
|
||||
deliveryStatus: "not_applicable",
|
||||
preferMetadata: true,
|
||||
startedAt: 100,
|
||||
lastEventAt: 100,
|
||||
progressSummary: "Copilot native subagent started.",
|
||||
});
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith({
|
||||
runId: "copilot-agent:child-1",
|
||||
status: "succeeded",
|
||||
endedAt: 100,
|
||||
lastEventAt: 100,
|
||||
progressSummary: "Copilot native subagent completed.",
|
||||
terminalSummary: "Copilot native subagent completed (2 tool calls, 30 tokens).",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses toolCallId when the SDK omits agentId", () => {
|
||||
const runtime = createRuntime();
|
||||
const mirror = new CopilotNativeSubagentTaskMirror({ now: () => 200 }, runtime);
|
||||
|
||||
mirror.handleEvent(
|
||||
makeEvent("subagent.started", {
|
||||
agentDescription: "",
|
||||
agentDisplayName: "Researcher",
|
||||
agentName: "researcher",
|
||||
toolCallId: "call-2",
|
||||
}),
|
||||
);
|
||||
mirror.handleEvent(
|
||||
makeEvent("subagent.failed", {
|
||||
agentDisplayName: "Researcher",
|
||||
agentName: "researcher",
|
||||
error: "failed",
|
||||
toolCallId: "call-2",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runId: "copilot-agent:call-2",
|
||||
status: "failed",
|
||||
error: "failed",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps parallel subagents distinct when they share a parent tool call", () => {
|
||||
const runtime = createRuntime();
|
||||
const mirror = new CopilotNativeSubagentTaskMirror({ now: () => 250 }, runtime);
|
||||
|
||||
for (const agentId of ["child-1", "child-2"]) {
|
||||
mirror.handleEvent(
|
||||
makeEvent(
|
||||
"subagent.started",
|
||||
{
|
||||
agentDescription: `inspect ${agentId}`,
|
||||
agentDisplayName: "Researcher",
|
||||
agentName: "researcher",
|
||||
toolCallId: "call-shared",
|
||||
},
|
||||
agentId,
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const agentId of ["child-1", "child-2"]) {
|
||||
mirror.handleEvent(
|
||||
makeEvent(
|
||||
"subagent.completed",
|
||||
{
|
||||
agentDisplayName: "Researcher",
|
||||
agentName: "researcher",
|
||||
toolCallId: "call-shared",
|
||||
},
|
||||
agentId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ runId: "copilot-agent:child-1" }),
|
||||
);
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ runId: "copilot-agent:child-2" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("finalizes active tasks when the parent attempt tears down", () => {
|
||||
const runtime = createRuntime();
|
||||
const mirror = new CopilotNativeSubagentTaskMirror({ now: () => 300 }, runtime);
|
||||
|
||||
mirror.handleEvent(
|
||||
makeEvent("subagent.started", {
|
||||
agentDescription: "inspect",
|
||||
agentDisplayName: "Researcher",
|
||||
agentName: "researcher",
|
||||
toolCallId: "call-3",
|
||||
}),
|
||||
);
|
||||
mirror.finalizeActiveRuns();
|
||||
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith({
|
||||
runId: "copilot-agent:call-3",
|
||||
status: "cancelled",
|
||||
endedAt: 300,
|
||||
lastEventAt: 300,
|
||||
error: "Copilot native subagent ended with its parent attempt.",
|
||||
progressSummary: "Copilot native subagent cancelled with its parent attempt.",
|
||||
terminalSummary: "Copilot native subagent cancelled.",
|
||||
});
|
||||
});
|
||||
});
|
||||
199
extensions/copilot/src/native-subagent-task-mirror.ts
Normal file
199
extensions/copilot/src/native-subagent-task-mirror.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import type { SessionEvent } from "@github/copilot-sdk";
|
||||
import {
|
||||
createAgentHarnessTaskRuntime,
|
||||
type AgentHarnessTaskRuntime,
|
||||
type AgentHarnessTaskRuntimeScope,
|
||||
} from "openclaw/plugin-sdk/agent-harness-task-runtime";
|
||||
|
||||
const COPILOT_NATIVE_SUBAGENT_TASK_KIND = "copilot-native";
|
||||
const COPILOT_NATIVE_SUBAGENT_RUN_ID_PREFIX = "copilot-agent:";
|
||||
|
||||
type CopilotNativeSubagentEvent = Extract<
|
||||
SessionEvent,
|
||||
{ type: "subagent.started" | "subagent.completed" | "subagent.failed" }
|
||||
>;
|
||||
|
||||
type TaskLifecycleRuntime = Pick<
|
||||
AgentHarnessTaskRuntime,
|
||||
"tryCreateRunningTaskRun" | "recordTaskRunProgressByRunId" | "finalizeTaskRunByRunId"
|
||||
>;
|
||||
|
||||
export function createCopilotNativeSubagentTaskMirror(params: {
|
||||
agentId?: string;
|
||||
now?: () => number;
|
||||
scope?: AgentHarnessTaskRuntimeScope;
|
||||
}): CopilotNativeSubagentTaskMirror | undefined {
|
||||
if (!params.scope) {
|
||||
return undefined;
|
||||
}
|
||||
return new CopilotNativeSubagentTaskMirror(
|
||||
{
|
||||
agentId: params.agentId,
|
||||
now: params.now,
|
||||
},
|
||||
createAgentHarnessTaskRuntime({
|
||||
runtime: "subagent",
|
||||
taskKind: COPILOT_NATIVE_SUBAGENT_TASK_KIND,
|
||||
scope: params.scope,
|
||||
runIdPrefix: COPILOT_NATIVE_SUBAGENT_RUN_ID_PREFIX,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export class CopilotNativeSubagentTaskMirror {
|
||||
private readonly runIdByAgentId = new Map<string, string>();
|
||||
private readonly runIdByToolCallId = new Map<string, string>();
|
||||
private readonly terminalRunIds = new Set<string>();
|
||||
private readonly activeRunIds = new Set<string>();
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(
|
||||
private readonly params: { agentId?: string; now?: () => number },
|
||||
private readonly runtime: TaskLifecycleRuntime,
|
||||
) {
|
||||
this.now = params.now ?? Date.now;
|
||||
}
|
||||
|
||||
handleEvent(event: CopilotNativeSubagentEvent): void {
|
||||
const toolCallId = event.data.toolCallId.trim();
|
||||
if (!toolCallId) {
|
||||
return;
|
||||
}
|
||||
const runId = this.resolveRunId(event);
|
||||
if (event.type === "subagent.started") {
|
||||
this.handleStarted(event, runId, toolCallId);
|
||||
return;
|
||||
}
|
||||
if (event.type === "subagent.completed") {
|
||||
this.handleCompleted(event, runId);
|
||||
return;
|
||||
}
|
||||
this.handleFailed(event, runId);
|
||||
}
|
||||
|
||||
finalizeActiveRuns(): void {
|
||||
const eventAt = this.now();
|
||||
for (const runId of this.activeRunIds) {
|
||||
this.terminalRunIds.add(runId);
|
||||
this.runtime.finalizeTaskRunByRunId({
|
||||
runId,
|
||||
status: "cancelled",
|
||||
endedAt: eventAt,
|
||||
lastEventAt: eventAt,
|
||||
error: "Copilot native subagent ended with its parent attempt.",
|
||||
progressSummary: "Copilot native subagent cancelled with its parent attempt.",
|
||||
terminalSummary: "Copilot native subagent cancelled.",
|
||||
});
|
||||
}
|
||||
this.activeRunIds.clear();
|
||||
}
|
||||
|
||||
private handleStarted(
|
||||
event: Extract<CopilotNativeSubagentEvent, { type: "subagent.started" }>,
|
||||
runId: string,
|
||||
toolCallId: string,
|
||||
): void {
|
||||
const agentId = event.agentId?.trim();
|
||||
const existingRunId = agentId
|
||||
? this.runIdByAgentId.get(agentId)
|
||||
: this.runIdByToolCallId.get(toolCallId);
|
||||
if (existingRunId) {
|
||||
return;
|
||||
}
|
||||
const eventAt = this.now();
|
||||
const label = event.data.agentDisplayName.trim() || event.data.agentName.trim();
|
||||
const task = event.data.agentDescription.trim() || `Copilot native subagent ${label}`;
|
||||
const taskRecord = this.runtime.tryCreateRunningTaskRun({
|
||||
sourceId: toolCallId,
|
||||
agentId: this.params.agentId,
|
||||
runId,
|
||||
label: label || "Copilot subagent",
|
||||
task,
|
||||
notifyPolicy: "silent",
|
||||
deliveryStatus: "not_applicable",
|
||||
preferMetadata: true,
|
||||
startedAt: eventAt,
|
||||
lastEventAt: eventAt,
|
||||
progressSummary: "Copilot native subagent started.",
|
||||
});
|
||||
if (!taskRecord) {
|
||||
return;
|
||||
}
|
||||
if (agentId) {
|
||||
this.runIdByAgentId.set(agentId, runId);
|
||||
} else {
|
||||
this.runIdByToolCallId.set(toolCallId, runId);
|
||||
}
|
||||
this.terminalRunIds.delete(runId);
|
||||
this.activeRunIds.add(runId);
|
||||
}
|
||||
|
||||
private handleCompleted(
|
||||
event: Extract<CopilotNativeSubagentEvent, { type: "subagent.completed" }>,
|
||||
runId: string,
|
||||
): void {
|
||||
if (this.terminalRunIds.has(runId)) {
|
||||
return;
|
||||
}
|
||||
const eventAt = this.now();
|
||||
this.terminalRunIds.add(runId);
|
||||
this.activeRunIds.delete(runId);
|
||||
this.runtime.finalizeTaskRunByRunId({
|
||||
runId,
|
||||
status: "succeeded",
|
||||
endedAt: eventAt,
|
||||
lastEventAt: eventAt,
|
||||
progressSummary: "Copilot native subagent completed.",
|
||||
terminalSummary: buildCompletionSummary(event),
|
||||
});
|
||||
}
|
||||
|
||||
private handleFailed(
|
||||
event: Extract<CopilotNativeSubagentEvent, { type: "subagent.failed" }>,
|
||||
runId: string,
|
||||
): void {
|
||||
if (this.terminalRunIds.has(runId)) {
|
||||
return;
|
||||
}
|
||||
const eventAt = this.now();
|
||||
this.terminalRunIds.add(runId);
|
||||
this.activeRunIds.delete(runId);
|
||||
this.runtime.finalizeTaskRunByRunId({
|
||||
runId,
|
||||
status: "failed",
|
||||
endedAt: eventAt,
|
||||
lastEventAt: eventAt,
|
||||
error: event.data.error,
|
||||
progressSummary: "Copilot native subagent failed.",
|
||||
terminalSummary: "Copilot native subagent failed.",
|
||||
});
|
||||
}
|
||||
|
||||
private resolveRunId(event: CopilotNativeSubagentEvent): string {
|
||||
const agentId = event.agentId?.trim();
|
||||
if (agentId) {
|
||||
const existing = this.runIdByAgentId.get(agentId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
const existing = this.runIdByToolCallId.get(event.data.toolCallId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const identity = agentId || event.data.toolCallId.trim();
|
||||
return `${COPILOT_NATIVE_SUBAGENT_RUN_ID_PREFIX}${identity}`;
|
||||
}
|
||||
}
|
||||
|
||||
function buildCompletionSummary(
|
||||
event: Extract<CopilotNativeSubagentEvent, { type: "subagent.completed" }>,
|
||||
): string {
|
||||
const details = [
|
||||
event.data.totalToolCalls !== undefined ? `${event.data.totalToolCalls} tool calls` : undefined,
|
||||
event.data.totalTokens !== undefined ? `${event.data.totalTokens} tokens` : undefined,
|
||||
].filter((value): value is string => value !== undefined);
|
||||
return details.length > 0
|
||||
? `Copilot native subagent completed (${details.join(", ")}).`
|
||||
: "Copilot native subagent completed.";
|
||||
}
|
||||
@@ -156,11 +156,184 @@ describe("createCopilotToolBridge", () => {
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(result.sourceTools).toBe(sourceTools);
|
||||
expect(result.sourceTools).toEqual(sourceTools);
|
||||
expect(result.sdkTools).toHaveLength(2);
|
||||
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["tool-a", "tool-b"]);
|
||||
});
|
||||
|
||||
it("compacts the Copilot tool surface behind tool_search controls when enabled", async () => {
|
||||
const createOpenClawCodingTools = vi.fn(async (opts: unknown) => {
|
||||
const includeToolSearchControls = Boolean(
|
||||
(opts as { includeToolSearchControls?: boolean }).includeToolSearchControls,
|
||||
);
|
||||
return includeToolSearchControls
|
||||
? [
|
||||
makeTool({ name: "tool_search_code" }),
|
||||
makeTool({ name: "fake_hidden" }),
|
||||
makeTool({ name: "read" }),
|
||||
]
|
||||
: [makeTool({ name: "fake_hidden" }), makeTool({ name: "read" })];
|
||||
});
|
||||
|
||||
const result = await createCopilotToolBridge({
|
||||
agentId: "agent-1",
|
||||
attemptParams: {
|
||||
config: { tools: { toolSearch: true } },
|
||||
runId: "run-tool-search",
|
||||
sessionKey: "agent:main:main",
|
||||
} as never,
|
||||
createOpenClawCodingTools,
|
||||
modelId: "gpt-4o",
|
||||
modelProvider: "github-copilot",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(createOpenClawCodingTools).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
includeToolSearchControls: true,
|
||||
toolSearchCatalogRef: expect.any(Object),
|
||||
toolSearchCatalogExecutor: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
expect(result.sourceTools.map((tool) => tool.name)).toEqual(["tool_search_code"]);
|
||||
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["tool_search_code"]);
|
||||
});
|
||||
|
||||
it("keeps tool_search controls visible when a narrow allowlist is active", async () => {
|
||||
const createOpenClawCodingTools = vi.fn(async (opts: unknown) => {
|
||||
const includeToolSearchControls = Boolean(
|
||||
(opts as { includeToolSearchControls?: boolean }).includeToolSearchControls,
|
||||
);
|
||||
return includeToolSearchControls
|
||||
? [makeTool({ name: "tool_search_code" }), makeTool({ name: "read" })]
|
||||
: [makeTool({ name: "read" })];
|
||||
});
|
||||
|
||||
const result = await createCopilotToolBridge({
|
||||
agentId: "agent-1",
|
||||
attemptParams: {
|
||||
config: { tools: { toolSearch: true } },
|
||||
runId: "run-tool-search",
|
||||
sessionKey: "agent:main:main",
|
||||
toolsAllow: ["read"],
|
||||
} as never,
|
||||
createOpenClawCodingTools,
|
||||
modelId: "gpt-4o",
|
||||
modelProvider: "github-copilot",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(result.sourceTools.map((tool) => tool.name)).toEqual(["tool_search_code"]);
|
||||
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["tool_search_code"]);
|
||||
});
|
||||
|
||||
it("filters the hidden tool_search catalog before compacting narrowed tools", async () => {
|
||||
let catalogRef: { current?: { entries?: Array<{ name: string }> } } | undefined;
|
||||
const createOpenClawCodingTools = vi.fn(async (opts: unknown) => {
|
||||
catalogRef = (opts as { toolSearchCatalogRef?: typeof catalogRef }).toolSearchCatalogRef;
|
||||
return [
|
||||
makeTool({ name: "tool_search_code" }),
|
||||
makeTool({ name: "read" }),
|
||||
makeTool({ name: "edit" }),
|
||||
makeTool({ name: "write" }),
|
||||
];
|
||||
});
|
||||
|
||||
await createCopilotToolBridge({
|
||||
agentId: "agent-1",
|
||||
attemptParams: {
|
||||
config: { tools: { toolSearch: true } },
|
||||
runId: "run-tool-search",
|
||||
sessionKey: "agent:main:main",
|
||||
toolsAllow: ["read"],
|
||||
} as never,
|
||||
createOpenClawCodingTools,
|
||||
modelId: "gpt-4o",
|
||||
modelProvider: "github-copilot",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(catalogRef?.current?.entries?.map((entry) => entry.name)).toEqual(["read"]);
|
||||
});
|
||||
|
||||
it("compacts the Copilot tool surface behind code-mode exec/wait when enabled", async () => {
|
||||
const createOpenClawCodingTools = vi.fn(async () => [
|
||||
makeTool({ name: "fake_hidden" }),
|
||||
makeTool({ name: "read" }),
|
||||
]);
|
||||
|
||||
const result = await createCopilotToolBridge({
|
||||
agentId: "agent-1",
|
||||
attemptParams: {
|
||||
config: { tools: { codeMode: true } },
|
||||
runId: "run-code-mode",
|
||||
sessionKey: "agent:main:main",
|
||||
} as never,
|
||||
createOpenClawCodingTools,
|
||||
modelId: "gpt-4o",
|
||||
modelProvider: "github-copilot",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(createOpenClawCodingTools).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
includeToolSearchControls: false,
|
||||
toolSearchCatalogRef: expect.any(Object),
|
||||
toolSearchCatalogExecutor: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
expect(result.sourceTools.map((tool) => tool.name)).toEqual(["exec", "wait"]);
|
||||
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["exec", "wait"]);
|
||||
});
|
||||
|
||||
it("keeps code-mode controls visible when a narrow allowlist is active", async () => {
|
||||
const createOpenClawCodingTools = vi.fn(async () => [
|
||||
makeTool({ name: "fake_hidden" }),
|
||||
makeTool({ name: "read" }),
|
||||
]);
|
||||
|
||||
const result = await createCopilotToolBridge({
|
||||
agentId: "agent-1",
|
||||
attemptParams: {
|
||||
config: { tools: { codeMode: true } },
|
||||
runId: "run-code-mode",
|
||||
sessionKey: "agent:main:main",
|
||||
toolsAllow: ["read"],
|
||||
} as never,
|
||||
createOpenClawCodingTools,
|
||||
modelId: "gpt-4o",
|
||||
modelProvider: "github-copilot",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(result.sourceTools.map((tool) => tool.name)).toEqual(["exec", "wait"]);
|
||||
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["exec", "wait"]);
|
||||
});
|
||||
|
||||
it("filters the hidden code-mode catalog before compacting narrowed tools", async () => {
|
||||
let catalogRef: { current?: { entries?: Array<{ name: string }> } } | undefined;
|
||||
const createOpenClawCodingTools = vi.fn(async (opts: unknown) => {
|
||||
catalogRef = (opts as { toolSearchCatalogRef?: typeof catalogRef }).toolSearchCatalogRef;
|
||||
return [makeTool({ name: "read" }), makeTool({ name: "edit" }), makeTool({ name: "write" })];
|
||||
});
|
||||
|
||||
await createCopilotToolBridge({
|
||||
agentId: "agent-1",
|
||||
attemptParams: {
|
||||
config: { tools: { codeMode: true } },
|
||||
runId: "run-code-mode",
|
||||
sessionKey: "agent:main:main",
|
||||
toolsAllow: ["read"],
|
||||
} as never,
|
||||
createOpenClawCodingTools,
|
||||
modelId: "gpt-4o",
|
||||
modelProvider: "github-copilot",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(catalogRef?.current?.entries?.map((entry) => entry.name)).toEqual(["read"]);
|
||||
});
|
||||
|
||||
it("throws when createOpenClawCodingTools returns a non-array", async () => {
|
||||
await expect(
|
||||
createCopilotToolBridge({
|
||||
|
||||
@@ -17,10 +17,15 @@ import {
|
||||
resolveModelAuthMode,
|
||||
sanitizeToolResult,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { createAgentHarnessToolSurfaceRuntime } from "openclaw/plugin-sdk/agent-harness-tool-runtime";
|
||||
|
||||
type CreateOpenClawCodingTools =
|
||||
(typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"];
|
||||
type OpenClawCodingToolsOptions = NonNullable<Parameters<CreateOpenClawCodingTools>[0]>;
|
||||
type AgentHarnessToolSurfaceRuntime = ReturnType<typeof createAgentHarnessToolSurfaceRuntime>;
|
||||
type CatalogExecuteParams = Parameters<
|
||||
NonNullable<AgentHarnessToolSurfaceRuntime["toolSearchCatalogExecutor"]>
|
||||
>[0];
|
||||
|
||||
type AgentToolResultLike = {
|
||||
content?: unknown;
|
||||
@@ -130,6 +135,7 @@ export interface CopilotToolBridgeInput {
|
||||
}
|
||||
|
||||
export interface CopilotToolBridge {
|
||||
cleanup?: () => void;
|
||||
sdkTools: SdkTool[];
|
||||
sourceTools: AnyAgentTool[];
|
||||
}
|
||||
@@ -178,7 +184,31 @@ export async function createCopilotToolBridge(
|
||||
input.createOpenClawCodingTools ??
|
||||
(await import("openclaw/plugin-sdk/agent-harness")).createOpenClawCodingTools;
|
||||
|
||||
const toolOptions = buildOpenClawCodingToolsOptions(input, effectiveToolPlan);
|
||||
const toolSurfaceRuntime = createAgentHarnessToolSurfaceRuntime({
|
||||
abortSignal: input.abortSignal,
|
||||
agentId: input.agentId,
|
||||
config: attemptParams.config,
|
||||
disableTools: attemptParams.disableTools,
|
||||
executeTool: (toolParams) => executeCatalogTool(input, toolParams),
|
||||
forceMessageTool: shouldForceCopilotMessageTool(attemptParams),
|
||||
isRawModelRun: isCopilotRawModelRun(attemptParams),
|
||||
modelToolsEnabled: true,
|
||||
prompt: attemptParams.prompt,
|
||||
runId: attemptParams.runId,
|
||||
runtimeToolAllowlist: effectiveToolPlan.runtimeToolAllowlist,
|
||||
sessionId: input.sessionId,
|
||||
sessionKey: attemptParams.sandboxSessionKey ?? attemptParams.sessionKey ?? input.sessionKey,
|
||||
sourceReplyDeliveryMode: attemptParams.sourceReplyDeliveryMode,
|
||||
toolsAllow: attemptParams.toolsAllow,
|
||||
});
|
||||
const toolOptions = buildOpenClawCodingToolsOptions(
|
||||
input,
|
||||
{
|
||||
...effectiveToolPlan,
|
||||
runtimeToolAllowlist: toolSurfaceRuntime.runtimeToolAllowlist,
|
||||
},
|
||||
toolSurfaceRuntime,
|
||||
);
|
||||
|
||||
let sourceTools: unknown;
|
||||
try {
|
||||
@@ -196,13 +226,19 @@ export async function createCopilotToolBridge(
|
||||
);
|
||||
}
|
||||
|
||||
const plannedTools = filterCopilotToolsForConstructionPlan(
|
||||
const allowedSourceTools = filterCopilotToolsForAllowlist(
|
||||
sourceTools as AnyAgentTool[],
|
||||
toolSurfaceRuntime.runtimeToolAllowlist,
|
||||
);
|
||||
const compactedTools = toolSurfaceRuntime.compactTools(allowedSourceTools);
|
||||
const plannedTools = filterCopilotToolsForConstructionPlan(
|
||||
compactedTools.tools,
|
||||
effectiveToolPlan.codingToolConstructionPlan,
|
||||
{ preserveToolNames: toolSurfaceRuntime.runtimeToolAllowlist },
|
||||
);
|
||||
const filteredTools = filterCopilotToolsForAllowlist(
|
||||
plannedTools,
|
||||
effectiveToolPlan.runtimeToolAllowlist,
|
||||
toolSurfaceRuntime.runtimeToolAllowlist,
|
||||
);
|
||||
|
||||
// Run duplicate detection after filtering so a duplicate in a
|
||||
@@ -214,6 +250,7 @@ export async function createCopilotToolBridge(
|
||||
}
|
||||
|
||||
return {
|
||||
cleanup: toolSurfaceRuntime.cleanup,
|
||||
sdkTools: filteredTools.map((sourceTool) =>
|
||||
convertOpenClawToolToSdkTool(sourceTool, {
|
||||
abortSignal: input.abortSignal,
|
||||
@@ -251,6 +288,7 @@ export async function createCopilotToolBridge(
|
||||
function buildOpenClawCodingToolsOptions(
|
||||
input: CopilotToolBridgeInput,
|
||||
toolPlan: ReturnType<typeof resolveEmbeddedAttemptToolConstructionPlan>,
|
||||
toolSurfaceRuntime?: ReturnType<typeof createAgentHarnessToolSurfaceRuntime>,
|
||||
): OpenClawCodingToolsOptions {
|
||||
const a = input.attemptParams ?? ({} as CopilotToolAttemptParams);
|
||||
|
||||
@@ -339,11 +377,14 @@ function buildOpenClawCodingToolsOptions(
|
||||
// `resolveSandboxContext`).
|
||||
sandbox,
|
||||
spawnWorkspaceDir,
|
||||
config: a.config,
|
||||
config: toolSurfaceRuntime?.config ?? a.config,
|
||||
abortSignal: input.abortSignal,
|
||||
modelProvider: input.modelProvider,
|
||||
modelId: input.modelId,
|
||||
includeCoreTools: toolPlan.includeCoreTools,
|
||||
includeToolSearchControls: toolSurfaceRuntime?.includeToolSearchControls,
|
||||
toolSearchCatalogRef: toolSurfaceRuntime?.toolSearchCatalogRef,
|
||||
toolSearchCatalogExecutor: toolSurfaceRuntime?.toolSearchCatalogExecutor,
|
||||
runtimeToolAllowlist: toolPlan.runtimeToolAllowlist,
|
||||
toolConstructionPlan: toolPlan.codingToolConstructionPlan,
|
||||
modelCompat,
|
||||
@@ -575,6 +616,63 @@ export function convertOpenClawToolToSdkTool(
|
||||
};
|
||||
}
|
||||
|
||||
async function executeCatalogTool(
|
||||
input: CopilotToolBridgeInput,
|
||||
params: CatalogExecuteParams,
|
||||
): Promise<Awaited<ReturnType<AnyAgentTool["execute"]>>> {
|
||||
const sourceTool = params.tool as AnyAgentTool;
|
||||
const startedAt = Date.now();
|
||||
let preparedArgs: unknown = params.input;
|
||||
try {
|
||||
preparedArgs = sourceTool.prepareArguments
|
||||
? sourceTool.prepareArguments(params.input)
|
||||
: params.input;
|
||||
const result = await sourceTool.execute(
|
||||
params.toolCallId,
|
||||
preparedArgs,
|
||||
params.signal ?? input.abortSignal,
|
||||
params.onUpdate,
|
||||
);
|
||||
const sanitizedResult = sanitizeToolResult(result);
|
||||
const isError = isToolResultError(sanitizedResult);
|
||||
input.attemptParams?.onAgentToolResult?.({
|
||||
toolName: params.toolName,
|
||||
result: sanitizedResult,
|
||||
isError,
|
||||
});
|
||||
await input.onToolCompleted?.({
|
||||
toolName: params.toolName,
|
||||
toolCallId: params.toolCallId,
|
||||
args: toToolStartArgs(preparedArgs),
|
||||
result: sanitizedResult,
|
||||
...(isError
|
||||
? { error: extractToolErrorMessage(sanitizedResult) ?? "tool returned an error" }
|
||||
: {}),
|
||||
startedAt,
|
||||
});
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
const message = toError(error).message;
|
||||
const failure = sanitizeToolResult({
|
||||
content: [{ type: "text", text: message }],
|
||||
details: { status: "failed", error: message },
|
||||
});
|
||||
input.attemptParams?.onAgentToolResult?.({
|
||||
toolName: params.toolName,
|
||||
result: failure,
|
||||
isError: true,
|
||||
});
|
||||
await input.onToolCompleted?.({
|
||||
toolName: params.toolName,
|
||||
toolCallId: params.toolCallId,
|
||||
args: toToolStartArgs(preparedArgs),
|
||||
error: message,
|
||||
startedAt,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function toToolStartArgs(args: unknown): Record<string, unknown> {
|
||||
return args && typeof args === "object" && !Array.isArray(args)
|
||||
? (args as Record<string, unknown>)
|
||||
@@ -712,11 +810,16 @@ function filterCopilotToolsForAllowlist<T extends { name: string }>(
|
||||
function filterCopilotToolsForConstructionPlan<T extends { name: string }>(
|
||||
tools: T[],
|
||||
plan: ReturnType<typeof resolveEmbeddedAttemptToolConstructionPlan>["codingToolConstructionPlan"],
|
||||
options: { preserveToolNames?: readonly string[] } = {},
|
||||
): T[] {
|
||||
if (plan.includeBaseCodingTools && plan.includeShellTools) {
|
||||
return tools;
|
||||
}
|
||||
const preserveToolNames = new Set(options.preserveToolNames);
|
||||
return tools.filter((tool) => {
|
||||
if (preserveToolNames.has(tool.name)) {
|
||||
return true;
|
||||
}
|
||||
if (!plan.includeBaseCodingTools && BASE_COPILOT_CODING_TOOL_NAMES.has(tool.name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
121
extensions/copilot/src/user-input-bridge.test.ts
Normal file
121
extensions/copilot/src/user-input-bridge.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
// Copilot tests cover SDK ask_user bridge behavior.
|
||||
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createCopilotUserInputBridge } from "./user-input-bridge.js";
|
||||
|
||||
function createParams(): EmbeddedRunAttemptParams {
|
||||
return {
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
onBlockReply: vi.fn(),
|
||||
} as unknown as EmbeddedRunAttemptParams;
|
||||
}
|
||||
|
||||
function expectFirstBlockReplyText(params: EmbeddedRunAttemptParams): string {
|
||||
const onBlockReply = params.onBlockReply;
|
||||
if (!onBlockReply) {
|
||||
throw new Error("Expected onBlockReply callback");
|
||||
}
|
||||
const payload = vi.mocked(onBlockReply).mock.calls[0]?.[0];
|
||||
if (typeof payload?.text !== "string") {
|
||||
throw new Error("Expected first block reply text");
|
||||
}
|
||||
return payload.text;
|
||||
}
|
||||
|
||||
describe("Copilot user input bridge", () => {
|
||||
it("prompts through OpenClaw and resolves the SDK request from the next queued message", async () => {
|
||||
const params = createParams();
|
||||
const bridge = createCopilotUserInputBridge({ paramsForRun: params });
|
||||
|
||||
const response = bridge.onUserInputRequest(
|
||||
{
|
||||
question: "Pick a mode",
|
||||
choices: ["Fast", "Deep"],
|
||||
allowFreeform: false,
|
||||
},
|
||||
{ sessionId: "sdk-session-1" },
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1));
|
||||
expect(expectFirstBlockReplyText(params)).toContain("Pick a mode");
|
||||
expect(bridge.handleQueuedMessage("2")).toBe(true);
|
||||
|
||||
await expect(response).resolves.toEqual({ answer: "Deep", wasFreeform: false });
|
||||
});
|
||||
|
||||
it("returns free-form answers when Copilot allows them", async () => {
|
||||
const params = createParams();
|
||||
const bridge = createCopilotUserInputBridge({ paramsForRun: params });
|
||||
|
||||
const response = bridge.onUserInputRequest(
|
||||
{
|
||||
question: "Which branch?",
|
||||
allowFreeform: true,
|
||||
},
|
||||
{ sessionId: "sdk-session-1" },
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1));
|
||||
expect(bridge.handleQueuedMessage("fix/harness-parity")).toBe(true);
|
||||
|
||||
await expect(response).resolves.toEqual({
|
||||
answer: "fix/harness-parity",
|
||||
wasFreeform: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("escapes SDK-controlled prompt text before channel delivery", async () => {
|
||||
const params = createParams();
|
||||
const bridge = createCopilotUserInputBridge({ paramsForRun: params });
|
||||
|
||||
void bridge.onUserInputRequest(
|
||||
{
|
||||
question: "Pick [trusted](https://evil) <@U123> @here\u202e",
|
||||
choices: ["One @everyone", "Two `code`"],
|
||||
allowFreeform: false,
|
||||
},
|
||||
{ sessionId: "sdk-session-1" },
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1));
|
||||
const text = expectFirstBlockReplyText(params);
|
||||
expect(text).not.toContain("@here");
|
||||
expect(text).not.toContain("@everyone");
|
||||
expect(text).not.toContain("<@U123>");
|
||||
expect(text).not.toContain("[trusted](https://evil)");
|
||||
expect(text).not.toContain("`code`");
|
||||
expect(text).toContain("\uff20here");
|
||||
expect(text).toContain("\uff3btrusted\uff3d");
|
||||
});
|
||||
|
||||
it("rejects queued messages when no ask_user request is pending", () => {
|
||||
const bridge = createCopilotUserInputBridge({ paramsForRun: createParams() });
|
||||
|
||||
expect(bridge.handleQueuedMessage("late")).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves pending requests with an empty answer when aborted", async () => {
|
||||
const params = createParams();
|
||||
const controller = new AbortController();
|
||||
const bridge = createCopilotUserInputBridge({
|
||||
paramsForRun: params,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const response = bridge.onUserInputRequest(
|
||||
{
|
||||
question: "Continue?",
|
||||
choices: ["Yes", "No"],
|
||||
allowFreeform: false,
|
||||
},
|
||||
{ sessionId: "sdk-session-1" },
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1));
|
||||
controller.abort();
|
||||
|
||||
await expect(response).resolves.toEqual({ answer: "", wasFreeform: true });
|
||||
expect(bridge.handleQueuedMessage("1")).toBe(false);
|
||||
});
|
||||
});
|
||||
161
extensions/copilot/src/user-input-bridge.ts
Normal file
161
extensions/copilot/src/user-input-bridge.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import type { SessionConfig } from "@github/copilot-sdk";
|
||||
import {
|
||||
buildAgentHarnessUserInputAnswers,
|
||||
deliverAgentHarnessUserInputPrompt,
|
||||
embeddedAgentLog,
|
||||
type AgentHarnessUserInputQuestion,
|
||||
type EmbeddedRunAttemptParams,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
|
||||
type PendingCopilotUserInput = {
|
||||
question: AgentHarnessUserInputQuestion;
|
||||
resolve: (value: CopilotUserInputResponse) => void;
|
||||
cleanup: () => void;
|
||||
};
|
||||
|
||||
type CopilotUserInputHandler = NonNullable<SessionConfig["onUserInputRequest"]>;
|
||||
type CopilotUserInputRequest = Parameters<CopilotUserInputHandler>[0];
|
||||
type CopilotUserInputResponse = Awaited<ReturnType<CopilotUserInputHandler>>;
|
||||
|
||||
type CopilotUserInputBridge = {
|
||||
onUserInputRequest: CopilotUserInputHandler;
|
||||
handleQueuedMessage: (text: string) => boolean;
|
||||
cancelPending: () => void;
|
||||
};
|
||||
|
||||
const COPILOT_USER_INPUT_QUESTION_ID = "answer";
|
||||
|
||||
export function createCopilotUserInputBridge(params: {
|
||||
paramsForRun: EmbeddedRunAttemptParams;
|
||||
signal?: AbortSignal;
|
||||
}): CopilotUserInputBridge {
|
||||
let pending: PendingCopilotUserInput | undefined;
|
||||
|
||||
const resolvePending = (value: CopilotUserInputResponse) => {
|
||||
const current = pending;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
pending = undefined;
|
||||
current.cleanup();
|
||||
current.resolve(value);
|
||||
};
|
||||
|
||||
return {
|
||||
onUserInputRequest(request) {
|
||||
const question = toQuestion(request);
|
||||
resolvePending(emptyCopilotUserInputResponse());
|
||||
return new Promise<CopilotUserInputResponse>((resolve) => {
|
||||
const abortListener = () => resolvePending(emptyCopilotUserInputResponse());
|
||||
const cleanup = () => params.signal?.removeEventListener("abort", abortListener);
|
||||
pending = { question, resolve, cleanup };
|
||||
params.signal?.addEventListener("abort", abortListener, { once: true });
|
||||
if (params.signal?.aborted) {
|
||||
resolvePending(emptyCopilotUserInputResponse());
|
||||
return;
|
||||
}
|
||||
void deliverAgentHarnessUserInputPrompt(params.paramsForRun, [question], {
|
||||
intro: "Copilot needs input:",
|
||||
formatText: formatCopilotDisplayText,
|
||||
}).catch((error: unknown) => {
|
||||
embeddedAgentLog.warn("failed to deliver copilot user input prompt", { error });
|
||||
});
|
||||
});
|
||||
},
|
||||
handleQueuedMessage(text) {
|
||||
const current = pending;
|
||||
if (!current) {
|
||||
return false;
|
||||
}
|
||||
resolvePending(buildCopilotUserInputResponse(current.question, text));
|
||||
return true;
|
||||
},
|
||||
cancelPending() {
|
||||
resolvePending(emptyCopilotUserInputResponse());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toQuestion(request: CopilotUserInputRequest): AgentHarnessUserInputQuestion {
|
||||
return {
|
||||
id: COPILOT_USER_INPUT_QUESTION_ID,
|
||||
header: "Copilot needs input",
|
||||
question: request.question,
|
||||
isOther: request.allowFreeform !== false,
|
||||
isSecret: false,
|
||||
options:
|
||||
request.choices && request.choices.length > 0
|
||||
? request.choices.map((choice: string) => ({ label: choice }))
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCopilotUserInputResponse(
|
||||
question: AgentHarnessUserInputQuestion,
|
||||
inputText: string,
|
||||
): CopilotUserInputResponse {
|
||||
const rawAnswers = buildAgentHarnessUserInputAnswers([question], inputText);
|
||||
const selected = rawAnswers.answers[COPILOT_USER_INPUT_QUESTION_ID]?.answers[0] ?? "";
|
||||
return {
|
||||
answer: selected,
|
||||
wasFreeform: !isChoiceAnswer(question, selected),
|
||||
};
|
||||
}
|
||||
|
||||
function emptyCopilotUserInputResponse(): CopilotUserInputResponse {
|
||||
return { answer: "", wasFreeform: true };
|
||||
}
|
||||
|
||||
function isChoiceAnswer(question: AgentHarnessUserInputQuestion, answer: string): boolean {
|
||||
return Boolean(
|
||||
answer &&
|
||||
question.options?.some((option) => option.label.toLowerCase() === answer.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
function formatCopilotDisplayText(value: string): string {
|
||||
const safe = sanitizeCopilotDisplayText(value).trim();
|
||||
return escapeCopilotChatText(safe || "<unknown>");
|
||||
}
|
||||
|
||||
function sanitizeCopilotDisplayText(value: string): string {
|
||||
let safe = "";
|
||||
for (const character of value) {
|
||||
const codePoint = character.codePointAt(0);
|
||||
safe += codePoint != null && isUnsafeDisplayCodePoint(codePoint) ? "?" : character;
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
function escapeCopilotChatText(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll("@", "\uff20")
|
||||
.replaceAll("`", "\uff40")
|
||||
.replaceAll("[", "\uff3b")
|
||||
.replaceAll("]", "\uff3d")
|
||||
.replaceAll("(", "\uff08")
|
||||
.replaceAll(")", "\uff09")
|
||||
.replaceAll("*", "\u2217")
|
||||
.replaceAll("_", "\uff3f")
|
||||
.replaceAll("~", "\uff5e")
|
||||
.replaceAll("|", "\uff5c");
|
||||
}
|
||||
|
||||
function isUnsafeDisplayCodePoint(codePoint: number): boolean {
|
||||
return (
|
||||
codePoint <= 0x001f ||
|
||||
(codePoint >= 0x007f && codePoint <= 0x009f) ||
|
||||
codePoint === 0x00ad ||
|
||||
codePoint === 0x061c ||
|
||||
codePoint === 0x180e ||
|
||||
(codePoint >= 0x200b && codePoint <= 0x200f) ||
|
||||
(codePoint >= 0x202a && codePoint <= 0x202e) ||
|
||||
(codePoint >= 0x2060 && codePoint <= 0x206f) ||
|
||||
codePoint === 0xfeff ||
|
||||
(codePoint >= 0xfff9 && codePoint <= 0xfffb) ||
|
||||
(codePoint >= 0xe0000 && codePoint <= 0xe007f)
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"id": "deepgram",
|
||||
"icon": "https://cdn.simpleicons.org/deepgram/111111",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -49,6 +49,14 @@ function makeAgentModelEntry(id = "profile/live-model") {
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
async function withLiveDiscoveryTestEnv(
|
||||
mockFetch: ReturnType<typeof vi.fn>,
|
||||
runAssertions: () => Promise<void>,
|
||||
@@ -122,10 +130,9 @@ describe("deepinfra augmentModelCatalog", () => {
|
||||
|
||||
it("uses config-backed API keys to enable live model catalog augmentation", async () => {
|
||||
resetDeepInfraModelCacheForTest();
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [makeAgentModelEntry("config/live-model")] }),
|
||||
});
|
||||
const mockFetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse({ data: [makeAgentModelEntry("config/live-model")] }));
|
||||
const provider = await registerSingleProviderPlugin(deepinfraPlugin);
|
||||
|
||||
await withLiveDiscoveryTestEnv(mockFetch, async () => {
|
||||
@@ -151,10 +158,9 @@ describe("deepinfra augmentModelCatalog", () => {
|
||||
|
||||
it("still runs live discovery when ctx.entries includes custom DeepInfra rows", async () => {
|
||||
resetDeepInfraModelCacheForTest();
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [makeAgentModelEntry("custom/live-model")] }),
|
||||
});
|
||||
const mockFetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse({ data: [makeAgentModelEntry("custom/live-model")] }));
|
||||
const provider = await registerSingleProviderPlugin(deepinfraPlugin);
|
||||
|
||||
const seededDeepInfraCount = DEEPINFRA_MODEL_CATALOG.length + 5;
|
||||
@@ -230,10 +236,7 @@ describe("deepinfra capability registration", () => {
|
||||
|
||||
it("uses profile-resolved API keys for live text catalog discovery", async () => {
|
||||
resetDeepInfraModelCacheForTest();
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [makeAgentModelEntry()] }),
|
||||
});
|
||||
const mockFetch = vi.fn().mockResolvedValue(jsonResponse({ data: [makeAgentModelEntry()] }));
|
||||
const captured = createCapturedPluginRegistration();
|
||||
deepinfraPlugin.register(captured.api);
|
||||
const provider = captured.providers[0];
|
||||
|
||||
@@ -48,6 +48,14 @@ function makeAgentModelEntry(overrides: Record<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
function expectedStaticChatCatalog() {
|
||||
return DEEPINFRA_MODEL_CATALOG.map((model) => {
|
||||
const compat = Object.assign({}, model.compat, {
|
||||
@@ -195,10 +203,7 @@ describe("discoverDeepInfraModels (chat-only shim)", () => {
|
||||
});
|
||||
|
||||
it("fetches the openclaw-projection endpoint and parses chat-surface entries when an API key is configured", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [makeAgentModelEntry()] }),
|
||||
});
|
||||
const mockFetch = vi.fn().mockResolvedValue(jsonResponse({ data: [makeAgentModelEntry()] }));
|
||||
|
||||
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
|
||||
const models = await discoverDeepInfraModels();
|
||||
@@ -228,21 +233,19 @@ describe("discoverDeepInfraModels (chat-only shim)", () => {
|
||||
});
|
||||
|
||||
it("skips entries with no metadata or no surface tag, and deduplicates ids", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{ id: "BAAI/bge-m3", object: "model", metadata: null },
|
||||
makeAgentModelEntry({
|
||||
id: "untagged/model",
|
||||
metadata: { context_length: 1, max_tokens: 1, pricing: {}, tags: [] },
|
||||
}),
|
||||
makeAgentModelEntry(),
|
||||
makeAgentModelEntry(),
|
||||
],
|
||||
}),
|
||||
});
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [
|
||||
{ id: "BAAI/bge-m3", object: "model", metadata: null },
|
||||
makeAgentModelEntry({
|
||||
id: "untagged/model",
|
||||
metadata: { context_length: 1, max_tokens: 1, pricing: {}, tags: [] },
|
||||
}),
|
||||
makeAgentModelEntry(),
|
||||
makeAgentModelEntry(),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
|
||||
const models = await discoverDeepInfraModels();
|
||||
@@ -283,7 +286,7 @@ describe("discoverDeepInfraModels (chat-only shim)", () => {
|
||||
});
|
||||
|
||||
it("falls back to the static catalog on non-2xx HTTP responses", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 503 });
|
||||
const mockFetch = vi.fn().mockResolvedValue(new Response("", { status: 503 }));
|
||||
|
||||
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
|
||||
const models = await discoverDeepInfraModels();
|
||||
@@ -294,14 +297,10 @@ describe("discoverDeepInfraModels (chat-only shim)", () => {
|
||||
it("falls back without caching malformed successful model list payloads", async () => {
|
||||
const mockFetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: {} }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [makeAgentModelEntry({ id: "recovered/model" })] }),
|
||||
});
|
||||
.mockResolvedValueOnce(jsonResponse({ data: {} }))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ data: [makeAgentModelEntry({ id: "recovered/model" })] }),
|
||||
);
|
||||
|
||||
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
|
||||
expect((await discoverDeepInfraModels()).map((m) => m.id)).toEqual(
|
||||
@@ -328,14 +327,8 @@ describe("discoverDeepInfraModels (chat-only shim)", () => {
|
||||
it("caches successful discovery responses only", async () => {
|
||||
const mockFetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [makeAgentModelEntry({ id: "first/model" })] }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [makeAgentModelEntry({ id: "second/model" })] }),
|
||||
});
|
||||
.mockResolvedValueOnce(jsonResponse({ data: [makeAgentModelEntry({ id: "first/model" })] }))
|
||||
.mockResolvedValueOnce(jsonResponse({ data: [makeAgentModelEntry({ id: "second/model" })] }));
|
||||
|
||||
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
|
||||
const expectedIds = expectedLiveChatCatalog([
|
||||
@@ -359,14 +352,10 @@ describe("discoverDeepInfraModels (chat-only shim)", () => {
|
||||
it("does not cache successful responses that produce no live catalog rows", async () => {
|
||||
const mockFetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [] }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [makeAgentModelEntry({ id: "recovered/model" })] }),
|
||||
});
|
||||
.mockResolvedValueOnce(jsonResponse({ data: [] }))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ data: [makeAgentModelEntry({ id: "recovered/model" })] }),
|
||||
);
|
||||
|
||||
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
|
||||
expect((await discoverDeepInfraModels()).map((m) => m.id)).toEqual(
|
||||
@@ -393,67 +382,65 @@ describe("discoverDeepInfraModels (chat-only shim)", () => {
|
||||
|
||||
describe("discoverDeepInfraSurfaces (per-surface bucketing)", () => {
|
||||
it("buckets dynamic entries by short-alias surface tag", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
makeAgentModelEntry({
|
||||
id: "anthropic/claude-sonnet-4-6",
|
||||
metadata: {
|
||||
description: "claude sonnet 4.6",
|
||||
context_length: 200000,
|
||||
max_tokens: 8192,
|
||||
pricing: { input_tokens: 3, output_tokens: 15 },
|
||||
tags: ["chat", "vlm", "vision", "prompt_cache"],
|
||||
},
|
||||
}),
|
||||
makeAgentModelEntry({
|
||||
id: "BAAI/bge-m3",
|
||||
metadata: {
|
||||
description: "bge-m3",
|
||||
pricing: { input_tokens: 0.01 },
|
||||
tags: ["embed"],
|
||||
},
|
||||
}),
|
||||
makeAgentModelEntry({
|
||||
id: "black-forest-labs/FLUX-1-schnell",
|
||||
metadata: {
|
||||
description: "FLUX schnell",
|
||||
pricing: { per_image_unit: 0.003 },
|
||||
tags: ["image-gen"],
|
||||
default_width: 1024,
|
||||
default_height: 1024,
|
||||
default_iterations: 4,
|
||||
},
|
||||
}),
|
||||
makeAgentModelEntry({
|
||||
id: "Wan-AI/Wan2.6-T2V",
|
||||
metadata: {
|
||||
description: "Wan T2V",
|
||||
pricing: { output_seconds: 0.05 },
|
||||
tags: ["video-gen"],
|
||||
},
|
||||
}),
|
||||
makeAgentModelEntry({
|
||||
id: "Qwen/Qwen3-TTS",
|
||||
metadata: {
|
||||
description: "Qwen3 TTS",
|
||||
pricing: { input_characters: 0.65 },
|
||||
tags: ["tts"],
|
||||
},
|
||||
}),
|
||||
makeAgentModelEntry({
|
||||
id: "openai/whisper-large-v3-turbo",
|
||||
metadata: {
|
||||
description: "whisper",
|
||||
pricing: { input_seconds: 0.00004 },
|
||||
tags: ["stt"],
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [
|
||||
makeAgentModelEntry({
|
||||
id: "anthropic/claude-sonnet-4-6",
|
||||
metadata: {
|
||||
description: "claude sonnet 4.6",
|
||||
context_length: 200000,
|
||||
max_tokens: 8192,
|
||||
pricing: { input_tokens: 3, output_tokens: 15 },
|
||||
tags: ["chat", "vlm", "vision", "prompt_cache"],
|
||||
},
|
||||
}),
|
||||
makeAgentModelEntry({
|
||||
id: "BAAI/bge-m3",
|
||||
metadata: {
|
||||
description: "bge-m3",
|
||||
pricing: { input_tokens: 0.01 },
|
||||
tags: ["embed"],
|
||||
},
|
||||
}),
|
||||
makeAgentModelEntry({
|
||||
id: "black-forest-labs/FLUX-1-schnell",
|
||||
metadata: {
|
||||
description: "FLUX schnell",
|
||||
pricing: { per_image_unit: 0.003 },
|
||||
tags: ["image-gen"],
|
||||
default_width: 1024,
|
||||
default_height: 1024,
|
||||
default_iterations: 4,
|
||||
},
|
||||
}),
|
||||
makeAgentModelEntry({
|
||||
id: "Wan-AI/Wan2.6-T2V",
|
||||
metadata: {
|
||||
description: "Wan T2V",
|
||||
pricing: { output_seconds: 0.05 },
|
||||
tags: ["video-gen"],
|
||||
},
|
||||
}),
|
||||
makeAgentModelEntry({
|
||||
id: "Qwen/Qwen3-TTS",
|
||||
metadata: {
|
||||
description: "Qwen3 TTS",
|
||||
pricing: { input_characters: 0.65 },
|
||||
tags: ["tts"],
|
||||
},
|
||||
}),
|
||||
makeAgentModelEntry({
|
||||
id: "openai/whisper-large-v3-turbo",
|
||||
metadata: {
|
||||
description: "whisper",
|
||||
pricing: { input_seconds: 0.00004 },
|
||||
tags: ["stt"],
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
|
||||
const catalog = await discoverDeepInfraSurfaces();
|
||||
@@ -471,35 +458,33 @@ describe("discoverDeepInfraSurfaces (per-surface bucketing)", () => {
|
||||
});
|
||||
|
||||
it("drops malformed live numeric metadata", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
makeAgentModelEntry({
|
||||
id: "bad/chat",
|
||||
metadata: {
|
||||
description: "bad chat",
|
||||
context_length: -1,
|
||||
max_tokens: 1.5,
|
||||
pricing: { input_tokens: 3, output_tokens: 15 },
|
||||
tags: ["chat"],
|
||||
},
|
||||
}),
|
||||
makeAgentModelEntry({
|
||||
id: "bad/image",
|
||||
metadata: {
|
||||
description: "bad image",
|
||||
pricing: { per_image_unit: 0.003 },
|
||||
tags: ["image-gen"],
|
||||
default_width: Number.POSITIVE_INFINITY,
|
||||
default_height: 1024.5,
|
||||
default_iterations: 0,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [
|
||||
makeAgentModelEntry({
|
||||
id: "bad/chat",
|
||||
metadata: {
|
||||
description: "bad chat",
|
||||
context_length: -1,
|
||||
max_tokens: 1.5,
|
||||
pricing: { input_tokens: 3, output_tokens: 15 },
|
||||
tags: ["chat"],
|
||||
},
|
||||
}),
|
||||
makeAgentModelEntry({
|
||||
id: "bad/image",
|
||||
metadata: {
|
||||
description: "bad image",
|
||||
pricing: { per_image_unit: 0.003 },
|
||||
tags: ["image-gen"],
|
||||
default_width: Number.POSITIVE_INFINITY,
|
||||
default_height: 1024.5,
|
||||
default_iterations: 0,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
|
||||
const catalog = await discoverDeepInfraSurfaces();
|
||||
|
||||
@@ -49,6 +49,14 @@ const surfaceEntry = (id: string, surfaceTag: string, extra: Record<string, unkn
|
||||
},
|
||||
});
|
||||
|
||||
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
async function withLiveFetch(mockFetch: ReturnType<typeof vi.fn>, run: () => Promise<void>) {
|
||||
const env = { ...process.env };
|
||||
delete process.env.NODE_ENV;
|
||||
@@ -86,19 +94,17 @@ describe("DeepInfra generation catalogs", () => {
|
||||
|
||||
describe("listDeepInfraImageGenCatalog", () => {
|
||||
it("returns null when live discovery succeeds but the response has zero image-gen entries", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
surfaceEntry("anthropic/claude-sonnet-4-6", "chat", {
|
||||
context_length: 200000,
|
||||
max_tokens: 8192,
|
||||
pricing: { input_tokens: 3, output_tokens: 15 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [
|
||||
surfaceEntry("anthropic/claude-sonnet-4-6", "chat", {
|
||||
context_length: 200000,
|
||||
max_tokens: 8192,
|
||||
pricing: { input_tokens: 3, output_tokens: 15 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await withLiveFetch(mockFetch, async () => {
|
||||
const result = await listDeepInfraImageGenCatalog(withKeyCtx());
|
||||
@@ -115,28 +121,26 @@ describe("listDeepInfraImageGenCatalog", () => {
|
||||
});
|
||||
|
||||
it("projects discovered image-gen entries when a key is configured and discovery is live", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
surfaceEntry("black-forest-labs/FLUX-2-pro", "image-gen", {
|
||||
pricing: { per_image_unit: 0.08 },
|
||||
default_width: 1024,
|
||||
default_height: 1024,
|
||||
default_iterations: 28,
|
||||
}),
|
||||
surfaceEntry("ByteDance/Seedream-4", "image-gen", {
|
||||
pricing: { per_image_unit: 0.03 },
|
||||
}),
|
||||
surfaceEntry("anthropic/claude-sonnet-4-6", "chat", {
|
||||
context_length: 200000,
|
||||
max_tokens: 8192,
|
||||
pricing: { input_tokens: 3, output_tokens: 15 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [
|
||||
surfaceEntry("black-forest-labs/FLUX-2-pro", "image-gen", {
|
||||
pricing: { per_image_unit: 0.08 },
|
||||
default_width: 1024,
|
||||
default_height: 1024,
|
||||
default_iterations: 28,
|
||||
}),
|
||||
surfaceEntry("ByteDance/Seedream-4", "image-gen", {
|
||||
pricing: { per_image_unit: 0.03 },
|
||||
}),
|
||||
surfaceEntry("anthropic/claude-sonnet-4-6", "chat", {
|
||||
context_length: 200000,
|
||||
max_tokens: 8192,
|
||||
pricing: { input_tokens: 3, output_tokens: 15 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await withLiveFetch(mockFetch, async () => {
|
||||
const result = await listDeepInfraImageGenCatalog(withKeyCtx());
|
||||
@@ -161,22 +165,20 @@ describe("listDeepInfraVideoGenCatalog", () => {
|
||||
// produces zero video-gen entries. We must return null so the registered
|
||||
// provider's static fallback list is consulted instead of an empty
|
||||
// "live" answer.
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
surfaceEntry("anthropic/claude-sonnet-4-6", "chat", {
|
||||
context_length: 200000,
|
||||
max_tokens: 8192,
|
||||
pricing: { input_tokens: 3, output_tokens: 15 },
|
||||
}),
|
||||
surfaceEntry("black-forest-labs/FLUX-2-pro", "image-gen", {
|
||||
pricing: { per_image_unit: 0.08 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [
|
||||
surfaceEntry("anthropic/claude-sonnet-4-6", "chat", {
|
||||
context_length: 200000,
|
||||
max_tokens: 8192,
|
||||
pricing: { input_tokens: 3, output_tokens: 15 },
|
||||
}),
|
||||
surfaceEntry("black-forest-labs/FLUX-2-pro", "image-gen", {
|
||||
pricing: { per_image_unit: 0.08 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await withLiveFetch(mockFetch, async () => {
|
||||
const result = await listDeepInfraVideoGenCatalog(withKeyCtx());
|
||||
@@ -185,20 +187,18 @@ describe("listDeepInfraVideoGenCatalog", () => {
|
||||
});
|
||||
|
||||
it("projects discovered video-gen entries with capability shape", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
|
||||
pricing: { output_seconds: 0.05 },
|
||||
}),
|
||||
surfaceEntry("ByteDance/Seedance-2.0", "video-gen", {
|
||||
pricing: { output_seconds: 0.08 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [
|
||||
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
|
||||
pricing: { output_seconds: 0.05 },
|
||||
}),
|
||||
surfaceEntry("ByteDance/Seedance-2.0", "video-gen", {
|
||||
pricing: { output_seconds: 0.08 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await withLiveFetch(mockFetch, async () => {
|
||||
const result = await listDeepInfraVideoGenCatalog(withKeyCtx());
|
||||
@@ -214,17 +214,15 @@ describe("listDeepInfraVideoGenCatalog", () => {
|
||||
|
||||
describe("resolveDeepInfraVideoModelCapabilities", () => {
|
||||
it("returns capabilities for a discovered video-gen model", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
|
||||
pricing: { output_seconds: 0.05 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [
|
||||
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
|
||||
pricing: { output_seconds: 0.05 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await withLiveFetch(mockFetch, async () => {
|
||||
const caps = await resolveDeepInfraVideoModelCapabilities({
|
||||
@@ -236,17 +234,15 @@ describe("resolveDeepInfraVideoModelCapabilities", () => {
|
||||
});
|
||||
|
||||
it("strips the deepinfra/ prefix when matching", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
|
||||
pricing: { output_seconds: 0.05 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [
|
||||
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
|
||||
pricing: { output_seconds: 0.05 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await withLiveFetch(mockFetch, async () => {
|
||||
const caps = await resolveDeepInfraVideoModelCapabilities({
|
||||
@@ -257,17 +253,15 @@ describe("resolveDeepInfraVideoModelCapabilities", () => {
|
||||
});
|
||||
|
||||
it("returns undefined for an unknown model", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
|
||||
pricing: { output_seconds: 0.05 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [
|
||||
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
|
||||
pricing: { output_seconds: 0.05 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await withLiveFetch(mockFetch, async () => {
|
||||
const caps = await resolveDeepInfraVideoModelCapabilities({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"id": "deepseek",
|
||||
"icon": "https://cdn.simpleicons.org/deepseek/111111",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user