Compare commits
1 Commits
master
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c474d837ff |
2
.github/workflows/copilot-setup-steps.yml
vendored
2
.github/workflows/copilot-setup-steps.yml
vendored
@ -21,6 +21,6 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Install gh-aw extension
|
||||
uses: github/gh-aw-actions/setup-cli@07c7335cd76c4d4d9f00dd7874f85ff55ed71f24 # v0.71.3
|
||||
uses: github/gh-aw-actions/setup-cli@239aec45b78c8799417efdd5bc6d8cc036629ec1 # v0.71.1
|
||||
with:
|
||||
version: v0.68.1
|
||||
|
||||
72
.github/workflows/repo-assist.lock.yml
generated
vendored
72
.github/workflows/repo-assist.lock.yml
generated
vendored
@ -47,9 +47,9 @@
|
||||
# Custom actions used:
|
||||
# - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
# - actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
|
||||
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
# - github/gh-aw-actions/setup@ba90f2186d7ad780ec640f364005fa24e797b360 # v0.68.3
|
||||
# - github/gh-aw-actions/setup@239aec45b78c8799417efdd5bc6d8cc036629ec1 # v0.71.1
|
||||
#
|
||||
# Container images used:
|
||||
# - ghcr.io/github/gh-aw-firewall/agent:0.25.20@sha256:9161f2415a3306a344aca34dd671ee69f122317e0a512e66dc64c94b9c508682
|
||||
@ -131,7 +131,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@ba90f2186d7ad780ec640f364005fa24e797b360 # v0.68.3
|
||||
uses: github/gh-aw-actions/setup@239aec45b78c8799417efdd5bc6d8cc036629ec1 # v0.71.1
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@ -155,7 +155,7 @@ jobs:
|
||||
GH_AW_INFO_AWMG_VERSION: ""
|
||||
GH_AW_INFO_FIREWALL_TYPE: "squid"
|
||||
GH_AW_COMPILED_STRICT: "true"
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
|
||||
with:
|
||||
script: |
|
||||
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
|
||||
@ -165,7 +165,7 @@ jobs:
|
||||
- name: Add eyes reaction for immediate feedback
|
||||
id: react
|
||||
if: github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
|
||||
env:
|
||||
GH_AW_REACTION: "eyes"
|
||||
with:
|
||||
@ -191,7 +191,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
- name: Check workflow lock file
|
||||
id: check-lock-file
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
|
||||
env:
|
||||
GH_AW_WORKFLOW_FILE: "repo-assist.lock.yml"
|
||||
GH_AW_CONTEXT_WORKFLOW_REF: "${{ github.workflow_ref }}"
|
||||
@ -202,7 +202,7 @@ jobs:
|
||||
const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs');
|
||||
await main();
|
||||
- name: Check compile-agentic version
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
|
||||
env:
|
||||
GH_AW_COMPILED_VERSION: "v0.68.3"
|
||||
with:
|
||||
@ -213,7 +213,7 @@ jobs:
|
||||
await main();
|
||||
- name: Compute current body text
|
||||
id: sanitized
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
|
||||
with:
|
||||
script: |
|
||||
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
|
||||
@ -223,7 +223,7 @@ jobs:
|
||||
- name: Add comment with workflow run link
|
||||
id: add-comment
|
||||
if: github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
|
||||
env:
|
||||
GH_AW_WORKFLOW_NAME: "Repo Assist"
|
||||
GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e Generated by 🌈 {workflow_name}, see [workflow run]({run_url}). [Learn more](https://github.com/githubnext/agentics/blob/main/docs/repo-assist.md).\",\"runStarted\":\"{workflow_name} is processing {event_type}, see [workflow run]({run_url})...\",\"runSuccess\":\"✓ {workflow_name} completed successfully, see [workflow run]({run_url}).\",\"runFailure\":\"✗ {workflow_name} encountered {status}, see [workflow run]({run_url}).\"}"
|
||||
@ -314,7 +314,7 @@ jobs:
|
||||
GH_AW_PROMPT_0b7a82d8a513bd25_EOF
|
||||
} > "$GH_AW_PROMPT"
|
||||
- name: Interpolate variables and render templates
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
|
||||
env:
|
||||
GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
|
||||
GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
@ -328,7 +328,7 @@ jobs:
|
||||
const { main } = require('${{ runner.temp }}/gh-aw/actions/interpolate_prompt.cjs');
|
||||
await main();
|
||||
- name: Substitute placeholders
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
|
||||
env:
|
||||
GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
|
||||
GH_AW_GITHUB_ACTOR: ${{ github.actor }}
|
||||
@ -430,7 +430,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@ba90f2186d7ad780ec640f364005fa24e797b360 # v0.68.3
|
||||
uses: github/gh-aw-actions/setup@239aec45b78c8799417efdd5bc6d8cc036629ec1 # v0.71.1
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@ -503,7 +503,7 @@ jobs:
|
||||
id: checkout-pr
|
||||
if: |
|
||||
github.event.pull_request || github.event.issue.pull_request
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
@ -835,7 +835,7 @@ jobs:
|
||||
"customValidation": "requiresOneOf:status,title,body"
|
||||
}
|
||||
}
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
|
||||
with:
|
||||
script: |
|
||||
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
|
||||
@ -1027,7 +1027,7 @@ jobs:
|
||||
bash "${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh" "$GATEWAY_PID"
|
||||
- name: Redact secrets in logs
|
||||
if: always()
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
|
||||
with:
|
||||
script: |
|
||||
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
|
||||
@ -1053,7 +1053,7 @@ jobs:
|
||||
- name: Ingest agent output
|
||||
id: collect_output
|
||||
if: always()
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
|
||||
env:
|
||||
GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
|
||||
GH_AW_ALLOWED_DOMAINS: "*.gradle-enterprise.cloud,*.pythonhosted.org,*.vsblob.vsassets.io,adoptium.net,anaconda.org,api.adoptium.net,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.foojay.io,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.npms.io,api.nuget.org,api.snapcraft.io,archive.apache.org,archive.ubuntu.com,azure.archive.ubuntu.com,azuresearch-usnc.nuget.org,azuresearch-ussc.nuget.org,binstar.org,bootstrap.pypa.io,builds.dotnet.microsoft.com,bun.sh,cdn.azul.com,cdn.jsdelivr.net,central.sonatype.com,ci.dot.net,conda.anaconda.org,conda.binstar.org,crates.io,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,dc.services.visualstudio.com,deb.nodesource.com,deno.land,develocity.apache.org,dist.nuget.org,dl.google.com,dlcdn.apache.org,dot.net,dotnet.microsoft.com,dotnetcli.blob.core.windows.net,download.eclipse.org,download.java.net,download.oracle.com,downloads.gradle-dn.com,esm.sh,files.pythonhosted.org,ge.spockframework.org,get.pnpm.io,github.com,googleapis.deno.dev,googlechromelabs.github.io,gradle.org,host.docker.internal,index.crates.io,jcenter.bintray.com,jdk.java.net,json-schema.org,json.schemastore.org,jsr.io,keyserver.ubuntu.com,maven-central.storage-download.googleapis.com,maven.apache.org,maven.google.com,maven.oracle.com,maven.pkg.github.com,nodejs.org,npm.pkg.github.com,npmjs.com,npmjs.org,nuget.org,nuget.pkg.github.com,nugetregistryv2prod.blob.core.windows.net,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,oneocsp.microsoft.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,pip.pypa.io,pkgs.dev.azure.com,plugins-artifacts.gradle.org,plugins.gradle.org,ppa.launchpad.net,pypi.org,pypi.python.org,raw.githubusercontent.com,registry.bower.io,registry.npmjs.com,registry.npmjs.org,registry.yarnpkg.com,repo.anaconda.com,repo.continuum.io,repo.gradle.org,repo.grails.org,repo.maven.apache.org,repo.spring.io,repo.yarnpkg.com,repo1.maven.org,repository.apache.org,s.symcb.com,s.symcd.com,scans-in.gradle.com,security.ubuntu.com,services.gradle.org,sh.rustup.rs,skimdb.npmjs.com,static.crates.io,static.rust-lang.org,storage.googleapis.com,telemetry.enterprise.githubcopilot.com,telemetry.vercel.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com,www.java.com,www.microsoft.com,www.npmjs.com,www.npmjs.org,yarnpkg.com"
|
||||
@ -1068,7 +1068,7 @@ jobs:
|
||||
await main();
|
||||
- name: Parse agent logs for step summary
|
||||
if: always()
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
|
||||
env:
|
||||
GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/
|
||||
with:
|
||||
@ -1080,7 +1080,7 @@ jobs:
|
||||
- name: Parse MCP Gateway logs for step summary
|
||||
if: always()
|
||||
id: parse-mcp-gateway
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
|
||||
with:
|
||||
script: |
|
||||
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
|
||||
@ -1105,7 +1105,7 @@ jobs:
|
||||
- name: Parse token usage for step summary
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
|
||||
with:
|
||||
script: |
|
||||
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
|
||||
@ -1179,7 +1179,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@ba90f2186d7ad780ec640f364005fa24e797b360 # v0.68.3
|
||||
uses: github/gh-aw-actions/setup@239aec45b78c8799417efdd5bc6d8cc036629ec1 # v0.71.1
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@ -1200,7 +1200,7 @@ jobs:
|
||||
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
|
||||
- name: Process no-op messages
|
||||
id: noop
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
|
||||
env:
|
||||
GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
|
||||
GH_AW_NOOP_MAX: "1"
|
||||
@ -1219,7 +1219,7 @@ jobs:
|
||||
await main();
|
||||
- name: Log detection run
|
||||
id: detection_runs
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
|
||||
env:
|
||||
GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
|
||||
GH_AW_WORKFLOW_NAME: "Repo Assist"
|
||||
@ -1237,7 +1237,7 @@ jobs:
|
||||
await main();
|
||||
- name: Record missing tool
|
||||
id: missing_tool
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
|
||||
env:
|
||||
GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
|
||||
GH_AW_MISSING_TOOL_CREATE_ISSUE: "true"
|
||||
@ -1253,7 +1253,7 @@ jobs:
|
||||
await main();
|
||||
- name: Record incomplete
|
||||
id: report_incomplete
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
|
||||
env:
|
||||
GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
|
||||
GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE: "true"
|
||||
@ -1270,7 +1270,7 @@ jobs:
|
||||
- name: Handle agent failure
|
||||
id: handle_agent_failure
|
||||
if: always()
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
|
||||
env:
|
||||
GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
|
||||
GH_AW_WORKFLOW_NAME: "Repo Assist"
|
||||
@ -1307,7 +1307,7 @@ jobs:
|
||||
await main();
|
||||
- name: Update reaction comment with completion status
|
||||
id: conclusion
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
|
||||
env:
|
||||
GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
|
||||
GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }}
|
||||
@ -1342,7 +1342,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@ba90f2186d7ad780ec640f364005fa24e797b360 # v0.68.3
|
||||
uses: github/gh-aw-actions/setup@239aec45b78c8799417efdd5bc6d8cc036629ec1 # v0.71.1
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@ -1409,7 +1409,7 @@ jobs:
|
||||
ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true
|
||||
- name: Setup threat detection
|
||||
if: always() && steps.detection_guard.outputs.run_detection == 'true'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
|
||||
env:
|
||||
WORKFLOW_NAME: "Repo Assist"
|
||||
WORKFLOW_DESCRIPTION: "A friendly repository assistant that runs 2 times a day to support contributors and maintainers.\nCan also be triggered on-demand via '/repo-assist <instructions>' to perform specific tasks.\n- Labels and triages open issues\n- Comments helpfully on open issues to unblock contributors and onboard newcomers\n- Identifies issues that can be fixed and creates draft pull requests with fixes\n- Improves performance, testing, and code quality via PRs\n- Makes engineering investments: dependency updates, CI improvements, tooling\n- Updates its own PRs when CI fails or merge conflicts arise\n- Nudges stale PRs waiting for author response\n- Takes the repository forward with proactive improvements\n- Maintains a persistent memory of work done and what remains\nAlways polite, constructive, and mindful of the project's goals."
|
||||
@ -1472,7 +1472,7 @@ jobs:
|
||||
- name: Parse and conclude threat detection
|
||||
id: detection_conclusion
|
||||
if: always()
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
|
||||
env:
|
||||
RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }}
|
||||
GH_AW_DETECTION_CONTINUE_ON_ERROR: "true"
|
||||
@ -1493,13 +1493,13 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@ba90f2186d7ad780ec640f364005fa24e797b360 # v0.68.3
|
||||
uses: github/gh-aw-actions/setup@239aec45b78c8799417efdd5bc6d8cc036629ec1 # v0.71.1
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
- name: Check team membership for command workflow
|
||||
id: check_membership
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
|
||||
env:
|
||||
GH_AW_REQUIRED_ROLES: "admin,maintainer,write"
|
||||
with:
|
||||
@ -1511,7 +1511,7 @@ jobs:
|
||||
await main();
|
||||
- name: Check command position
|
||||
id: check_command_position
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
|
||||
env:
|
||||
GH_AW_COMMANDS: "[\"repo-assist\"]"
|
||||
with:
|
||||
@ -1542,7 +1542,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@ba90f2186d7ad780ec640f364005fa24e797b360 # v0.68.3
|
||||
uses: github/gh-aw-actions/setup@239aec45b78c8799417efdd5bc6d8cc036629ec1 # v0.71.1
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@ -1574,7 +1574,7 @@ jobs:
|
||||
- name: Push repo-memory changes (default)
|
||||
id: push_repo_memory_default
|
||||
if: always()
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
@ -1637,7 +1637,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@ba90f2186d7ad780ec640f364005fa24e797b360 # v0.68.3
|
||||
uses: github/gh-aw-actions/setup@239aec45b78c8799417efdd5bc6d8cc036629ec1 # v0.71.1
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@ -1695,7 +1695,7 @@ jobs:
|
||||
echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV"
|
||||
- name: Process Safe Outputs
|
||||
id: process_safe_outputs
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
|
||||
env:
|
||||
GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
|
||||
GH_AW_ALLOWED_DOMAINS: "*.gradle-enterprise.cloud,*.pythonhosted.org,*.vsblob.vsassets.io,adoptium.net,anaconda.org,api.adoptium.net,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.foojay.io,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.npms.io,api.nuget.org,api.snapcraft.io,archive.apache.org,archive.ubuntu.com,azure.archive.ubuntu.com,azuresearch-usnc.nuget.org,azuresearch-ussc.nuget.org,binstar.org,bootstrap.pypa.io,builds.dotnet.microsoft.com,bun.sh,cdn.azul.com,cdn.jsdelivr.net,central.sonatype.com,ci.dot.net,conda.anaconda.org,conda.binstar.org,crates.io,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,dc.services.visualstudio.com,deb.nodesource.com,deno.land,develocity.apache.org,dist.nuget.org,dl.google.com,dlcdn.apache.org,dot.net,dotnet.microsoft.com,dotnetcli.blob.core.windows.net,download.eclipse.org,download.java.net,download.oracle.com,downloads.gradle-dn.com,esm.sh,files.pythonhosted.org,ge.spockframework.org,get.pnpm.io,github.com,googleapis.deno.dev,googlechromelabs.github.io,gradle.org,host.docker.internal,index.crates.io,jcenter.bintray.com,jdk.java.net,json-schema.org,json.schemastore.org,jsr.io,keyserver.ubuntu.com,maven-central.storage-download.googleapis.com,maven.apache.org,maven.google.com,maven.oracle.com,maven.pkg.github.com,nodejs.org,npm.pkg.github.com,npmjs.com,npmjs.org,nuget.org,nuget.pkg.github.com,nugetregistryv2prod.blob.core.windows.net,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,oneocsp.microsoft.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,pip.pypa.io,pkgs.dev.azure.com,plugins-artifacts.gradle.org,plugins.gradle.org,ppa.launchpad.net,pypi.org,pypi.python.org,raw.githubusercontent.com,registry.bower.io,registry.npmjs.com,registry.npmjs.org,registry.yarnpkg.com,repo.anaconda.com,repo.continuum.io,repo.gradle.org,repo.grails.org,repo.maven.apache.org,repo.spring.io,repo.yarnpkg.com,repo1.maven.org,repository.apache.org,s.symcb.com,s.symcd.com,scans-in.gradle.com,security.ubuntu.com,services.gradle.org,sh.rustup.rs,skimdb.npmjs.com,static.crates.io,static.rust-lang.org,storage.googleapis.com,telemetry.enterprise.githubcopilot.com,telemetry.vercel.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com,www.java.com,www.microsoft.com,www.npmjs.com,www.npmjs.org,yarnpkg.com"
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -349,5 +349,3 @@ test_ws.py
|
||||
|
||||
# Local visual test output
|
||||
visual-test-output/
|
||||
|
||||
.squad/
|
||||
|
||||
@ -1,31 +0,0 @@
|
||||
# Aaron: actual fixes for PR #274 bugs 2 and 3
|
||||
|
||||
## Bug 2 — tray quick-chat broken
|
||||
|
||||
Traced tray left-click to `InitializeTrayIcon()` -> `_trayIcon.Selected += OnTrayIconSelected` -> `OnTrayIconSelected()` -> `ShowChatWindow()`. The quick-chat path did use `ShowChatWindow`, but it resolved only `settings.Token` while the working operator client resolves `settings.Token`, `settings.BootstrapToken`, then stored `DeviceIdentity.DeviceToken` via `GatewayCredentialResolver`.
|
||||
|
||||
Changes:
|
||||
- `App.ShowChatWindow()` and chat pre-warm now use the same `GatewayCredentialResolver` pattern as the operator client.
|
||||
- `ShowChatWindow()` calls `ChatWindow.RefreshCredentials()` on every tray click, including newly-created windows.
|
||||
- `ChatWindow.RefreshCredentials()` always rebuilds the URL and navigates initialized WebView2 to it; it no longer returns early when the same stale URL is cached.
|
||||
- Added diagnostic logs: `[ChatWindow] Quick-chat credentials resolved from ...` and `[ChatWindow] Refreshing to ...`.
|
||||
- Applied Mattingly Bug 4 handoff: bootstrap injection now runs from `ChatWindow` after successful WebView navigation.
|
||||
|
||||
Manual validation for Mike: click tray icon; tail `%LOCALAPPDATA%\OpenClawTray\openclaw-tray.log` and look for `[ChatWindow] Refreshing to ...`, then verify chat loads without login loop.
|
||||
|
||||
## Bug 3 — pairing toast notification storm
|
||||
|
||||
Searched toast paths and traced pairing notifications through `WindowsNodeClient` direct `PairingStatusChanged` emitters (`pairing.requested`, `pairing.resolved`, `NOT_PAIRED`, and `hello-ok`) plus tray toasts in `App.OnPairingStatusChanged()` and `App.OnNodeStatusChanged()`.
|
||||
|
||||
Changes:
|
||||
- Routed all `WindowsNodeClient` pairing emitters through `EmitPairingStatusOnTransition()`; duplicates now log `[NODE] Suppressing duplicate pairing status event: ...`.
|
||||
- Added a toast-boundary 30-second dedupe in `App.ShowToast(builder, toastTag, deviceId)`, keyed by `(toastTag, deviceId)`.
|
||||
- Tagged node pairing pending/paired/rejected and node-connected toasts.
|
||||
- Suppressed the node-connected toast if a node-paired toast was just shown for the same device.
|
||||
- Added diagnostic logs: `[ToastDeduper] Showing toast tag=... deviceId=...` and `[ToastDeduper] Suppressed duplicate toast tag=... deviceId=...`.
|
||||
|
||||
Manual validation for Mike: complete pairing; expect exactly one node-paired toast and log line `[ToastDeduper] Showing toast tag=node-paired deviceId=...`; duplicates should log suppression.
|
||||
|
||||
## Validation
|
||||
|
||||
Ran `./build.ps1`: passed. Per fast-loop directive, skipped `dotnet test`.
|
||||
@ -1,45 +0,0 @@
|
||||
# Mattingly: actual fixes for PR #274 bugs 1, 4, 5
|
||||
|
||||
## Bug 1 — chat window auto-launch on Finish
|
||||
|
||||
Changed `OnboardingWindow.OnWizardComplete()` to ignore `WizardLifecycleState == "complete"`. The signal now is: the window is completing from `OnboardingRoute.Ready` and `StartupSetupState.RequiresSetup(settings, identityDataPath)` is false. That is the path the Finish button actually takes: `Ready` page Finish -> `OnboardingState.Complete()` -> `OnOnboardingFinished()` -> `OnWizardComplete()`.
|
||||
|
||||
Log to validate: `[OnboardingWindow] OnWizardComplete launching chat`.
|
||||
|
||||
## Bug 4 — BOOTSTRAP.md kickoff injection
|
||||
|
||||
Hardened `BootstrapMessageInjector`:
|
||||
|
||||
- Traverses shadow DOM for Lit UI controls.
|
||||
- Probes and logs visible control count: `[OpenClaw] Bootstrap probe controls=N`.
|
||||
- Supports `textarea`, text inputs, contenteditable, and role=textbox.
|
||||
- Uses native value setters so controlled inputs see the value.
|
||||
- Clicks Send/form-submit/Enter fallbacks.
|
||||
- Does **not** burn `HasInjectedFirstRunBootstrap` when the script returns `no-input`; the gate is only persisted on `sent`.
|
||||
|
||||
Aaron still needs to move the call site to after successful chat navigation because current `App.ShowChatWindow()` can see `TryGetScriptExecutor()==null` when the WebView2 is still initializing.
|
||||
|
||||
Exact handoff line for Aaron in `ChatWindow.xaml.cs` NavigationCompleted success branch after `RequestChatInputFocus();`:
|
||||
|
||||
```csharp
|
||||
OpenClawTray.Services.BootstrapMessageInjector.ScriptExecutor exec = script => WebView.CoreWebView2.ExecuteScriptAsync(script).AsTask();
|
||||
_ = OpenClawTray.Services.BootstrapMessageInjector.InjectAsync(exec, ((App)Microsoft.UI.Xaml.Application.Current).Settings, initialDelayMs: 500);
|
||||
```
|
||||
|
||||
If `App.Settings` is not exposed, add an internal property returning `_settings`, or route the existing `_settings` from `App.ShowChatWindow()` into a ChatWindow method. The important point is that the call must happen inside `NavigationCompleted` when `e.IsSuccess` is true.
|
||||
|
||||
## Bug 5 — autostart default/toggle
|
||||
|
||||
Changed `ReadyPage` to render the toggle ON as a safety default, then sync to `Settings.AutoStart` on mount and immediately call `AutoStartManager.SetAutoStart()` so a user who never toggles still gets the Run-key. The toggle handler still persists settings and updates the Run-key immediately.
|
||||
|
||||
Changed `AutoStartManager.SetAutoStart()` to use `Registry.CurrentUser.CreateSubKey(...)` instead of `OpenSubKey(...)`, so it can create the Run key/value when missing instead of silently returning.
|
||||
|
||||
Manual registry validation:
|
||||
|
||||
```powershell
|
||||
Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -Name OpenClawTray -ErrorAction SilentlyContinue
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
Ran `./build.ps1`: passed. Per fast-loop directive, skipped `dotnet test`.
|
||||
@ -1,58 +0,0 @@
|
||||
# Mattingly — PR #274 finish should open Hub chat
|
||||
|
||||
## Audit
|
||||
|
||||
Command requested: `grep -rn "launching chat\|ShowChatWindow\|ShowHub\|OnWizardComplete" src/OpenClaw.Tray.WinUI` (run with ripgrep equivalent because `rg` was not on PATH in PowerShell; Copilot rg tool was used against the same tree).
|
||||
|
||||
HEAD before this fix: `8c68111 Launch hub chat after onboarding`.
|
||||
|
||||
Matches found:
|
||||
|
||||
- `src/OpenClaw.Tray.WinUI/App.xaml.cs:498` — tray icon click calls `ShowChatWindow()`.
|
||||
- `src/OpenClaw.Tray.WinUI/App.xaml.cs:501` — `ShowChatWindow()` method.
|
||||
- `src/OpenClaw.Tray.WinUI/App.xaml.cs:542` — `ShowChatWindow` deferred-show warning string.
|
||||
- `src/OpenClaw.Tray.WinUI/App.xaml.cs:644` — tray menu `openchat` calls `ShowChatWindow()`.
|
||||
- `src/OpenClaw.Tray.WinUI/App.xaml.cs:562,581,647,652,654,710,1043,1855,2809,2928,3048,3101,3603,4265` — `ShowHub(...)` method/call sites.
|
||||
- `src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs:587` — Finish event calls `OnWizardComplete()`.
|
||||
- `src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs:596` — X/Closed path calls `OnWizardComplete()`.
|
||||
- `src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs:620` — single `OnWizardComplete()` implementation.
|
||||
- `src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs:649` — required diagnostic log line.
|
||||
- `src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs:650,658,660,667,671,675,679` — deferred Hub chat launch helper.
|
||||
- Documentation/comment-only references in `ChatWindow.xaml.cs`, `HubWindow.xaml.cs`, `VoiceOverlayWindow.xaml.cs`, and `OnboardingState.cs`.
|
||||
|
||||
The literal old string `launching chat` has no remaining source match in this worktree.
|
||||
|
||||
## Diagnosis
|
||||
|
||||
The log Mike captured (`[OnboardingWindow] OnWizardComplete launching chat`) corresponds to the pre-`8c68111` body of `OnboardingWindow.OnWizardComplete` in `src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs`, the only wizard-completion implementation. In the current clean worktree, `8c68111` did change that exact method to log `[OnboardingWindow] OnWizardComplete launching HubWindow on chat tab` and call `App.ShowHub("chat")`.
|
||||
|
||||
I did not find a second `OnWizardComplete`, overload, post-finish hook, or hidden `ShowHub` fallback to `ChatWindow`. `App.ShowHub(...)` creates a `HubWindow` when `_hubWindow` is null/closed, sets state, navigates, and activates it. The remaining `ShowChatWindow()` calls are tray quick-chat entry points, not wizard finish paths.
|
||||
|
||||
The prior fix therefore did not take in the live run because that run was not executing source/binaries containing `8c68111` (or was launched from another stale build/worktree). To make the wizard finish path more robust and easier to verify, this follow-up keeps the exact required log line and dispatches `ShowHub("chat")` at low priority after the wizard close event settles, so the Hub opens after the wizard finishes closing and cannot lose an ordering fight to wizard teardown.
|
||||
|
||||
## Changes
|
||||
|
||||
- `src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs`
|
||||
- Keeps the required log line: `[OnboardingWindow] OnWizardComplete launching HubWindow on chat tab`.
|
||||
- Replaces the inline post-finish call with `ShowHubChatAfterWizardClose()`.
|
||||
- The helper dispatches `App.ShowHub("chat")` on the UI dispatcher at low priority, with a direct fallback if enqueue fails.
|
||||
- Adds an explicit warning if `Application.Current` is not the tray `App`.
|
||||
- Updates stale bootstrap comment from `App.ShowChatWindow()` to HubWindow chat navigation.
|
||||
|
||||
- `src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingState.cs`
|
||||
- Updates stale route comment to say the Ready path launches the Hub chat tab, not the old chat window.
|
||||
|
||||
- `src/OpenClaw.Tray.WinUI/Services/BootstrapMessageInjector.cs`
|
||||
- Updates stale comment to describe HubWindow chat page injection instead of post-wizard `App.ShowChatWindow()`.
|
||||
|
||||
## Validation
|
||||
|
||||
- `git pull --rebase fork feat/wsl-gateway-clean` before commit: already up to date.
|
||||
- `./build.ps1`: passed.
|
||||
- Tests intentionally not run per active directive: NO tests, incremental `./build.ps1` only.
|
||||
|
||||
## Verification log line
|
||||
|
||||
Mike should verify this exact line on the next finish run:
|
||||
|
||||
`[OnboardingWindow] OnWizardComplete launching HubWindow on chat tab`
|
||||
@ -1,21 +0,0 @@
|
||||
# Mattingly: Finish opens HubWindow chat
|
||||
|
||||
## Summary
|
||||
Onboarding completion from Ready now launches the full HubWindow directly on the Chat tab instead of the standalone quick-chat ChatWindow.
|
||||
|
||||
## Changes
|
||||
- `src\OpenClaw.Tray.WinUI\App.xaml.cs`
|
||||
- Made `ShowHub(string? navigateTo = null, bool activate = true)` internal so onboarding can reuse the existing hub-opening path.
|
||||
- `src\OpenClaw.Tray.WinUI\Onboarding\OnboardingWindow.cs`
|
||||
- Replaced `ShowChatWindow()` completion launch with `ShowHub("chat")`.
|
||||
- Added diagnostic log: `[OnboardingWindow] OnWizardComplete launching HubWindow on chat tab`.
|
||||
- `src\OpenClaw.Tray.WinUI\Pages\ChatPage.xaml.cs`
|
||||
- Wired `BootstrapMessageInjector.InjectAsync` into the Hub chat WebView2 `NavigationCompleted` success path, matching the standalone `ChatWindow` gated injection behavior.
|
||||
|
||||
## Validation
|
||||
- Ran `./build.ps1` successfully after the code change.
|
||||
- Per active session directive, did not run tests after the fix.
|
||||
|
||||
## Architectural notes
|
||||
- Hub already exposes tag-based navigation through `NavigateTo("chat")`; `ShowHub("chat")` selects the existing NavigationView item and navigates to `ChatPage`.
|
||||
- Bootstrap injection remains wired in both standalone `ChatWindow` and Hub `ChatPage`; the existing global `Settings.HasInjectedFirstRunBootstrap` gate ensures only one path injects.
|
||||
@ -22,8 +22,5 @@ If a command fails:
|
||||
Notes:
|
||||
|
||||
- If a build/test is blocked by an environmental lock (for example running executable locking output assemblies), stop/close the locking process and rerun.
|
||||
- In linked git worktrees, set `OPENCLAW_REPO_ROOT` to the worktree path before running tests that discover the repository root, for example:
|
||||
- `$env:OPENCLAW_REPO_ROOT='D:\github\moltbot-windows-hub.<worktree-name>'`
|
||||
- Tray tests must isolate `SettingsManager` from real user settings. Do not use `new SettingsManager()` in tests unless the test intentionally reads `%APPDATA%\OpenClawTray\settings.json`; pass a temp settings directory or set `OPENCLAW_TRAY_DATA_DIR` before the test process starts.
|
||||
- Prefer isolated worktrees for PR validation. Use `git-wt` for worktree workflows; `wt.exe` may resolve to WorkTrunk instead of Windows Terminal, so use the full Windows Terminal path when explicitly launching Terminal.
|
||||
- Do not claim completion without reporting validation results.
|
||||
|
||||
@ -87,7 +87,7 @@ OpenClaw.Tray.Tests ──tests──▶ OpenClaw.Shared
|
||||
|-----------|----------|---------|
|
||||
| **Gateway Communication** | `OpenClaw.Shared/OpenClawGatewayClient.cs` | WebSocket client with protocol v3, reconnect/backoff logic |
|
||||
| **Notification System** | `OpenClaw.Tray.WinUI/App.xaml.cs` | Event routing, toast notifications, classification |
|
||||
| **WebView2 Integration** | `OpenClaw.Tray.WinUI/Windows/ChatWindow.xaml.cs` | Embedded chat panel with lifecycle management |
|
||||
| **WebView2 Integration** | `OpenClaw.Tray.WinUI/Windows/WebChatWindow.xaml.cs` | Embedded chat panel with lifecycle management |
|
||||
| **Tray Icon Management** | `OpenClaw.Tray.WinUI/Helpers/IconHelper.cs` | GDI handle management, dynamic icon generation |
|
||||
| **Session Tracking** | `OpenClaw.Shared/OpenClawGatewayClient.cs` | Session state, activity tracking, polling |
|
||||
| **Settings & Logging** | `OpenClaw.Tray.WinUI/Services/` | JSON settings persistence, file rotation logging |
|
||||
@ -285,7 +285,7 @@ Notifications are classified using two strategies:
|
||||
|
||||
### WebView2 Lifecycle
|
||||
|
||||
The `ChatWindow` uses Microsoft Edge WebView2 for embedded web content:
|
||||
The `WebChatWindow` uses Microsoft Edge WebView2 for embedded web content:
|
||||
|
||||
**Initialization:**
|
||||
1. WebView2 control created in XAML
|
||||
@ -299,7 +299,7 @@ Window Created → WebView2.EnsureCoreWebView2Async() → Navigate to Chat URL
|
||||
```
|
||||
|
||||
**Key Design Decisions:**
|
||||
- **Singleton pattern**: Only one chat window instance exists
|
||||
- **Singleton pattern**: Only one WebChat window instance exists
|
||||
- **Hidden instead of disposed**: Window is hidden when closed to preserve state
|
||||
- **Separate user data folder**: Isolates cookies/storage from browser
|
||||
- **Navigation guard**: Prevents accidental navigation away from chat
|
||||
@ -425,8 +425,8 @@ dotnet test --filter "FullyQualifiedName~AgentActivityTests"
|
||||
```
|
||||
|
||||
**Test Coverage:**
|
||||
- ✅ **1182 tests** in `OpenClaw.Shared.Tests` — models, gateway client, exec approvals, capabilities, URL helpers, notification categorization, shell quoting, MCP, device identity, and WinNode client coverage
|
||||
- ✅ **388 tests** in `OpenClaw.Tray.Tests` — settings round-trip, deep link parsing, onboarding state, setup code decoder, gateway health/chat helpers, security validation, wizard step parsing, gateway discovery, localization validation
|
||||
- ✅ **652 tests** in `OpenClaw.Shared.Tests` — models, gateway client, exec approvals, capabilities, URL helpers, notification categorization, shell quoting
|
||||
- ✅ **262 tests** in `OpenClaw.Tray.Tests` — menu display, menu positioning, settings round-trip, deep link parsing, onboarding state, setup code decoder, security validation, wizard step parsing, localization validation
|
||||
- ✅ All tests are pure unit tests (no network, no file system, no external dependencies)
|
||||
|
||||
See [tests/OpenClaw.Shared.Tests/README.md](tests/OpenClaw.Shared.Tests/README.md) for detailed test documentation.
|
||||
@ -441,7 +441,7 @@ You can test the UI and basic functionality without a running gateway:
|
||||
3. Enter a dummy gateway URL (e.g., `ws://localhost:18789`)
|
||||
4. The app will show "Disconnected" status but you can:
|
||||
- Test the tray menu structure
|
||||
- Open the Settings page and configure preferences
|
||||
- Open Settings dialog and configure preferences
|
||||
- Test auto-start functionality
|
||||
- View logs
|
||||
|
||||
@ -487,8 +487,8 @@ You can test the UI and basic functionality without a running gateway:
|
||||
- Verify Windows toast notification appears (if enabled)
|
||||
- Click toast → should open relevant UI
|
||||
|
||||
2. **Activity / notification history**:
|
||||
- Right-click tray → **Activity Stream** or **Notification History**
|
||||
2. **Notification History**:
|
||||
- Right-click tray → **Notification History**
|
||||
- Verify past notifications are listed
|
||||
- Test filtering by category
|
||||
|
||||
|
||||
23
README.md
23
README.md
@ -98,13 +98,13 @@ Modern Windows 11-style system tray companion that connects to your local OpenCl
|
||||
- 🌐 **Web Chat** - Embedded chat window with WebView2
|
||||
- 📊 **Live Status** - Real-time sessions, channels, and usage display
|
||||
- 🧭 **Command Center** - Dense gateway, channel, usage, node, pairing, and allowlist diagnostics from one window
|
||||
- ⚡ **Activity Stream** - Command Center page for live session, usage, node, and notification events
|
||||
- ⚡ **Activity Stream** - Dedicated flyout for live session, usage, node, and notification events
|
||||
- 🔔 **Toast Notifications** - Clickable Windows notifications with [smart categorization](docs/NOTIFICATION_CATEGORIZATION.md)
|
||||
- 📡 **Channel Control** - Start/stop Telegram & WhatsApp from the menu
|
||||
- 🖥️ **Node Observability** - Node inventory with online/offline state and copyable summary
|
||||
- ⏱ **Cron Jobs** - Quick access to scheduled tasks
|
||||
- 🚀 **Auto-start** - Launch with Windows
|
||||
- ⚙️ **Settings** - Full configuration page
|
||||
- ⚙️ **Settings** - Full configuration dialog
|
||||
- 🎯 **First-run onboarding** — 6-screen setup wizard (connection, permissions, chat, configuration)
|
||||
|
||||
#### Quick Send scope requirement
|
||||
@ -123,7 +123,7 @@ If Quick Send fails with `pairing required` / `NOT_PAIRED`, that is a **device a
|
||||
|
||||
### Menu Sections
|
||||
- **Status** - Gateway connection status with click-to-view details
|
||||
- **Command Center** - Hub with diagnostics, channel health, usage, sessions, nodes, and copyable repair commands
|
||||
- **Command Center** - Status detail window with diagnostics, channel health, usage, sessions, nodes, and copyable repair commands
|
||||
- **Sessions** - Active agent sessions with preview and per-session controls
|
||||
- **Usage** - Provider/cost summary with quick jump to activity details
|
||||
- **Channels** - Telegram/WhatsApp status with toggle control
|
||||
@ -177,13 +177,10 @@ When Node Mode is enabled in Settings, your Windows PC becomes a **node** that t
|
||||
| **Canvas** | `canvas.present`, `canvas.hide`, `canvas.navigate`, `canvas.eval`, `canvas.snapshot`, `canvas.a2ui.push`, `canvas.a2ui.pushJSONL`, `canvas.a2ui.reset` | Display and control a WebView2 window |
|
||||
| **Screen** | `screen.snapshot`, `screen.record` | Capture screenshots and fixed-duration MP4 screen recordings |
|
||||
| **Camera** | `camera.list`, `camera.snap`, `camera.clip` | Enumerate cameras and capture still photos or short video clips |
|
||||
| **Speech-to-text** | `stt.transcribe` | Capture audio from the default microphone for a bounded duration and return transcribed text. Default-off; opt-in via Settings. When enabled, advertised to both gateway callers (subject to gateway allowlist) and local MCP clients (subject to bearer token). |
|
||||
| **Location** | `location.get` | Return Windows geolocation when permission is available |
|
||||
| **Device** | `device.info`, `device.status` | Return Windows host/app metadata and lightweight status |
|
||||
| **Text-to-speech** | `tts.speak` | Speak text aloud through Windows speech synthesis, or ElevenLabs when configured |
|
||||
|
||||
Packaged installs declare camera, microphone, and location capabilities. Windows may ask for consent the first time a node capability uses one of those protected resources.
|
||||
|
||||
#### Node Setup
|
||||
|
||||
1. **Enable Node Mode** in Settings (enabled by default)
|
||||
@ -302,12 +299,12 @@ OpenClaw registers the `openclaw://` URL scheme for automation and integration:
|
||||
|
||||
| Link | Description |
|
||||
|------|-------------|
|
||||
| `openclaw://settings` | Open the Settings page |
|
||||
| `openclaw://settings` | Open Settings dialog |
|
||||
| `openclaw://setup` | Open Setup Wizard |
|
||||
| `openclaw://chat` | Open the Chat page |
|
||||
| `openclaw://chat` | Open Web Chat window |
|
||||
| `openclaw://commandcenter` | Open Command Center diagnostics |
|
||||
| `openclaw://activity` | Open the Activity page |
|
||||
| `openclaw://history` | Open the Activity page filtered to notification history |
|
||||
| `openclaw://activity` | Open Activity Stream |
|
||||
| `openclaw://history` | Open Notification History |
|
||||
| `openclaw://dashboard` | Open Dashboard in browser |
|
||||
| `openclaw://dashboard/sessions` | Open specific dashboard page |
|
||||
| `openclaw://dashboard/channels` | Open Channels dashboard page |
|
||||
@ -344,15 +341,15 @@ PowerToys Command Palette extension for quick OpenClaw access.
|
||||
- **📡 Dashboard: Channels** - Open the channel configuration dashboard
|
||||
- **🧩 Dashboard: Skills** - Open the skills dashboard
|
||||
- **⏱️ Dashboard: Cron** - Open the scheduled jobs dashboard
|
||||
- **💬 Web Chat** - Open the embedded Chat page
|
||||
- **💬 Web Chat** - Open the embedded Web Chat window
|
||||
- **📝 Quick Send** - Open the Quick Send dialog to compose a message
|
||||
- **🧭 Setup Wizard** - Open pairing/setup
|
||||
- **🧭 Command Center** - Open diagnostics and support actions
|
||||
- **🔄 Run Health Check** - Refresh connection health
|
||||
- **⬇️ Check for Updates** - Run a manual GitHub Releases update check
|
||||
- **⚡ Activity Stream** - Open recent activity
|
||||
- **📋 Notification History** - Open notification history in the Activity page
|
||||
- **⚙️ Settings** - Open the OpenClaw Tray Settings page
|
||||
- **📋 Notification History** - Open notification history
|
||||
- **⚙️ Settings** - Open the OpenClaw Tray Settings dialog
|
||||
- **📄 Open Log File / 📁 Logs / 🗂️ Config / 🧪 Diagnostics** - Open support files and folders
|
||||
- **📋 Copy Support Context** - Copy redacted Command Center metadata
|
||||
- **🧰 Copy Debug Bundle** - Copy combined support, port, capability, node, channel, and activity diagnostics
|
||||
|
||||
@ -46,7 +46,7 @@ OpenClaw Tray uses WinUI `.resw` resource files for localization. Windows automa
|
||||
|
||||
5. **Do not translate resource key names** (the `name` attribute). Only translate `<value>` content.
|
||||
|
||||
6. **Submit a pull request** with just your new `Resources.resw` file. No code changes are needed — the build system and localization tests automatically discover new locale folders.
|
||||
6. **Submit a pull request** with just your new `Resources.resw` file. No code changes are needed — the build system automatically discovers new locale folders.
|
||||
|
||||
## How It Works
|
||||
|
||||
@ -104,17 +104,15 @@ All onboarding wizard strings use the `Onboarding_` prefix:
|
||||
|
||||
## Validation
|
||||
|
||||
All resource files must have the **same set of keys**. Locale directories are discovered dynamically under `Strings/`, so adding a new `Strings/<locale>/Resources.resw` file automatically brings it under validation. You can verify counts with:
|
||||
All 5 resource files must have the **same set of keys**. You can verify with:
|
||||
|
||||
```powershell
|
||||
$locales = @("en-us", "fr-fr", "nl-nl", "zh-cn", "zh-tw")
|
||||
$base = "src\OpenClaw.Tray.WinUI\Strings"
|
||||
Get-ChildItem $base -Directory | ForEach-Object {
|
||||
$loc = $_.Name
|
||||
foreach ($loc in $locales) {
|
||||
$count = (Select-String -Path "$base\$loc\Resources.resw" -Pattern '<data name="' | Measure-Object).Count
|
||||
Write-Host "$loc : $count keys"
|
||||
}
|
||||
```
|
||||
|
||||
All locale counts should match. Missing or extra keys indicate an incomplete translation.
|
||||
|
||||
Non-English resource values must also follow the all-or-none rule enforced by `LocalizationValidationTests`: each key is either translated in every non-English locale, intentionally invariant in every non-English locale, or explicitly deferred with rationale. Partial translation, where only some non-English locales differ from `en-us`, is treated as a regression.
|
||||
|
||||
@ -242,7 +242,7 @@ These are reasonable next steps but explicitly out of scope for the initial impl
|
||||
| `src/OpenClaw.Shared/Mcp/McpHttpServer.cs` | `HttpListener`-based loopback HTTP transport. |
|
||||
| `src/OpenClaw.Tray.WinUI/Services/NodeService.cs` | Owns the capability list. Hosts the MCP server when enabled. |
|
||||
| `src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs` | In-memory settings model + load/save. Migrates legacy `McpOnlyMode`. |
|
||||
| `src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml(.cs)` | Settings UI surface hosted by `HubWindow`. |
|
||||
| `src/OpenClaw.Tray.WinUI/Windows/SettingsWindow.xaml(.cs)` | UI toggle, endpoint URL, and live status. |
|
||||
| `src/OpenClaw.Tray.WinUI/App.xaml.cs` | Bootstraps `NodeService` based on the new mode matrix. |
|
||||
| `tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs` | 9 unit tests for the bridge. |
|
||||
|
||||
|
||||
@ -241,7 +241,7 @@ In Settings, show read-only detected topology near gateway URL/tunnel settings:
|
||||
|
||||
### 4.5 Future Mission Control pages
|
||||
|
||||
Keep `HubWindow` as the Command Center host, with pages/sections for:
|
||||
Keep StatusDetailWindow as the first Command Center, but plan for tabs/sections:
|
||||
|
||||
1. Overview
|
||||
2. Gateway topology
|
||||
@ -341,8 +341,8 @@ Files:
|
||||
- `src/OpenClaw.Shared/SettingsData.cs` if optional declared kind is persisted
|
||||
- `src/OpenClaw.Tray.WinUI/App.xaml.cs`
|
||||
- `src/OpenClaw.Tray.WinUI/Services/SshTunnelService.cs`
|
||||
- `src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml`
|
||||
- `src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs`
|
||||
- `src/OpenClaw.Tray.WinUI/Windows/StatusDetailWindow.xaml`
|
||||
- `src/OpenClaw.Tray.WinUI/Windows/StatusDetailWindow.xaml.cs`
|
||||
- `tests/OpenClaw.Shared.Tests/ModelsTests.cs`
|
||||
- `tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs` if settings change
|
||||
|
||||
|
||||
@ -54,7 +54,7 @@ Checks 5 Windows permissions using native APIs and registry:
|
||||
Each permission shows its current status (Enabled/Disabled/Allowed/Denied) with an "Open Settings" button linking to the relevant `ms-settings:` URI.
|
||||
|
||||
### Chat
|
||||
Embeds the gateway's web chat UI via WebView2, matching the post-setup `ChatWindow` for visual consistency. Uses the shared `GatewayChatHelper` for URL building and WebView2 initialization.
|
||||
Embeds the gateway's web chat UI via WebView2, matching the post-setup `WebChatWindow` for visual consistency. Uses the shared `GatewayChatHelper` for URL building and WebView2 initialization.
|
||||
|
||||
On first load, a bootstrap message is auto-injected to kick off the gateway's first-run ritual (BOOTSTRAP.md). The message is safely encoded using `JsonSerializer.Serialize` to prevent XSS.
|
||||
|
||||
@ -75,7 +75,7 @@ The onboarding wizard follows these security practices:
|
||||
|
||||
## Localization
|
||||
|
||||
All user-visible strings use `LocalizationHelper.GetString()` with the `Onboarding_*` key namespace. Supported languages are discovered from the `Strings/<locale>/Resources.resw` directories; the current locales are English, French, Dutch, Chinese Simplified, and Chinese Traditional.
|
||||
All user-visible strings use `LocalizationHelper.GetString()` with the `Onboarding_*` key namespace. Supported languages: English, French, Dutch, Chinese Simplified, Chinese Traditional.
|
||||
|
||||
Translations are AI-generated following the repo convention. Technical terms (Gateway, Token, Node Mode) are kept in English across all locales.
|
||||
|
||||
|
||||
@ -39,15 +39,15 @@ Open Command Palette (`Win+Alt+Space`), type **"OpenClaw"** — you should see t
|
||||
| **📡 Dashboard: Channels** | Opens the channel configuration dashboard |
|
||||
| **🧩 Dashboard: Skills** | Opens the skills dashboard |
|
||||
| **⏱️ Dashboard: Cron** | Opens the scheduled jobs dashboard |
|
||||
| **💬 Web Chat** | Opens the embedded Chat page in OpenClaw Tray |
|
||||
| **💬 Web Chat** | Opens the embedded Web Chat window in OpenClaw Tray |
|
||||
| **📝 Quick Send** | Opens the Quick Send dialog to compose a message |
|
||||
| **🧭 Setup Wizard** | Opens QR, setup code, and manual gateway pairing |
|
||||
| **🧭 Command Center** | Opens gateway, tunnel, node, browser, and support diagnostics |
|
||||
| **🔄 Run Health Check** | Refreshes gateway or node connection health |
|
||||
| **⬇️ Check for Updates** | Runs a manual GitHub Releases update check |
|
||||
| **⚡ Activity Stream** | Opens recent tray activity and support bundle actions |
|
||||
| **📋 Notification History** | Opens recent OpenClaw tray notifications in the Activity page |
|
||||
| **⚙️ Settings** | Opens the OpenClaw Tray Settings page |
|
||||
| **📋 Notification History** | Opens recent OpenClaw tray notifications |
|
||||
| **⚙️ Settings** | Opens the OpenClaw Tray Settings dialog |
|
||||
| **📄 Open Log File** | Opens the current OpenClaw Tray log |
|
||||
| **📁 Open Logs Folder** | Opens the OpenClaw Tray logs folder |
|
||||
| **🗂️ Open Config Folder** | Opens the OpenClaw Tray configuration folder |
|
||||
|
||||
@ -63,7 +63,7 @@ On first launch, Molty opens a **6-screen onboarding wizard** that walks you thr
|
||||
- **Camera** — for camera capture
|
||||
- **Microphone** — for voice input
|
||||
- **Screen Capture** — for screenshots
|
||||
- **Location** — optional, for location-aware features; packaged installs declare this capability so Windows may prompt for location consent the first time it is used
|
||||
- **Location** — optional, for location-aware features
|
||||
|
||||
Each permission shows its current status. Click **Open Settings** next to any permission to jump directly to the relevant Windows Settings page.
|
||||
|
||||
@ -95,14 +95,14 @@ OpenClaw Tray responds to `openclaw://` deep links, which can be invoked from a
|
||||
| `openclaw://dashboard/channels` | Open the channels dashboard page |
|
||||
| `openclaw://dashboard/skills` | Open the skills dashboard page |
|
||||
| `openclaw://dashboard/cron` | Open the cron dashboard page |
|
||||
| `openclaw://chat` | Open the embedded Chat page |
|
||||
| `openclaw://chat` | Open the embedded Web Chat window |
|
||||
| `openclaw://send` | Open the Quick Send dialog |
|
||||
| `openclaw://send?message=Hello` | Open Quick Send with pre-filled text |
|
||||
| `openclaw://settings` | Open the Settings page |
|
||||
| `openclaw://settings` | Open the Settings dialog |
|
||||
| `openclaw://setup` | Open the Setup Wizard |
|
||||
| `openclaw://commandcenter` | Open Command Center diagnostics |
|
||||
| `openclaw://activity` | Open the Activity page |
|
||||
| `openclaw://history` | Open the Activity page filtered to notification history |
|
||||
| `openclaw://activity` | Open the Activity Stream |
|
||||
| `openclaw://history` | Open Notification History |
|
||||
| `openclaw://healthcheck` | Run a manual health check |
|
||||
| `openclaw://check-updates` | Run a manual update check |
|
||||
| `openclaw://logs` | Open the current tray log file |
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
# Test Coverage Summary
|
||||
|
||||
**1570 tests total** (1182 shared + 388 tray) — all passing ✅
|
||||
**914 tests total** (652 shared + 262 tray) — all passing ✅
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Total Tests | 1570 |
|
||||
| Passing | 1570 (100%) |
|
||||
| Total Tests | 914 |
|
||||
| Passing | 914 (100%) |
|
||||
| Failing | 0 |
|
||||
| Framework | xUnit 2.9.3 / .NET 10.0 |
|
||||
|
||||
## Test Projects
|
||||
|
||||
### OpenClaw.Shared.Tests — 1182 tests
|
||||
### OpenClaw.Shared.Tests — 652 tests
|
||||
|
||||
#### ModelsTests
|
||||
- **AgentActivityTests** (~15) — glyph mapping for all ActivityKind values, display text formatting
|
||||
@ -71,7 +71,7 @@
|
||||
|
||||
---
|
||||
|
||||
### OpenClaw.Tray.Tests — 388 tests
|
||||
### OpenClaw.Tray.Tests — 262 tests
|
||||
|
||||
#### Core Tray Tests
|
||||
|
||||
@ -83,14 +83,14 @@
|
||||
#### Onboarding Tests
|
||||
|
||||
- **OnboardingStateTests** (19) — Page order, mode logic, route changes, wizard state persistence, completion, disposal
|
||||
- **WizardStepPropsTests** (4) — Enum values, record defaults, callback verification
|
||||
- **GatewayChatHelperTests** (11) — URL scheme conversion, token encoding, localhost checks, session keys
|
||||
- **LocalGatewayApproverTests** (13) — IsLocalGateway for localhost/remote/edge cases
|
||||
- **SetupCodeDecoderTests** (14) — Base64url decode, size limits, JSON validation, URL/token extraction
|
||||
- **GatewayHealthCheckTests** (6) — Health URI building, scheme conversion, port preservation
|
||||
- **SecurityValidationTests** (16) — Locale whitelist, port range, path traversal, URI scheme validation
|
||||
- **WizardStepParsingTests** (12) — JSON step parsing, options, completion, sensitive fields
|
||||
- **GatewayDiscoveryServiceTests** — mDNS host selection and connection URL regression coverage
|
||||
- **LocalizationValidationTests** — locale key parity, onboarding key presence, duplicate detection, and all-or-none translation consistency
|
||||
- **LocalizationValidationTests** (6) — 5-locale key parity, onboarding key presence, no duplicates
|
||||
|
||||
---
|
||||
|
||||
@ -124,6 +124,6 @@ dotnet test --logger "console;verbosity=detailed"
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2026-05-04
|
||||
**Last Updated**: 2026-04-26
|
||||
**Framework**: xUnit 2.9.3 / .NET 10.0
|
||||
**Status**: ✅ 1570 tests passing
|
||||
**Status**: ✅ 914 tests passing
|
||||
|
||||
@ -61,7 +61,6 @@ These features need the gateway to send `node.invoke` commands:
|
||||
| `location.get` | Get Windows location | Uses Windows location permission/settings |
|
||||
| `device.info` / `device.status` | Device metadata/status | Returns host/app/locale plus battery/storage/network/uptime payloads |
|
||||
| `browser.proxy` | Proxy browser-control host requests | Requires Browser proxy bridge enabled, a compatible browser-control host listening on gateway port + 2, and matching browser-control auth |
|
||||
| `stt.transcribe` | Speech-to-text from default microphone | Default-off; bounded `maxDurationMs` ≤ 30000; concatenates phrases until duration elapses; requires explicit gateway allowlist |
|
||||
| `tts.speak` | Speak text aloud | Requires Text-to-speech playback enabled in Settings; gateway mode also requires `tts.speak` in `gateway.nodes.allowCommands` |
|
||||
|
||||
## Capabilities Advertised
|
||||
@ -113,40 +112,6 @@ When the node connects, it advertises these capabilities:
|
||||
- If you see "Camera access blocked", enable camera access for desktop apps in Windows Privacy settings
|
||||
- Packaged MSIX builds will show the system consent prompt automatically
|
||||
|
||||
### `stt.transcribe` returns "Speech recognition failed" or "Internal Speech Error"
|
||||
- Open Windows Settings → Privacy & security → Speech (`ms-settings:privacy-speech`)
|
||||
- Turn **Online speech recognition** = On. The Windows speech recognizer's default dictation grammar often fails without it, and Windows surfaces an unmapped HRESULT as "Internal Speech Error"
|
||||
- Open Windows Settings → Time & language → Language & region (`ms-settings:regionlanguage`), select your display language → Language options, and confirm **Speech** appears under Installed features (install it if not, ~50 MB; reboot or sign out/in afterward)
|
||||
- Verify the recognizer end-to-end with `ms-settings:speech` → "Microphone" → **Get started** before re-trying `stt.transcribe`
|
||||
|
||||
### `stt.transcribe` returns "Microphone permission denied"
|
||||
- Open Windows Settings → Privacy & security → Microphone
|
||||
- Ensure **Microphone access** (top-level toggle) is on
|
||||
- For **unpackaged** tray builds (the default `.\build.ps1` output): ensure **Let desktop apps access your microphone** is on. The tray exe will **not** appear as its own row — desktop-app access is granted as a group, not per-app
|
||||
- For **packaged MSIX** tray builds: the tray appears as its own entry under "Let apps access your microphone" and must be individually enabled (the OS shows a consent prompt on first use)
|
||||
- After changing permissions, re-pair the node so the gateway picks up the new advertised command
|
||||
|
||||
### `stt.transcribe` returns "Language pack 'X' is not installed"
|
||||
- Open Windows Settings → Time & language → Language & region
|
||||
- Add the requested display language and ensure the **Speech** optional feature is installed
|
||||
- Restart the tray after installing the speech pack
|
||||
|
||||
### Manual STT validation
|
||||
1. Enable Node Mode in Settings.
|
||||
2. Enable **Speech-to-text (microphone)** in Settings → Node mode.
|
||||
3. Append `stt.transcribe` to your existing gateway allowlist (do **not** copy a literal `...` — substitute the commands you already allow). For example, starting from the recommended Windows safe companion list:
|
||||
```bash
|
||||
openclaw config set gateway.nodes.allowCommands '["canvas.present","canvas.hide","canvas.navigate","canvas.eval","canvas.snapshot","canvas.a2ui.push","canvas.a2ui.pushJSONL","canvas.a2ui.reset","camera.list","location.get","screen.snapshot","device.info","device.status","system.execApprovals.get","system.execApprovals.set","stt.transcribe"]'
|
||||
openclaw gateway restart
|
||||
```
|
||||
4. Re-pair or re-approve the node so the gateway refreshes its command snapshot.
|
||||
5. Invoke and speak a short phrase:
|
||||
```bash
|
||||
openclaw nodes invoke --node <id> --command stt.transcribe \
|
||||
--params '{"maxDurationMs":5000,"language":"en-US"}'
|
||||
```
|
||||
6. The Windows microphone OS indicator should appear during recognition. Confirm a `transcribed:true` payload returns the text.
|
||||
|
||||
## Remaining Work (Roadmap)
|
||||
|
||||
1. ~~**system.run + exec approvals**~~ ✅ Implemented
|
||||
|
||||
@ -262,7 +262,7 @@ fake `WindowsNodeClient`).
|
||||
| Per-surface theme scope | `Hosting/SurfaceHost.cs ApplyThemeToScope` | multi-surface tab views don't bleed themes |
|
||||
| `IA2UITelemetry` seam | `Telemetry/IA2UITelemetry.cs` | structured events instead of log scraping |
|
||||
| Single-handler `Func` events on `CanvasCapability` | reviewed in commit `5b9c468` | catches accidental multi-subscribe instead of silent `Delegate.Combine` |
|
||||
| MCP bearer token in Settings UI | `SettingsPage.xaml.cs` | quality-of-life for MCP setup, kept out of action payloads |
|
||||
| MCP bearer token in Settings UI | `SettingsWindow.xaml.cs` | quality-of-life for MCP setup, kept out of action payloads |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -336,7 +336,7 @@ Recommended gateway defaults:
|
||||
| Command bucket | Windows default? | Reason |
|
||||
|----------------|------------------|--------|
|
||||
| Safe declared companion commands: `canvas.*`, `camera.list`, `location.get`, `screen.snapshot`, `device.info`, `device.status` | Yes | Matches macOS parity and only applies when declared by the node |
|
||||
| Dangerous/privacy-heavy commands: `camera.snap`, `camera.clip`, `screen.record`, `stt.transcribe`, write commands like `contacts.add` | No | Existing gateway model already requires explicit `gateway.nodes.allowCommands` |
|
||||
| Dangerous/privacy-heavy commands: `camera.snap`, `camera.clip`, `screen.record`, write commands like `contacts.add` | No | Existing gateway model already requires explicit `gateway.nodes.allowCommands` |
|
||||
| Exec commands: `system.run`, `system.run.prepare`, `system.which`, `system.notify`, `browser.proxy` | Yes | Existing Windows headless-host behavior |
|
||||
|
||||
Until the gateway expands Windows safe defaults, the practical local solution is:
|
||||
@ -364,7 +364,6 @@ Privacy-sensitive commands should stay out of the default safe list and should o
|
||||
camera.snap
|
||||
camera.clip
|
||||
screen.record
|
||||
stt.transcribe
|
||||
```
|
||||
|
||||
After changing either `gateway.nodes.allowCommands` or `gateway.nodes.denyCommands`, re-approve or re-pair the Windows node. Approved device records may keep a snapshot of the commands that were visible at approval time, so a gateway restart alone may not refresh existing approvals.
|
||||
@ -425,7 +424,6 @@ Proposal:
|
||||
- `camera.snap`
|
||||
- `camera.clip`
|
||||
- `screen.record`
|
||||
- `stt.transcribe`
|
||||
- write commands such as `contacts.add`, `calendar.add`, etc.
|
||||
|
||||
This does not grant capabilities to headless Windows hosts by itself. A command still has to pass both gates: the node must declare it in `commands`, and the gateway policy must allow it. Headless Windows node hosts that only declare `system.run` / `system.which` remain exec-only.
|
||||
@ -443,7 +441,7 @@ When shipping the Windows node, README/wiki should tell users:
|
||||
> ```
|
||||
> Then re-pair the node (`openclaw devices reject <old-id>` + re-approve).
|
||||
>
|
||||
> Add `camera.snap`, `camera.clip`, `screen.record`, and `stt.transcribe` only when you explicitly want to allow privacy-sensitive camera, screen, or microphone capture.
|
||||
> Add `camera.snap`, `camera.clip`, and `screen.record` only when you explicitly want to allow privacy-sensitive camera or screen capture.
|
||||
>
|
||||
> The Windows tray Command Center (`openclaw://commandcenter`) surfaces these policy problems directly: it separates safe companion allowlist fixes from privacy-sensitive opt-ins and provides copyable repair text for safe fixes or pending pairing approval.
|
||||
|
||||
|
||||
@ -1,369 +0,0 @@
|
||||
# OpenClaw Windows local gateway: WSL-owner Q&A
|
||||
|
||||
This document is the structured record of the questions we asked Craig Loewen
|
||||
(WSL) about the Windows OpenClaw local-gateway design, and Craig's answers.
|
||||
It is the canonical "why does the architecture look like this?" reference
|
||||
for the Windows local-gateway PR.
|
||||
|
||||
Companion: [`docs/wsl-owner-validation.md`](wsl-owner-validation.md)
|
||||
describes the resulting design as it ships.
|
||||
|
||||
**Status legend:** ✅ Answered (verbatim or paraphrased Craig answer
|
||||
recorded). 🟡 Open.
|
||||
|
||||
**Source:** Craig Loewen's review of the prototype `wsl-owner-open-issues.md`
|
||||
(2026-05-04). His answers are summarized authoritatively in
|
||||
`.squad/decisions.md` under "Decision: Craig Loewen's WSL Answers
|
||||
(Authoritative)" and underpinned the Phase 3 plan revision in
|
||||
`.squad/decisions-archive.md`. The architecture statements below are
|
||||
paraphrased; Mike's relayed verbatim Q&A lives in the squad decisions thread,
|
||||
not in the public PR.
|
||||
|
||||
The design is built on three coupled choices:
|
||||
|
||||
1. **Distribution model:** create a dedicated `OpenClawGateway` instance from
|
||||
the Store Ubuntu-24.04 package and configure it post-install — no custom
|
||||
OpenClaw rootfs.
|
||||
2. **Networking model:** loopback only between the Windows tray and the
|
||||
gateway in WSL — no WSL-IP fallback, no `lan`/`auto` bind.
|
||||
3. **Lifecycle model:** instance-scoped `wsl --terminate OpenClawGateway` for
|
||||
repair; user-systemd plus a tray-owned keepalive for liveness; no global
|
||||
`wsl --shutdown` and no global `.wslconfig` mutation.
|
||||
|
||||
The goal remains a low-maintenance implementation that uses the public
|
||||
OpenClaw Linux installer unchanged and does not maintain a custom OpenClaw
|
||||
Linux distribution.
|
||||
|
||||
## Final shape
|
||||
|
||||
1. The Windows tray verifies WSL/WSL2 availability.
|
||||
2. The tray creates a dedicated WSL2 instance named `OpenClawGateway` from
|
||||
the Store Ubuntu-24.04 package:
|
||||
```powershell
|
||||
wsl.exe --install Ubuntu-24.04 `
|
||||
--name OpenClawGateway `
|
||||
--location "$env:LOCALAPPDATA\OpenClawTray\wsl" `
|
||||
--no-launch `
|
||||
--version 2
|
||||
```
|
||||
3. The tray launches the instance as root and applies OpenClaw-owned
|
||||
configuration:
|
||||
- create the `openclaw` user;
|
||||
- create `/home/openclaw/.openclaw`, `/opt/openclaw`,
|
||||
`/var/lib/openclaw`, and `/var/log/openclaw`;
|
||||
- write `/etc/wsl.conf` and `/etc/wsl-distribution.conf`;
|
||||
- set the default user to `openclaw` via
|
||||
`wsl --manage OpenClawGateway --set-default-user openclaw`;
|
||||
- terminate only `OpenClawGateway` so WSL config takes effect.
|
||||
4. The tray runs the public OpenClaw Linux installer inside the instance:
|
||||
`https://openclaw.ai/install-cli.sh` with prefix `/opt/openclaw`. No
|
||||
forked or patched gateway installer.
|
||||
5. The tray uses upstream OpenClaw CLI/service commands to configure and
|
||||
start the gateway.
|
||||
6. The tray calls upstream `openclaw qr --json`, consumes the upstream
|
||||
setup-code/bootstrap-token handoff, and pairs Windows tray operator and
|
||||
Windows tray node sessions; both device tokens land in
|
||||
`%APPDATA%\OpenClawTray\device-key-ed25519.json`.
|
||||
|
||||
## Issue 1: Ubuntu Store package + post-install configuration
|
||||
|
||||
### Q1.1 — Is `wsl --install Ubuntu-24.04 --name OpenClawGateway --location ... --no-launch --version 2` a supported primitive for a Windows app creating a dedicated app-owned WSL instance?
|
||||
|
||||
**Status:** ✅ Answered.
|
||||
|
||||
**Craig:** Yes — supportable. This is the canonical primitive for an
|
||||
app-owned WSL instance.
|
||||
|
||||
**Implication:** `LocalGatewaySetup.cs` issues exactly this command. The
|
||||
clean port removed `--web-download`, `--from-file`, and any rootfs-import
|
||||
fallback.
|
||||
|
||||
### Q1.2 — Is it acceptable to treat the install as successful when post-conditions pass, even if the `wsl --install` process itself hangs or exits unclearly?
|
||||
|
||||
**Status:** ✅ Answered.
|
||||
|
||||
**Craig:** **Trust the exit code.** The hang-fallback pattern from the
|
||||
prototype is not needed.
|
||||
|
||||
**Implication:** The clean engine treats `wsl --install` exit 0 as the
|
||||
success signal, and additionally confirms `OpenClawGateway` appears in
|
||||
`wsl --list --quiet` to defend against the "winget-style" failure mode where
|
||||
exit 0 reports success without registering a distro (see Q1.3). Non-zero
|
||||
exit ⇒ install failure; no postcondition-on-hang path.
|
||||
|
||||
### Q1.3 — Should we prefer generic `Ubuntu`, explicit `Ubuntu-24.04`, `--web-download`, `--from-file`, or another source for the default path?
|
||||
|
||||
**Status:** ✅ Answered.
|
||||
|
||||
**Craig:** Use **explicit `Ubuntu-24.04`**, not generic `Ubuntu`. No
|
||||
`--web-download` and no `--from-file` are needed.
|
||||
|
||||
**Implication:** The clean install command is pinned to `Ubuntu-24.04`. The
|
||||
prototype's "generic `Ubuntu` channel was more reliable on this dev machine"
|
||||
observation is not a basis for a final product default.
|
||||
|
||||
Empirical confirmation (2026-05-04, 20-iter harness on Windows 10.0.26200,
|
||||
WSL 2.6.3.0): `wsl --install Ubuntu-24.04 --name <gen> --location <path>
|
||||
--no-launch --version 2` succeeded **10/10**; `winget install --id
|
||||
Canonical.Ubuntu.2404 -e --silent --accept-source-agreements
|
||||
--accept-package-agreements --disable-interactivity` succeeded **0/10**
|
||||
(stages the launcher APPX but never registers a WSL distro under
|
||||
`--silent --disable-interactivity`). Raw artifacts:
|
||||
`artifacts/wsl-install-vs-winget/run-20260504-131837/summary.json`.
|
||||
|
||||
### Q1.4 — What is the recommended enterprise/offline fallback when Store access is blocked?
|
||||
|
||||
**Status:** ✅ Answered.
|
||||
|
||||
**Craig:** Modern WSL distributions are no longer Store-gated; an offline
|
||||
fallback is **not needed** for this PR.
|
||||
|
||||
**Implication:** No offline fallback path ships in this PR. If a future
|
||||
enterprise scenario surfaces a real blocker, that decision can be revisited
|
||||
separately.
|
||||
|
||||
### Q1.5 — Are `automount=false`, `interop=false`, and `appendWindowsPath=false` appropriate for this managed instance?
|
||||
|
||||
**Status:** ✅ Answered.
|
||||
|
||||
**Craig:** Yes — all three settings are appropriate for an app-owned
|
||||
appliance.
|
||||
|
||||
**Implication:** `/etc/wsl.conf` ships with all three disabled (see
|
||||
`docs/wsl-owner-validation.md`).
|
||||
|
||||
### Q1.6 — Are there WSL/systemd/machine-id/DNS/timezone details we should explicitly repair or validate after cloning/configuring an Ubuntu instance?
|
||||
|
||||
**Status:** ✅ Answered.
|
||||
|
||||
**Craig:** **No post-clone repairs needed** — machine-id / DNS / timezone
|
||||
work as delivered.
|
||||
|
||||
**Implication:** The setup engine does not regenerate `/etc/machine-id`,
|
||||
does not rewrite `/etc/resolv.conf`, and does not touch timezone state. It
|
||||
relies on `useWindowsTimezone=true` in `/etc/wsl.conf` for clock alignment.
|
||||
|
||||
### Q1.7 — Should OpenClaw avoid writing `/etc/wsl-distribution.conf`, or is it appropriate to suppress shortcuts/terminal profile for the dedicated instance?
|
||||
|
||||
**Status:** ✅ Answered.
|
||||
|
||||
**Craig:** Use both `wsl.conf` and `wsl-distribution.conf`. Suppressing
|
||||
shortcut/terminal entries is the correct application of
|
||||
`wsl-distribution.conf` for a privately managed instance.
|
||||
|
||||
**Implication:** The setup engine writes `/etc/wsl-distribution.conf` with
|
||||
`shortcut.enabled=false` and `terminal.enabled=false`.
|
||||
|
||||
## Issue 2: Local networking between Windows and the WSL gateway
|
||||
|
||||
### Q2.1 — Is Windows localhost forwarding to a WSL2 service reliable enough to make `loopback` the final default?
|
||||
|
||||
**Status:** ✅ Answered.
|
||||
|
||||
**Craig:** **Yes — loopback only.** Windows localhost forwarding to a WSL2
|
||||
service is a reliable core WSL promise.
|
||||
|
||||
**Implication:** Gateway binds to loopback inside WSL on `:18789`. Windows
|
||||
tray connects via `http://localhost:18789` / `ws://localhost:18789`. The
|
||||
prototype's earlier observations of localhost-forwarding flakiness were
|
||||
attributed to other lifecycle issues (see Issue 3) and not to the forwarding
|
||||
contract itself.
|
||||
|
||||
### Q2.2 — If localhost forwarding fails, is WSL-IP fallback a supported/recommended pattern for a Windows app-owned WSL instance?
|
||||
|
||||
**Status:** ✅ Answered.
|
||||
|
||||
**Craig:** **No.** WSL-IP fallback is not the recommended pattern.
|
||||
|
||||
**Implication:** The clean port has **no** WSL-IP fallback. The endpoint
|
||||
resolver does not enumerate WSL interface addresses, does not run
|
||||
`hostname -I` / `ip -4 addr` / `ip route` / `ss -ltnp` inside WSL, and
|
||||
returns exactly one candidate: `http://localhost:18789`.
|
||||
|
||||
### Q2.3 — Is `gateway.bind=lan` inside the WSL instance acceptable for the fallback path, assuming the Windows tray still only advertises/selects local endpoints by default?
|
||||
|
||||
**Status:** ✅ Answered.
|
||||
|
||||
**Craig:** **No** — loopback only.
|
||||
|
||||
**Implication:** The setup engine never writes `gateway.bind=lan`. The
|
||||
runtime configuration surface for `gateway.bind` was removed.
|
||||
|
||||
### Q2.4 — Should we implement `auto` bind promotion instead of defaulting to `lan`?
|
||||
|
||||
**Status:** ✅ Answered.
|
||||
|
||||
**Craig:** **No.** Loopback only; no `auto` promotion.
|
||||
|
||||
**Implication:** No promotion logic exists in the clean port. There is one
|
||||
bind mode, and it is loopback.
|
||||
|
||||
### Q2.5 — Are there WSL NAT, mirrored networking, firewall, or portproxy recommendations we should follow while still avoiding global `.wslconfig` changes?
|
||||
|
||||
**Status:** ✅ Answered.
|
||||
|
||||
**Craig:** No — loopback forwarding works without any of those
|
||||
modifications.
|
||||
|
||||
**Implication:** The tray does not write to `.wslconfig`, does not configure
|
||||
mirrored networking, does not add Windows firewall rules, and does not run
|
||||
`netsh interface portproxy` for normal local-gateway operation.
|
||||
|
||||
### Q2.6 — What diagnostics should we capture before asking users/maintainers to file WSL networking bugs?
|
||||
|
||||
**Status:** ✅ Answered.
|
||||
|
||||
**Craig:** Point at **<https://aka.ms/wsllogs>**. Do not scrape WSL internal
|
||||
log files from the product.
|
||||
|
||||
**Implication:** On any setup or networking failure, the
|
||||
`LocalSetupProgressPage` shows an aka.ms/wsllogs hint, the validation
|
||||
script's `Save-DiagnosticsSnapshot` records `wslLogsHelp =
|
||||
https://aka.ms/wsllogs`, and the run summary appends a "Diagnostics: see
|
||||
https://aka.ms/wsllogs..." note. The product captures only its own state
|
||||
(Windows-side `:18789` listener snapshot, loopback `/health` probe,
|
||||
redacted setup-state.json) and a generated repro guide.
|
||||
|
||||
## Issue 3: WSL gateway lifecycle and service ownership
|
||||
|
||||
### Q3.1 — For an app-owned WSL appliance, should the gateway be a user-systemd service, a root/system service wrapper, or something else?
|
||||
|
||||
**Status:** ✅ Answered.
|
||||
|
||||
**Craig:** Both **user-systemd** and a **tray-owned keepalive** are
|
||||
acceptable for this shape.
|
||||
|
||||
**Implication:** The clean port uses upstream OpenClaw service primitives
|
||||
under the `openclaw` user, plus a tray-owned WSL keepalive
|
||||
(`wsl.exe -d OpenClawGateway -u openclaw -- sleep 2147483647`) while
|
||||
local-gateway mode is active. Readiness still requires Windows-side
|
||||
`/health` to succeed — `systemctl active` alone does not imply Windows
|
||||
reachability.
|
||||
|
||||
### Q3.2 — Is `loginctl enable-linger openclaw` expected to be reliable in this WSL shape, or should we avoid depending on it?
|
||||
|
||||
**Status:** ✅ Answered.
|
||||
|
||||
**Craig:** Linger is acceptable for this shape (alongside the tray
|
||||
keepalive).
|
||||
|
||||
**Implication:** Setup runs `loginctl enable-linger openclaw`. The tray
|
||||
keepalive remains as belt-and-suspenders for the active local-gateway
|
||||
window.
|
||||
|
||||
### Q3.3 — Is a tray-owned keepalive process acceptable, or should it be treated as validation-only?
|
||||
|
||||
**Status:** ✅ Answered.
|
||||
|
||||
**Craig:** Acceptable as a product primitive (see Q3.1). It is not
|
||||
validation-only.
|
||||
|
||||
**Implication:** The keepalive ships as part of the runtime, not just as a
|
||||
test scaffold.
|
||||
|
||||
### Q3.4 — Is instance-scoped `wsl --terminate OpenClawGateway` the right repair/restart primitive?
|
||||
|
||||
**Status:** ✅ Answered.
|
||||
|
||||
**Craig:** **Yes.** Use `wsl --terminate OpenClawGateway` only. **Never**
|
||||
global `wsl --shutdown`.
|
||||
|
||||
**Implication:** Setup, repair, validation, and removal paths all use
|
||||
`wsl --terminate OpenClawGateway`. `git grep 'wsl --shutdown'` over the
|
||||
clean worktree returns no product or validation hits.
|
||||
|
||||
### Q3.5 — Are there cases where global `wsl --shutdown` is recommended or unavoidable, despite our desire to avoid it?
|
||||
|
||||
**Status:** ✅ Answered.
|
||||
|
||||
**Craig:** **No.** Do not issue `wsl --shutdown` from this product.
|
||||
|
||||
**Implication:** Recreate / FreshMachine validation scenarios use
|
||||
`wsl --unregister OpenClawGateway` for destructive cleanup. They never
|
||||
issue a global shutdown.
|
||||
|
||||
### Q3.6 — What lifecycle diagnostics should the tray collect when WSL reports the service active but Windows cannot connect?
|
||||
|
||||
**Status:** ✅ Answered.
|
||||
|
||||
**Craig:** Same answer as Q2.6 — point at <https://aka.ms/wsllogs>; the
|
||||
product should not scrape WSL logs.
|
||||
|
||||
**Implication:** The product collects only its own state and points at the
|
||||
WSL-team-owned diagnostics page. See Q2.6.
|
||||
|
||||
## Mac app comparison: operator vs node
|
||||
|
||||
The macOS app runs operator/UI and a local Mac node from the same app
|
||||
binary/process via separate gateway sessions:
|
||||
|
||||
- `GatewayConnection.shared` owns one `GatewayChannelActor` for
|
||||
operator/UI scopes (`role: "operator"`, `clientMode: "ui"`).
|
||||
- `MacNodeModeCoordinator.shared.start()` owns a separate
|
||||
`GatewayNodeSession` and `MacNodeRuntime` (`role: "node"`,
|
||||
`clientId: "openclaw-macos"`, capabilities for canvas / screen / browser
|
||||
/ etc.), connecting to the same gateway URL over a distinct WebSocket.
|
||||
- In local mode, `GatewayProcessManager` manages the local gateway via
|
||||
launchd / OpenClaw CLI behavior; in remote mode,
|
||||
`ConnectionModeCoordinator` stops the local gateway and uses
|
||||
`NodeServiceManager.start()` against the remote gateway.
|
||||
|
||||
**Implication for Windows (decided by Mike):** The Windows tray pairs as
|
||||
**both operator and node** against the local gateway, mirroring the macOS
|
||||
in-app node model. There is **no separate WSL-internal worker** in this
|
||||
PR. `StartWorker` / `PairWorker` phases were dropped; the
|
||||
`PreserveWorkerData` parameter and `worker_data_preserved` lifecycle step
|
||||
were removed in Phase 3 cleanup.
|
||||
|
||||
If a future scope adds a Linux worker inside the WSL gateway instance, it
|
||||
will require a separate upstream-supported install/start/list proof and a
|
||||
new owner decision — not a re-litigation of the current PR.
|
||||
|
||||
## Architectural decisions captured
|
||||
|
||||
For traceability, the high-order decisions implied by Craig's answers are:
|
||||
|
||||
1. **Distribution model** — Store Ubuntu-24.04 + post-install configuration;
|
||||
no custom rootfs; no offline fallback. (Q1.1, Q1.3, Q1.4)
|
||||
2. **Configuration** — `wsl.conf` (systemd, automount/interop/appendPath
|
||||
off, default user `openclaw`, `useWindowsTimezone=true`) +
|
||||
`wsl-distribution.conf` (no shortcut, no terminal). No post-clone
|
||||
repairs. (Q1.5, Q1.6, Q1.7)
|
||||
3. **Networking** — Loopback only, port 18789. No WSL-IP fallback. No
|
||||
`lan`/`auto` bind. No `.wslconfig` / portproxy / firewall mutation.
|
||||
(Q2.1–Q2.5)
|
||||
4. **Lifecycle** — User-systemd + tray keepalive. Linger acceptable.
|
||||
`wsl --terminate OpenClawGateway` for repair. **Never** global
|
||||
`wsl --shutdown`. (Q3.1–Q3.5)
|
||||
5. **Diagnostics** — `https://aka.ms/wsllogs`. No internal log scraping.
|
||||
(Q2.6, Q3.6)
|
||||
6. **Roles in scope** — Windows tray operator + Windows tray node.
|
||||
Worker-in-WSL out of scope. (Mac app comparison + Mike's Phase-0
|
||||
decision.)
|
||||
|
||||
These decisions are reflected one-for-one in:
|
||||
|
||||
- `src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs`
|
||||
- `src/OpenClaw.Tray.WinUI/App.xaml.cs` (factory + identity-path wiring)
|
||||
- `src/OpenClaw.Tray.WinUI/Services/NodeService.cs`
|
||||
- `src/OpenClaw.Tray.WinUI/Onboarding/Pages/SetupWarningPage.cs`
|
||||
- `src/OpenClaw.Tray.WinUI/Onboarding/Pages/LocalSetupProgressPage.cs`
|
||||
- `scripts/validate-wsl-gateway.ps1` (4 scenarios)
|
||||
- `scripts/reset-openclaw-wsl-validation-state.ps1` (exact-target gated
|
||||
cleanup)
|
||||
|
||||
## Open follow-ups
|
||||
|
||||
These are not open architecture questions for Craig — they are tracked
|
||||
work items that intentionally fall outside this PR:
|
||||
|
||||
- **Off-box / LAN / phone reachability via OpenClaw relay.** Blocked on
|
||||
relay ownership / protocol clarity. Not addressed in this PR.
|
||||
- **`winget install Microsoft.WSL` as a platform repair fallback.** Deeper
|
||||
research in flight; does not change the Phase 3 decision to use
|
||||
`wsl --install` for distro creation in this PR.
|
||||
- **Onboarding copy localization.** `Onboarding_SetupWarning_*` /
|
||||
`Onboarding_LocalSetupProgress_*` resw entries to be added across
|
||||
supported locales after Mike signs off final copy.
|
||||
|
||||
No open questions for Craig remain that block this PR.
|
||||
@ -1,384 +0,0 @@
|
||||
# OpenClaw Windows local gateway: WSL design validation
|
||||
|
||||
This document describes the WSL design that ships in this PR. It reflects Craig
|
||||
Loewen's authoritative review of `docs/wsl-owner-open-issues.md` (verbatim Q&A
|
||||
reproduced inline in that companion doc). Where the prototype enumerated
|
||||
options, this version states the chosen design.
|
||||
|
||||
The current scope is:
|
||||
|
||||
- A dedicated app-owned **Ubuntu-24.04** WSL2 instance named `OpenClawGateway`,
|
||||
created from the standard Ubuntu Store package and then configured by the
|
||||
Windows tray.
|
||||
- The public OpenClaw Linux installer (`https://openclaw.ai/install-cli.sh`)
|
||||
runs unchanged inside that instance with prefix `/opt/openclaw`.
|
||||
- **Loopback-only** local networking (`http://localhost:18789`) between the
|
||||
Windows tray and the gateway.
|
||||
- Repair / restart via instance-scoped `wsl --terminate OpenClawGateway`.
|
||||
- Diagnostics on failure pointed at <https://aka.ms/wsllogs>.
|
||||
- The Windows tray pairs as both **operator** and **node** against the local
|
||||
gateway (matching the macOS app's in-app node model). No worker-in-WSL is
|
||||
installed by the Windows tray in this PR.
|
||||
|
||||
Out of scope for this PR (explicitly):
|
||||
|
||||
- No custom OpenClaw rootfs / OpenClaw-distributed Linux image.
|
||||
- No `--web-download` / `--from-file` / signed offline-base-artifact fallback.
|
||||
- No WSL-IP / `lan` / `auto`-bind fallback. No `gateway.bind` overrides.
|
||||
- No global `.wslconfig` mutation. No global `wsl --shutdown` from any product
|
||||
or validation path.
|
||||
- No `\\wsl$` or `\\wsl.localhost` file I/O. All WSL file operations go through
|
||||
`wsl.exe -d OpenClawGateway -- ...`.
|
||||
|
||||
## High-level user experience
|
||||
|
||||
1. User installs or opens the Windows tray app.
|
||||
2. The first onboarding page (`SetupWarningPage`) offers **Set up locally**
|
||||
(default) or **Advanced setup**.
|
||||
3. **Set up locally** opens `LocalSetupProgressPage`, which drives
|
||||
`LocalGatewaySetupEngine` to:
|
||||
- preflight the WSL host;
|
||||
- create the `OpenClawGateway` instance from Ubuntu-24.04;
|
||||
- apply OpenClaw-owned WSL configuration (`/etc/wsl.conf`,
|
||||
`/etc/wsl-distribution.conf`, `openclaw` user, state directories);
|
||||
- install OpenClaw via the public installer;
|
||||
- prepare and start the gateway service;
|
||||
- mint a bootstrap setup-code via `openclaw qr --json`;
|
||||
- pair the Windows tray operator and Windows tray node;
|
||||
- verify end-to-end reachability over loopback.
|
||||
4. On terminal failure, the page surfaces a link to <https://aka.ms/wsllogs>;
|
||||
no internal log scraping is attempted.
|
||||
|
||||
## End-state architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Windows["Windows user session"]
|
||||
Tray["OpenClaw Tray app"]
|
||||
Identity["%APPDATA%\OpenClawTray\<br/>device-key-ed25519.json (operator + node)"]
|
||||
Engine["LocalGatewaySetupEngine"]
|
||||
WslFeature["Windows WSL platform"]
|
||||
end
|
||||
|
||||
subgraph WSL["WSL2: OpenClawGateway"]
|
||||
Ubuntu["Ubuntu-24.04 (Store)"]
|
||||
WslConf["/etc/wsl.conf<br/>systemd=true<br/>automount=false<br/>interop=false<br/>appendWindowsPath=false<br/>default user=openclaw"]
|
||||
DistroConf["/etc/wsl-distribution.conf<br/>shortcut=false<br/>terminal=false"]
|
||||
Systemd["systemd"]
|
||||
Installer["public installer<br/>install-cli.sh<br/>--prefix /opt/openclaw"]
|
||||
GatewaySvc["openclaw gateway<br/>bind=loopback :18789"]
|
||||
State["/var/lib/openclaw"]
|
||||
end
|
||||
|
||||
Tray --> Engine
|
||||
Engine -->|"wsl --install Ubuntu-24.04 --name OpenClawGateway --location <appdata>\OpenClawTray\wsl --no-launch --version 2"| WslFeature
|
||||
WslFeature --> Ubuntu
|
||||
Ubuntu --> WslConf
|
||||
Ubuntu --> DistroConf
|
||||
WslConf --> Systemd
|
||||
Engine -->|"wsl -d OpenClawGateway -u root -- bash install-cli.sh"| Installer
|
||||
Installer --> GatewaySvc
|
||||
Systemd --> GatewaySvc
|
||||
GatewaySvc --> State
|
||||
Tray -->|"http://localhost:18789 (operator + node WebSocket sessions)"| GatewaySvc
|
||||
Tray --> Identity
|
||||
```
|
||||
|
||||
## WSL touch points
|
||||
|
||||
### Dedicated WSL instance lifecycle
|
||||
|
||||
The tray treats WSL as an application-owned runtime boundary and uses a single
|
||||
dedicated WSL2 instance named `OpenClawGateway`. The base is **Ubuntu-24.04**
|
||||
from the Store; the OpenClaw-owned configuration is applied after the instance
|
||||
is laid down.
|
||||
|
||||
| Operation | WSL command | Scope |
|
||||
| --- | --- | --- |
|
||||
| Preflight | `wsl.exe --status`, `wsl.exe --list --verbose` | Read-only WSL capability checks |
|
||||
| Instance creation | `wsl.exe --install Ubuntu-24.04 --name OpenClawGateway --location <%LOCALAPPDATA%>\OpenClawTray\wsl --no-launch --version 2` | Creates only the dedicated OpenClaw instance |
|
||||
| In-instance configuration | `wsl.exe -d OpenClawGateway -u root -- ...` | Writes `/etc/wsl.conf`, `/etc/wsl-distribution.conf`, creates `openclaw` user and state dirs |
|
||||
| Default user | `wsl.exe --manage OpenClawGateway --set-default-user openclaw` | Locks default user to `openclaw` |
|
||||
| Apply config | `wsl.exe --terminate OpenClawGateway` (then implicit restart on next command) | Picks up `wsl.conf` changes |
|
||||
| Public OpenClaw install | `wsl.exe -d OpenClawGateway -u root -- bash -c "curl -fsSL https://openclaw.ai/install-cli.sh \| bash -s -- --prefix /opt/openclaw"` | Runs the public installer unchanged |
|
||||
| Service start/check | `wsl.exe -d OpenClawGateway -u root -- systemctl ...` | Starts/checks OpenClaw gateway |
|
||||
| Repair | `wsl.exe --terminate OpenClawGateway` | Instance-scoped restart only |
|
||||
| Remove | `wsl.exe --terminate OpenClawGateway`, `wsl.exe --unregister OpenClawGateway` | Requires explicit user confirmation |
|
||||
|
||||
Guarantees:
|
||||
|
||||
- **WSL2 only** for the OpenClaw instance.
|
||||
- The tray never modifies the user's default WSL instance.
|
||||
- The tray never modifies global `.wslconfig`.
|
||||
- The tray never calls global `wsl.exe --shutdown` in any product, validation,
|
||||
repair, or removal path.
|
||||
- The tray never unregisters arbitrary WSL instances; only the exact
|
||||
`OpenClawGateway` name is eligible, and destructive cleanup requires explicit
|
||||
confirmation in scripts.
|
||||
|
||||
### Install command and success criterion
|
||||
|
||||
The single canonical install primitive is:
|
||||
|
||||
```powershell
|
||||
wsl.exe --install Ubuntu-24.04 `
|
||||
--name OpenClawGateway `
|
||||
--location "$env:LOCALAPPDATA\OpenClawTray\wsl" `
|
||||
--no-launch `
|
||||
--version 2
|
||||
```
|
||||
|
||||
Success criterion (per Craig): **trust the `wsl --install` exit code**.
|
||||
There is no postcondition-on-hang fallback. After exit, the engine confirms
|
||||
that `OpenClawGateway` appears in `wsl --list --quiet`; failure of that
|
||||
post-condition is treated as install failure regardless of stdout.
|
||||
|
||||
`Ubuntu-24.04` is used explicitly (not the generic `Ubuntu` channel). No
|
||||
`--web-download` and no `--from-file` are used; there is no offline base
|
||||
fallback in this PR.
|
||||
|
||||
#### Empirical evidence
|
||||
|
||||
The literature recommendation (`wsl --install` over `winget install
|
||||
Canonical.Ubuntu.2404`) was confirmed empirically on 2026-05-04 with a 20-iter
|
||||
harness:
|
||||
|
||||
| Path | success | failure | strict success rate |
|
||||
|---|---:|---:|---|
|
||||
| `wsl --install Ubuntu-24.04 --name <gen> --location <path> --no-launch --version 2` | 10 | 0 | **10/10** |
|
||||
| `winget install --id Canonical.Ubuntu.2404 -e --silent --accept-source-agreements --accept-package-agreements --disable-interactivity` | 0 | 10 | **0/10** |
|
||||
|
||||
Success ≡ exit 0 AND target distro registered in `wsl --list --quiet`.
|
||||
|
||||
Root cause for winget 0/10: `Canonical.Ubuntu.2404` is the launcher APPX, not
|
||||
a WSL distro creator; with `--silent --disable-interactivity` the launcher is
|
||||
never invoked, so the APPX stages but no distro registers. winget cannot pass
|
||||
`--name` or `--location` to the launcher.
|
||||
|
||||
Harness, raw timings, exit codes, and per-iteration `detail.json`:
|
||||
`artifacts/wsl-install-vs-winget/run-20260504-131837/summary.json`. (The
|
||||
`artifacts/` tree is gitignored; the summary will be present on any host that
|
||||
runs `scripts/experiments/wsl-install-vs-winget-empirical-2026-05-04.ps1`.)
|
||||
|
||||
A deeper winget research thread is in flight (Aaron-9, prototype worktree).
|
||||
That work may broaden the picture for `winget install Microsoft.WSL` as a
|
||||
**platform** repair fallback — it does not change the Phase 3 decision to use
|
||||
`wsl --install` for distro creation in this PR.
|
||||
|
||||
### `/etc/wsl.conf`
|
||||
|
||||
```ini
|
||||
[boot]
|
||||
systemd=true
|
||||
|
||||
[automount]
|
||||
enabled=false
|
||||
mountFsTab=false
|
||||
|
||||
[interop]
|
||||
enabled=false
|
||||
appendWindowsPath=false
|
||||
|
||||
[user]
|
||||
default=openclaw
|
||||
|
||||
[time]
|
||||
useWindowsTimezone=true
|
||||
```
|
||||
|
||||
Rationale (Craig confirmed all settings appropriate for an app-owned
|
||||
appliance):
|
||||
|
||||
- `systemd=true` — gateway is a systemd-managed service.
|
||||
- `automount.enabled=false` / `mountFsTab=false` — the gateway does not need
|
||||
Windows drive mounts.
|
||||
- `interop.enabled=false` / `appendWindowsPath=false` — the appliance does not
|
||||
shell out to Windows binaries.
|
||||
- `default=openclaw` — non-root default user; root only via explicit
|
||||
`wsl.exe -d OpenClawGateway -u root -- ...`.
|
||||
- `useWindowsTimezone=true` — gateway timestamps align with the user's
|
||||
Windows session.
|
||||
|
||||
Per Craig: no post-clone repairs needed (machine-id / DNS / timezone work as
|
||||
delivered by Ubuntu-24.04).
|
||||
|
||||
### `/etc/wsl-distribution.conf`
|
||||
|
||||
```ini
|
||||
[oobe]
|
||||
defaultName=OpenClawGateway
|
||||
|
||||
[shortcut]
|
||||
enabled=false
|
||||
|
||||
[terminal]
|
||||
enabled=false
|
||||
```
|
||||
|
||||
Rationale: the OpenClaw instance is an implementation detail; users should not
|
||||
see a Start menu shortcut or Windows Terminal profile for it. Craig confirmed
|
||||
this is the correct use of `wsl-distribution.conf` for a privately managed
|
||||
instance.
|
||||
|
||||
### Networking — loopback only
|
||||
|
||||
The gateway binds to **loopback inside WSL on port 18789**. The Windows tray
|
||||
connects via `http://localhost:18789` / `ws://localhost:18789`.
|
||||
|
||||
Per Craig: Windows localhost forwarding to a WSL2 service is a reliable core
|
||||
WSL promise. **No** WSL-IP fallback. **No** `lan` or `auto` bind. **No**
|
||||
`gateway.bind` overrides written by the tray. **No** Windows portproxy or
|
||||
firewall mutation.
|
||||
|
||||
The endpoint resolver and validation runner do not enumerate WSL interface
|
||||
addresses, do not run `hostname -I` / `ip -4 addr` / `ip route` / `ss -ltnp`
|
||||
inside WSL, and do not promote between bind modes. There is one Windows-side
|
||||
TCP listener snapshot of port 18789 plus a loopback `/health` probe.
|
||||
|
||||
Off-box / LAN / phone reachability is out of scope for this PR and will be
|
||||
handled separately when relay ownership and protocol are clear.
|
||||
|
||||
### Lifecycle and service ownership
|
||||
|
||||
- The gateway is started/managed via upstream OpenClaw CLI commands invoked
|
||||
through `wsl.exe -d OpenClawGateway -u root -- ...`.
|
||||
- `loginctl enable-linger openclaw` plus a tray-owned WSL keepalive
|
||||
(`wsl.exe -d OpenClawGateway -u openclaw -- sleep 2147483647`) keep the
|
||||
instance reachable while local-gateway mode is active. Both patterns are
|
||||
acceptable per Craig.
|
||||
- Repair primitive: `wsl.exe --terminate OpenClawGateway`. Global
|
||||
`wsl --shutdown` is **never** issued.
|
||||
- Removal: `wsl.exe --unregister OpenClawGateway` only (after explicit user
|
||||
confirmation), preceded by `wsl.exe --terminate OpenClawGateway`. Cleanup
|
||||
also removes the install-location directory.
|
||||
|
||||
Product readiness for the gateway requires all of:
|
||||
|
||||
1. service start/restart command returns;
|
||||
2. WSL listener exists on `:18789`;
|
||||
3. Windows-side `http://localhost:18789/health` probe succeeds;
|
||||
4. gateway status / RPC succeeds with the device token;
|
||||
5. setup-code mint succeeds.
|
||||
|
||||
`systemctl active` alone is not treated as readiness.
|
||||
|
||||
### Diagnostics
|
||||
|
||||
On any setup failure, the engine and validation script surface the link
|
||||
<https://aka.ms/wsllogs> for the user/maintainer to collect WSL logs. The
|
||||
product does **not** scrape WSL internal log files or invoke
|
||||
`wsl --shutdown` to collect them. The validation script's
|
||||
`Save-DiagnosticsSnapshot` records `wslLogsHelp = https://aka.ms/wsllogs` and
|
||||
`Write-Summary` appends a "Diagnostics: see https://aka.ms/wsllogs..." note
|
||||
to `summary.md` on failure.
|
||||
|
||||
### Host filesystem and file I/O
|
||||
|
||||
All WSL file operations from Windows go through `wsl.exe -d OpenClawGateway
|
||||
-- ...` subprocess calls. `\\wsl$` and `\\wsl.localhost` are forbidden in
|
||||
product code, validation scripts, tests, and ad-hoc PowerShell. The instance
|
||||
does not depend on any Windows drive mount after setup.
|
||||
|
||||
### Pairing and protocol boundary
|
||||
|
||||
OpenClaw pairing is implemented entirely through the upstream OpenClaw
|
||||
protocol. The tray never edits gateway pairing stores directly.
|
||||
|
||||
1. Gateway starts with local token auth from
|
||||
`/var/lib/openclaw/gateway.env`.
|
||||
2. Tray invokes `wsl.exe -d OpenClawGateway -- openclaw qr --json` and
|
||||
decodes the upstream setup-code payload (with short-lived bootstrap
|
||||
token).
|
||||
3. Tray (operator) connects over WebSocket using its Ed25519 device identity
|
||||
and `auth.bootstrapToken`; gateway returns `hello-ok.auth.deviceToken`,
|
||||
stored in `%APPDATA%\OpenClawTray\device-key-ed25519.json` (operator
|
||||
token field).
|
||||
4. Tray (node) opens a separate WebSocket session with role `node` and
|
||||
pairs through the same setup-code/bootstrap-token flow; the resulting
|
||||
device token is stored in the same identity file under the **node**
|
||||
field.
|
||||
5. Subsequent reconnects use `auth.deviceToken`. Node tokens are never
|
||||
reused as `auth.token` and vice versa.
|
||||
|
||||
Identity-path invariant: operator and node device tokens share
|
||||
`%APPDATA%\OpenClawTray\device-key-ed25519.json` (`OPENCLAW_TRAY_APPDATA_DIR`
|
||||
override honored), with role distinction inside the file. The
|
||||
prototype-era split between `%APPDATA%` (operator) and `%LOCALAPPDATA%`
|
||||
(node) was closed in Phase 4.
|
||||
|
||||
The Windows tray node parallels the macOS app's in-app node model
|
||||
(`MacNodeModeCoordinator` with role `node`, separate session, capabilities
|
||||
declared). No WSL-internal worker is paired by the Windows tray in this PR.
|
||||
|
||||
## Validation
|
||||
|
||||
`scripts/validate-wsl-gateway.ps1` provides four scenarios. Each writes a
|
||||
JSON+markdown summary under `artifacts/validate-wsl-gateway/<run-id>/`.
|
||||
|
||||
Validation AppData isolation uses this canonical contract:
|
||||
|
||||
- `OPENCLAW_TRAY_DATA_DIR` is the settings/logs/run-marker root consumed by
|
||||
`SettingsManager`, `App.DataPath`, `Logger`, and token path resolution.
|
||||
- `OPENCLAW_TRAY_APPDATA_DIR` is the roaming identity-store root consumed by
|
||||
`DeviceIdentity`/pairing paths. Validation sets it alongside
|
||||
`OPENCLAW_TRAY_DATA_DIR` for backward compatibility and identity isolation.
|
||||
- `OPENCLAW_TRAY_LOCALAPPDATA_DIR` is the local setup-state/WSL-install root.
|
||||
|
||||
| Scenario | What it does | When to use | Destructive |
|
||||
|---|---|---|---|
|
||||
| `PreflightOnly` | Repo-layout sanity, WSL host status (`wsl --status`, `wsl --list --verbose`), relay-prototype probe (NotAvailable when no probe URI). No build, no install, no WSL state mutation. | Cheap CI / local sanity check. Safe on dev box. | No |
|
||||
| `UpstreamInstall` | Build + tests, then drives the tray onboarding so the product itself runs the canonical `wsl --install Ubuntu-24.04 --name OpenClawGateway --location <path> --no-launch --version 2` path. Smoke + bootstrap-token + operator+node pairing proofs over loopback. Reuses an existing `OpenClawGateway` instance if present. | Lab / dedicated machine. End-to-end product path. | Reuses existing distro state |
|
||||
| `FreshMachine` | `UpstreamInstall` after a fresh-machine reset: `wsl --unregister OpenClawGateway` + AppData wipe (single shot). | Lab. Fresh install proof. | Yes, scoped to `OpenClawGateway` |
|
||||
| `Recreate` | Iterated `FreshMachine`. Supports `-Iterations`. Uses `wsl --unregister` only — **never** `wsl --shutdown`. | Lab / repeatability harness. | Yes, scoped to `OpenClawGateway` |
|
||||
|
||||
Scenarios deliberately removed from the prototype: `BuildRootfs`,
|
||||
`InstallOnly`, `Smoke`, `Full`, `Loop`. Parameters deliberately removed:
|
||||
`-BuildDevRootfs`, `-BaseRootfsPath`, `-GatewayPackagePath`,
|
||||
`-UseExistingManifest`, `-RootfsPath`, `-AllowUnsignedDevArtifact`,
|
||||
`-SigningKeyId`, `-PublicKeyPath`,
|
||||
`-AllowNonStandardDistroNameForDestructiveClean`, `-NetworkingMode`,
|
||||
`-LoopMode`, `-RequireWorkerPairing`, `-CleanOpenClawState`,
|
||||
`-GoSkillProofCommand`, `-RequireGoSkillProof`.
|
||||
|
||||
The validation script:
|
||||
|
||||
- Drives onboarding via the `SetupWarningPage` "Set up locally" button
|
||||
(`OnboardingSetupLocal` automation ID); `LocalSetupProgressPage` autostarts
|
||||
the engine on appearance.
|
||||
- Polls `setup-state.json` for `Complete` (terminal status). Worker / rootfs
|
||||
phases are gone; terminal status is `Complete` only.
|
||||
- Snapshots loopback diagnostics on failure (Windows-side `:18789` listener
|
||||
state; loopback `/health` probe). Does **not** run any networking probes
|
||||
inside WSL.
|
||||
- Redacts sensitive output: `Redact-SensitiveGatewayOutput` over
|
||||
`openclaw qr --json` stdout, `Save-RedactedSettings` strips `Token`,
|
||||
`GatewayToken`, `BootstrapToken`, `bootstrap_token`, `NodeToken`,
|
||||
`nodeToken`; relay probe body strips `token=...`.
|
||||
|
||||
Scope guarantees from the validation script:
|
||||
|
||||
- Only `OpenClawGateway` is ever the target of `wsl --unregister`.
|
||||
- Global `wsl --shutdown` is never issued.
|
||||
- No `\\wsl$` or `\\wsl.localhost` paths are read or written.
|
||||
|
||||
Companion script:
|
||||
`scripts/reset-openclaw-wsl-validation-state.ps1` — exact-target gated
|
||||
cleanup for `OpenClawGateway` plus the `%APPDATA%\OpenClawTray` and
|
||||
`%LOCALAPPDATA%\OpenClawTray` directories. Refuses to act on any other distro
|
||||
name.
|
||||
|
||||
## Outstanding follow-ups
|
||||
|
||||
Tracked but outside the scope of this PR:
|
||||
|
||||
- Off-box / LAN / phone reachability via OpenClaw relay (blocked on relay
|
||||
ownership / protocol clarity).
|
||||
- Optional `winget install Microsoft.WSL` as a **platform** repair fallback
|
||||
(deeper research in flight). Distro creation stays on `wsl --install`
|
||||
regardless.
|
||||
- Internationalization of the onboarding copy (`Onboarding_SetupWarning_*`
|
||||
/ `Onboarding_LocalSetupProgress_*` resw entries across the supported
|
||||
locales).
|
||||
|
||||
See `docs/wsl-owner-open-issues.md` for the structured Q&A explaining **why**
|
||||
this design is what it is, with Craig's verbatim answers.
|
||||
@ -27,7 +27,6 @@
|
||||
<Project Path="tests/OpenClaw.Shared.Tests/OpenClaw.Shared.Tests.csproj" />
|
||||
<Project Path="tests/OpenClaw.WinNode.Cli.Tests/OpenClaw.WinNode.Cli.Tests.csproj" />
|
||||
<Project Path="tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj" />
|
||||
<Project Path="tests/OpenClawTray.FunctionalUI.Tests/OpenClawTray.FunctionalUI.Tests.csproj" />
|
||||
<Project Path="tests/OpenClaw.Tray.IntegrationTests/OpenClaw.Tray.IntegrationTests.csproj" />
|
||||
<Project Path="tests/OpenClaw.Tray.UITests/OpenClaw.Tray.UITests.csproj">
|
||||
<Platform Solution="*|Any CPU" Project="x64" />
|
||||
|
||||
@ -1,326 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Dev-loop helper: kill → backup/wipe state → optionally wipe WSL distro → build x64 → (optionally) launch tray.
|
||||
|
||||
.DESCRIPTION
|
||||
Consolidates the full dev-reset cycle used during OpenClaw tray development.
|
||||
Idempotent: no error if nothing is running, state dirs are absent, or the WSL
|
||||
distro is not registered.
|
||||
|
||||
Process kills are always by PID (Stop-Process -Id). Name-based kills are
|
||||
forbidden in this repo.
|
||||
|
||||
WSL file operations use 'wsl bash -c' — never \\wsl$\ paths (which trigger
|
||||
Windows permission prompts via the 9P protocol).
|
||||
|
||||
.PARAMETER WipeWslDistro
|
||||
Also unregister the OpenClawGateway WSL distro (wsl --unregister).
|
||||
Default: off (preserve the distro).
|
||||
|
||||
.PARAMETER CaptureDir
|
||||
If set, exports OPENCLAW_VISUAL_TEST=1 and OPENCLAW_VISUAL_TEST_DIR=<path>
|
||||
before launching the tray so the app auto-captures screenshots.
|
||||
|
||||
.PARAMETER SkipBuild
|
||||
Skip the 'dotnet build' step. Useful when you have just built.
|
||||
|
||||
.PARAMETER DontLaunch
|
||||
Reset and (optionally) build, but do not launch the tray.
|
||||
|
||||
.PARAMETER WorktreePath
|
||||
Root of the git worktree to operate in.
|
||||
Default: result of 'git rev-parse --show-toplevel' in the current directory.
|
||||
|
||||
.PARAMETER NoBackup
|
||||
Instead of backing up state dirs to TEMP, delete them directly.
|
||||
Faster, but no rollback.
|
||||
|
||||
.EXAMPLE
|
||||
.\scripts\dev-reset-rebuild-launch.ps1
|
||||
Standard reset + rebuild + launch (no WSL wipe, no capture).
|
||||
|
||||
.EXAMPLE
|
||||
.\scripts\dev-reset-rebuild-launch.ps1 -WipeWslDistro
|
||||
Full clean slate: also unregister the OpenClawGateway WSL distro.
|
||||
|
||||
.EXAMPLE
|
||||
.\scripts\dev-reset-rebuild-launch.ps1 -DontLaunch
|
||||
Reset + build only (useful before testing manually).
|
||||
|
||||
.EXAMPLE
|
||||
.\scripts\dev-reset-rebuild-launch.ps1 -CaptureDir .\visual-test-output\my-test
|
||||
Reset + build + launch with OPENCLAW_VISUAL_TEST capture enabled.
|
||||
#>
|
||||
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param(
|
||||
[switch]$WipeWslDistro,
|
||||
[string]$CaptureDir = "",
|
||||
[switch]$SkipBuild,
|
||||
[switch]$DontLaunch,
|
||||
[string]$WorktreePath = "",
|
||||
[switch]$NoBackup
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# ─── Resolve worktree path ────────────────────────────────────────────────────
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($WorktreePath)) {
|
||||
$gitTop = & git rev-parse --show-toplevel 2>$null
|
||||
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($gitTop)) {
|
||||
Write-Error "Cannot resolve worktree path: not inside a git repository and -WorktreePath was not supplied."
|
||||
exit 1
|
||||
}
|
||||
$WorktreePath = $gitTop.Trim()
|
||||
}
|
||||
$WorktreePath = (Resolve-Path -LiteralPath $WorktreePath).Path
|
||||
|
||||
# ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
$DistroName = "OpenClawGateway"
|
||||
$TrayProject = Join-Path $WorktreePath "src\OpenClaw.Tray.WinUI\OpenClaw.Tray.WinUI.csproj"
|
||||
$AppDataDir = Join-Path $env:APPDATA "OpenClawTray"
|
||||
$LocalAppDataDir = Join-Path $env:LOCALAPPDATA "OpenClawTray"
|
||||
$timestamp = (Get-Date).ToString("yyyy-MM-ddTHH-mm-ss")
|
||||
$BackupRoot = Join-Path $env:TEMP "openclaw-test-backup-$timestamp"
|
||||
|
||||
# ─── Summary state ────────────────────────────────────────────────────────────
|
||||
|
||||
$summary = [ordered]@{
|
||||
backupPath = $null
|
||||
distroState = "not-checked"
|
||||
buildResult = "skipped"
|
||||
launchPid = $null
|
||||
}
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function Write-Step {
|
||||
param([string]$Icon, [string]$Message)
|
||||
Write-Host " $Icon $Message"
|
||||
}
|
||||
function Write-OK { param([string]$m) Write-Step "✓" $m }
|
||||
function Write-Skip { param([string]$m) Write-Step "-" $m }
|
||||
function Write-Fail { param([string]$m) Write-Step "x" $m }
|
||||
|
||||
function Get-OpenClawProcesses {
|
||||
@(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.ProcessName -like "OpenClaw*" })
|
||||
}
|
||||
|
||||
function Get-WslDistros {
|
||||
$out = & wsl.exe --list --quiet 2>$null
|
||||
if ($LASTEXITCODE -ne 0 -or $null -eq $out) { return @() }
|
||||
@($out | ForEach-Object { ($_ -replace "`0", "").Trim() } | Where-Object { $_ })
|
||||
}
|
||||
|
||||
# ─── Banner ───────────────────────────────────────────────────────────────────
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "============================================================"
|
||||
Write-Host " OpenClaw Dev Loop -- Reset / Rebuild / Launch"
|
||||
Write-Host "============================================================"
|
||||
Write-Host " Timestamp : $timestamp"
|
||||
Write-Host " WorktreePath : $WorktreePath"
|
||||
Write-Host " WipeWslDistro: $WipeWslDistro SkipBuild: $SkipBuild DontLaunch: $DontLaunch"
|
||||
Write-Host " NoBackup : $NoBackup CaptureDir: $(if ($CaptureDir) { $CaptureDir } else { '(none)' })"
|
||||
if ($WhatIfPreference) {
|
||||
Write-Host " *** WHATIF MODE -- no state will be changed ***"
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# =============================================================================
|
||||
# STEP 1 -- Kill OpenClaw* processes (by PID; name-based kills are forbidden)
|
||||
# =============================================================================
|
||||
|
||||
Write-Host "STEP 1: Kill OpenClaw* processes"
|
||||
$procs = @(Get-OpenClawProcesses)
|
||||
|
||||
if ($procs.Count -eq 0) {
|
||||
Write-Skip "No OpenClaw* processes running"
|
||||
}
|
||||
else {
|
||||
foreach ($p in $procs) {
|
||||
if ($PSCmdlet.ShouldProcess("PID $($p.Id) ($($p.ProcessName))", "Stop-Process -Id")) {
|
||||
try {
|
||||
Stop-Process -Id $p.Id -Force
|
||||
Write-OK "Stopped PID $($p.Id) ($($p.ProcessName))"
|
||||
}
|
||||
catch {
|
||||
Write-Fail "Failed to stop PID $($p.Id): $_"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Skip "WhatIf: would stop PID $($p.Id) ($($p.ProcessName))"
|
||||
}
|
||||
}
|
||||
if (-not $WhatIfPreference) {
|
||||
Start-Sleep -Milliseconds 500 # brief pause for file-lock release
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# STEP 2 -- Backup or wipe tray state dirs
|
||||
# =============================================================================
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "STEP 2: $(if ($NoBackup) { 'Wipe' } else { 'Backup' }) tray state dirs"
|
||||
|
||||
function Invoke-StateDirReset {
|
||||
param([string]$Path, [string]$Label)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Path)) {
|
||||
Write-Skip "$Label not present -- nothing to do"
|
||||
return
|
||||
}
|
||||
|
||||
if ($NoBackup) {
|
||||
if ($PSCmdlet.ShouldProcess($Path, "Remove-Item -Recurse -Force")) {
|
||||
Remove-Item -LiteralPath $Path -Recurse -Force
|
||||
Write-OK "Deleted $Label ($Path)"
|
||||
}
|
||||
else {
|
||||
Write-Skip "WhatIf: would delete $Label ($Path)"
|
||||
}
|
||||
}
|
||||
else {
|
||||
$dest = Join-Path $BackupRoot $Label
|
||||
if ($PSCmdlet.ShouldProcess($Path, "Copy-Item to backup then Remove-Item")) {
|
||||
New-Item -ItemType Directory -Force -Path $BackupRoot | Out-Null
|
||||
Copy-Item -LiteralPath $Path -Destination $dest -Recurse -Force
|
||||
Remove-Item -LiteralPath $Path -Recurse -Force
|
||||
Write-OK "Backed up $Label --> $dest"
|
||||
$script:summary.backupPath = $BackupRoot
|
||||
}
|
||||
else {
|
||||
Write-Skip "WhatIf: would backup $Label --> $dest, then remove source"
|
||||
$script:summary.backupPath = "(whatif) $BackupRoot"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-StateDirReset -Path $AppDataDir -Label "AppData_OpenClawTray"
|
||||
Invoke-StateDirReset -Path $LocalAppDataDir -Label "LocalAppData_OpenClawTray"
|
||||
|
||||
# =============================================================================
|
||||
# STEP 3 -- Optionally wipe the WSL distro
|
||||
# =============================================================================
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "STEP 3: WSL distro ($DistroName)"
|
||||
|
||||
$distros = @(Get-WslDistros)
|
||||
$distroExists = $distros -contains $DistroName
|
||||
|
||||
if (-not $WipeWslDistro) {
|
||||
Write-Skip "-WipeWslDistro not set -- preserving $DistroName"
|
||||
$summary.distroState = if ($distroExists) { "preserved" } else { "absent" }
|
||||
}
|
||||
elseif (-not $distroExists) {
|
||||
Write-Skip "$DistroName is not registered -- nothing to unregister"
|
||||
$summary.distroState = "absent"
|
||||
}
|
||||
else {
|
||||
if ($PSCmdlet.ShouldProcess($DistroName, "wsl --terminate then wsl --unregister")) {
|
||||
& wsl.exe --terminate $DistroName 2>$null # ignore exit code -- distro may already be stopped
|
||||
& wsl.exe --unregister $DistroName
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Fail "wsl --unregister $DistroName failed (exit $LASTEXITCODE)"
|
||||
exit 1
|
||||
}
|
||||
Write-OK "Unregistered WSL distro $DistroName"
|
||||
$summary.distroState = "unregistered"
|
||||
}
|
||||
else {
|
||||
Write-Skip "WhatIf: would terminate + unregister WSL distro $DistroName"
|
||||
$summary.distroState = "(whatif) would-unregister"
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# STEP 4 -- Build x64 tray
|
||||
# =============================================================================
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "STEP 4: Build x64 tray"
|
||||
|
||||
if ($SkipBuild) {
|
||||
Write-Skip "-SkipBuild set -- skipping dotnet build"
|
||||
$summary.buildResult = "skipped"
|
||||
}
|
||||
else {
|
||||
if (-not (Test-Path -LiteralPath $TrayProject)) {
|
||||
Write-Fail "Tray project not found: $TrayProject"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($PSCmdlet.ShouldProcess($TrayProject, "dotnet build -p:Platform=x64 --no-restore -v q")) {
|
||||
Write-Verbose "Running: dotnet build `"$TrayProject`" -p:Platform=x64 --no-restore -v q"
|
||||
& dotnet build $TrayProject -p:Platform=x64 --no-restore -v q
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Fail "dotnet build failed (exit $LASTEXITCODE)"
|
||||
$summary.buildResult = "failed"
|
||||
exit 1
|
||||
}
|
||||
Write-OK "Build succeeded"
|
||||
$summary.buildResult = "succeeded"
|
||||
}
|
||||
else {
|
||||
Write-Skip "WhatIf: would run: dotnet build `"$TrayProject`" -p:Platform=x64 --no-restore -v q"
|
||||
$summary.buildResult = "(whatif) would-build"
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# STEP 5 -- Launch tray
|
||||
# =============================================================================
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "STEP 5: Launch tray"
|
||||
|
||||
if ($DontLaunch) {
|
||||
Write-Skip "-DontLaunch set -- not launching"
|
||||
}
|
||||
else {
|
||||
if ($PSCmdlet.ShouldProcess($TrayProject, "dotnet run -p:Platform=x64")) {
|
||||
if ($CaptureDir) {
|
||||
$captureAbs = if ([System.IO.Path]::IsPathRooted($CaptureDir)) {
|
||||
$CaptureDir
|
||||
}
|
||||
else {
|
||||
Join-Path $WorktreePath $CaptureDir
|
||||
}
|
||||
$env:OPENCLAW_VISUAL_TEST = "1"
|
||||
$env:OPENCLAW_VISUAL_TEST_DIR = $captureAbs
|
||||
Write-Verbose "Set OPENCLAW_VISUAL_TEST=1 OPENCLAW_VISUAL_TEST_DIR=$captureAbs"
|
||||
}
|
||||
|
||||
Write-Verbose "Launching: dotnet run --project `"$TrayProject`" -p:Platform=x64"
|
||||
$launchProc = Start-Process -FilePath "dotnet" `
|
||||
-ArgumentList "run", "--project", $TrayProject, "-p:Platform=x64" `
|
||||
-PassThru -WorkingDirectory $WorktreePath
|
||||
$summary.launchPid = $launchProc.Id
|
||||
Write-OK "Tray launched (PID $($launchProc.Id))"
|
||||
}
|
||||
else {
|
||||
Write-Skip "WhatIf: would launch: dotnet run --project `"$TrayProject`" -p:Platform=x64"
|
||||
if ($CaptureDir) {
|
||||
Write-Skip "WhatIf: would also set OPENCLAW_VISUAL_TEST=1 and OPENCLAW_VISUAL_TEST_DIR=$CaptureDir"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Summary
|
||||
# =============================================================================
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "---------------------------- Summary ----------------------------"
|
||||
Write-Host " Backup path : $(if ($summary.backupPath) { $summary.backupPath } elseif ($NoBackup) { '(deleted directly)' } else { '(nothing backed up)' })"
|
||||
Write-Host " Distro state : $($summary.distroState)"
|
||||
Write-Host " Build result : $($summary.buildResult)"
|
||||
Write-Host " Launch PID : $(if ($summary.launchPid) { $summary.launchPid } else { '(not launched)' })"
|
||||
Write-Host "-----------------------------------------------------------------"
|
||||
Write-Host ""
|
||||
@ -1,388 +0,0 @@
|
||||
# reset-openclaw-wsl-validation-state.ps1
|
||||
#
|
||||
# Exact-target destructive cleanup for OpenClaw-owned WSL validation state.
|
||||
#
|
||||
# Safety guarantees enforced by this script:
|
||||
# 1. Without -ConfirmDestructiveClean, the script runs in DRY-RUN mode and
|
||||
# reports what it WOULD do; it never mutates state.
|
||||
# 2. The only WSL distro this script will ever touch is the production
|
||||
# constant "OpenClawGateway". Any other distro name is rejected.
|
||||
# 3. Destructive operations are preceded by a copy of the user's
|
||||
# %APPDATA%\OpenClawTray and %LOCALAPPDATA%\OpenClawTray identity
|
||||
# directories to a timestamped backup location (printed to console).
|
||||
# 4. The script never calls `wsl --shutdown`. It uses
|
||||
# `wsl --terminate OpenClawGateway` only.
|
||||
# 5. The script never reads or writes \\wsl$ / \\wsl.localhost paths.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$OutputDir = (Join-Path (Get-Location) "artifacts\wsl-gateway-validation\reset"),
|
||||
[string]$BackupRoot,
|
||||
[string]$AppDataRoot,
|
||||
[string]$LocalAppDataRoot,
|
||||
[string]$InstallLocation,
|
||||
[switch]$CleanInstallLocation,
|
||||
[switch]$ConfirmDestructiveClean,
|
||||
[switch]$KeepRunningProcesses,
|
||||
[switch]$PassThruJson
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Production-locked WSL distro name (Phase 3 constant). This script will
|
||||
# refuse to act on any other distro, even via -DistroName overrides
|
||||
# (which are intentionally absent).
|
||||
$script:OpenClawDistroName = "OpenClawGateway"
|
||||
|
||||
$startedAt = Get-Date
|
||||
$timestamp = $startedAt.ToString("yyyyMMddHHmmss")
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($BackupRoot)) {
|
||||
$BackupRoot = Join-Path (Get-Location) "artifacts\reset-backups\$timestamp"
|
||||
}
|
||||
|
||||
$result = [ordered]@{
|
||||
script = "reset-openclaw-wsl-validation-state"
|
||||
startedAt = $startedAt.ToString("o")
|
||||
finishedAt = $null
|
||||
outputDir = $OutputDir
|
||||
backupRoot = $BackupRoot
|
||||
distroName = $script:OpenClawDistroName
|
||||
installLocation = $InstallLocation
|
||||
appDataRoot = $AppDataRoot
|
||||
localAppDataRoot = $LocalAppDataRoot
|
||||
destructiveConfirmed = [bool]$ConfirmDestructiveClean
|
||||
dryRun = -not $ConfirmDestructiveClean
|
||||
targets = [ordered]@{}
|
||||
steps = @()
|
||||
}
|
||||
|
||||
function Add-ResetStep {
|
||||
param(
|
||||
[string]$Name,
|
||||
[string]$Status,
|
||||
[string]$Message,
|
||||
[hashtable]$Data = @{}
|
||||
)
|
||||
|
||||
$script:result.steps += [ordered]@{
|
||||
name = $Name
|
||||
status = $Status
|
||||
message = $Message
|
||||
data = $Data
|
||||
timestamp = (Get-Date).ToString("o")
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-CapturedCommand {
|
||||
param(
|
||||
[string]$Name,
|
||||
[string]$FilePath,
|
||||
[string[]]$ArgumentList,
|
||||
[string]$WorkingDirectory = (Get-Location).Path,
|
||||
[switch]$IgnoreExitCode
|
||||
)
|
||||
|
||||
$stepDir = Join-Path $OutputDir "commands"
|
||||
New-Item -ItemType Directory -Force -Path $stepDir | Out-Null
|
||||
$safeName = $Name -replace "[^a-zA-Z0-9_.-]", "-"
|
||||
$stdout = Join-Path $stepDir "$safeName.stdout.txt"
|
||||
$stderr = Join-Path $stepDir "$safeName.stderr.txt"
|
||||
|
||||
Push-Location $WorkingDirectory
|
||||
try {
|
||||
& $FilePath @ArgumentList > $stdout 2> $stderr
|
||||
$exitCode = if ($null -eq $global:LASTEXITCODE) { 0 } else { $global:LASTEXITCODE }
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Add-ResetStep $Name "Completed" "Command completed with exit code $exitCode." @{
|
||||
file = $FilePath
|
||||
arguments = ($ArgumentList -join " ")
|
||||
exitCode = $exitCode
|
||||
stdout = $stdout
|
||||
stderr = $stderr
|
||||
}
|
||||
|
||||
if ($exitCode -ne 0 -and -not $IgnoreExitCode) {
|
||||
throw "$Name failed with exit code $exitCode. See $stdout and $stderr."
|
||||
}
|
||||
}
|
||||
|
||||
function Backup-Directory {
|
||||
param(
|
||||
[string]$Path,
|
||||
[string]$Label
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Path)) {
|
||||
Add-ResetStep "backup-$Label" "Skipped" "$Path does not exist."
|
||||
return
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $BackupRoot | Out-Null
|
||||
$leaf = Split-Path -Leaf $Path
|
||||
$destination = Join-Path $BackupRoot "$Label-$leaf"
|
||||
|
||||
if ($result.dryRun) {
|
||||
Add-ResetStep "backup-$Label" "DryRun" "Would copy $Path to $destination, then remove the original." @{
|
||||
source = $Path
|
||||
destination = $destination
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $destination) {
|
||||
$destination = Join-Path $BackupRoot ("{0}-{1:yyyyMMddHHmmss}" -f "$Label-$leaf", (Get-Date))
|
||||
}
|
||||
|
||||
# Copy first so the user can recover even if removal fails partway.
|
||||
Copy-Item -LiteralPath $Path -Destination $destination -Recurse -Force
|
||||
Remove-Item -LiteralPath $Path -Recurse -Force
|
||||
Add-ResetStep "backup-$Label" "Completed" "Backed up $Path to $destination, then removed the original." @{
|
||||
source = $Path
|
||||
destination = $destination
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-DestructiveTargetIsAllowed {
|
||||
# Hard-lock: this script will only ever touch the production OpenClawGateway distro.
|
||||
# No override flag exists. If $script:OpenClawDistroName is ever something else,
|
||||
# the script must refuse to run regardless of dry-run mode.
|
||||
if ($script:OpenClawDistroName -ne "OpenClawGateway") {
|
||||
throw "Refusing to run: distro name is locked to 'OpenClawGateway' but resolved to '$($script:OpenClawDistroName)'."
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PortOwnerSnapshot {
|
||||
param([string]$Label)
|
||||
|
||||
$port = 18789
|
||||
try {
|
||||
$connections = @(Get-NetTCPConnection -LocalPort $port -ErrorAction Stop)
|
||||
$snapshot = @($connections | ForEach-Object {
|
||||
[ordered]@{
|
||||
localAddress = $_.LocalAddress
|
||||
localPort = $_.LocalPort
|
||||
state = $_.State.ToString()
|
||||
owningProcess = $_.OwningProcess
|
||||
}
|
||||
})
|
||||
}
|
||||
catch {
|
||||
$snapshot = @()
|
||||
}
|
||||
|
||||
$snapshotPath = Join-Path $OutputDir "port-18789-$Label.json"
|
||||
$snapshot | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $snapshotPath -Encoding UTF8
|
||||
Add-ResetStep "port-snapshot-$Label" "Completed" "Captured TCP listener snapshot for port 18789." @{
|
||||
path = $snapshotPath
|
||||
ownerCount = @($snapshot).Count
|
||||
}
|
||||
return $snapshot
|
||||
}
|
||||
|
||||
function Get-WslDistros {
|
||||
$output = & wsl.exe --list --quiet 2>$null
|
||||
if ($LASTEXITCODE -ne 0 -or $null -eq $output) {
|
||||
return @()
|
||||
}
|
||||
|
||||
return @($output | ForEach-Object { ($_ -replace "`0", "").Trim() } | Where-Object { $_ })
|
||||
}
|
||||
|
||||
function Get-OpenClawProcesses {
|
||||
return @(Get-Process | Where-Object { $_.ProcessName -like "OpenClaw*" })
|
||||
}
|
||||
|
||||
function Add-TargetSummary {
|
||||
param(
|
||||
[object[]]$Processes,
|
||||
[string[]]$Distros,
|
||||
[string]$AppDataPath,
|
||||
[string]$LocalAppDataPath,
|
||||
[string]$InstallLocationPath,
|
||||
[object[]]$PortOwners
|
||||
)
|
||||
|
||||
$script:result.targets = [ordered]@{
|
||||
processes = @($Processes | ForEach-Object {
|
||||
[ordered]@{
|
||||
pid = $_.Id
|
||||
name = $_.ProcessName
|
||||
path = $_.Path
|
||||
}
|
||||
})
|
||||
distroExists = ($Distros -contains $script:OpenClawDistroName)
|
||||
distroName = $script:OpenClawDistroName
|
||||
appDataPath = $AppDataPath
|
||||
appDataExists = Test-Path -LiteralPath $AppDataPath
|
||||
localAppDataPath = $LocalAppDataPath
|
||||
localAppDataExists = Test-Path -LiteralPath $LocalAppDataPath
|
||||
installLocationPath = $InstallLocationPath
|
||||
installLocationExists = (-not [string]::IsNullOrWhiteSpace($InstallLocationPath)) -and (Test-Path -LiteralPath $InstallLocationPath)
|
||||
installLocationCleanupRequested = [bool]$CleanInstallLocation
|
||||
port18789OwnersBefore = @($PortOwners)
|
||||
outputDir = $OutputDir
|
||||
backupRoot = $BackupRoot
|
||||
}
|
||||
|
||||
Add-ResetStep "target-summary" "Completed" "Captured OpenClaw-owned reset targets." @{
|
||||
processCount = @($Processes).Count
|
||||
distroExists = [bool]$script:result.targets.distroExists
|
||||
appDataExists = [bool]$script:result.targets.appDataExists
|
||||
localAppDataExists = [bool]$script:result.targets.localAppDataExists
|
||||
installLocationExists = [bool]$script:result.targets.installLocationExists
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-CleanPostCondition {
|
||||
param(
|
||||
[string]$AppDataPath,
|
||||
[string]$LocalAppDataPath,
|
||||
[string]$InstallLocationPath
|
||||
)
|
||||
|
||||
if ($result.dryRun) {
|
||||
Add-ResetStep "postconditions" "Skipped" "Postconditions are skipped during dry-run."
|
||||
return
|
||||
}
|
||||
|
||||
$remainingProcesses = @(Get-OpenClawProcesses)
|
||||
if (-not $KeepRunningProcesses -and $remainingProcesses.Count -gt 0) {
|
||||
throw "OpenClaw processes are still running after reset: $(@($remainingProcesses | ForEach-Object { $_.Id }) -join ', ')"
|
||||
}
|
||||
|
||||
$remainingDistros = @(Get-WslDistros)
|
||||
if ($remainingDistros -contains $script:OpenClawDistroName) {
|
||||
throw "WSL distro '$($script:OpenClawDistroName)' is still registered after reset."
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $AppDataPath) {
|
||||
throw "AppData path still exists after reset: $AppDataPath"
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $LocalAppDataPath) {
|
||||
throw "LocalAppData path still exists after reset: $LocalAppDataPath"
|
||||
}
|
||||
|
||||
if ($CleanInstallLocation -and -not [string]::IsNullOrWhiteSpace($InstallLocationPath) -and (Test-Path -LiteralPath $InstallLocationPath)) {
|
||||
throw "Install location still exists after reset: $InstallLocationPath"
|
||||
}
|
||||
|
||||
$wslListAfterPath = Join-Path $OutputDir "wsl-list-after.txt"
|
||||
& wsl.exe --list --verbose > $wslListAfterPath 2>&1
|
||||
$script:result.targets.port18789OwnersAfter = @(Get-PortOwnerSnapshot -Label "after")
|
||||
Add-ResetStep "postconditions" "Passed" "OpenClaw-owned state reset postconditions passed." @{
|
||||
wslListAfter = $wslListAfterPath
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
|
||||
|
||||
try {
|
||||
Assert-DestructiveTargetIsAllowed
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($AppDataRoot)) {
|
||||
$AppDataRoot = $env:APPDATA
|
||||
$result.appDataRoot = $AppDataRoot
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($LocalAppDataRoot)) {
|
||||
$LocalAppDataRoot = $env:LOCALAPPDATA
|
||||
$result.localAppDataRoot = $LocalAppDataRoot
|
||||
}
|
||||
|
||||
$appData = Join-Path $AppDataRoot "OpenClawTray"
|
||||
$localAppData = Join-Path $LocalAppDataRoot "OpenClawTray"
|
||||
$processes = @(Get-OpenClawProcesses)
|
||||
$distros = @(Get-WslDistros)
|
||||
$portOwnersBefore = @(Get-PortOwnerSnapshot -Label "before")
|
||||
Add-TargetSummary -Processes $processes -Distros $distros -AppDataPath $appData -LocalAppDataPath $localAppData -InstallLocationPath $InstallLocation -PortOwners $portOwnersBefore
|
||||
|
||||
if ($result.dryRun) {
|
||||
Add-ResetStep "mode" "DryRun" "No state will be changed. Pass -ConfirmDestructiveClean to reset OpenClaw-owned state."
|
||||
Write-Host "DRY-RUN: pass -ConfirmDestructiveClean to actually reset OpenClaw-owned state."
|
||||
}
|
||||
else {
|
||||
Add-ResetStep "mode" "Confirmed" "OpenClaw-owned state reset is enabled for this run."
|
||||
Write-Host "Backups will be written under: $BackupRoot"
|
||||
}
|
||||
|
||||
if ($processes.Count -eq 0) {
|
||||
Add-ResetStep "stop-openclaw-processes" "Skipped" "No OpenClaw processes are running."
|
||||
}
|
||||
elseif ($KeepRunningProcesses) {
|
||||
Add-ResetStep "stop-openclaw-processes" "Skipped" "Keeping running OpenClaw processes because -KeepRunningProcesses was set." @{
|
||||
pids = @($processes | ForEach-Object { $_.Id })
|
||||
}
|
||||
}
|
||||
elseif ($result.dryRun) {
|
||||
Add-ResetStep "stop-openclaw-processes" "DryRun" "Would stop running OpenClaw processes by PID." @{
|
||||
pids = @($processes | ForEach-Object { $_.Id })
|
||||
}
|
||||
}
|
||||
else {
|
||||
foreach ($process in $processes) {
|
||||
Stop-Process -Id $process.Id -Force
|
||||
}
|
||||
Add-ResetStep "stop-openclaw-processes" "Completed" "Stopped running OpenClaw processes by PID." @{
|
||||
pids = @($processes | ForEach-Object { $_.Id })
|
||||
}
|
||||
}
|
||||
|
||||
$hasGatewayDistro = $distros -contains $script:OpenClawDistroName
|
||||
$wslListPath = Join-Path $OutputDir "wsl-list-before.txt"
|
||||
& wsl.exe --list --verbose > $wslListPath 2>&1
|
||||
Add-ResetStep "capture-wsl-list" "Completed" "Captured WSL distro list." @{ path = $wslListPath }
|
||||
|
||||
if (-not $hasGatewayDistro) {
|
||||
Add-ResetStep "unregister-$($script:OpenClawDistroName)" "Skipped" "WSL distro '$($script:OpenClawDistroName)' is not registered."
|
||||
}
|
||||
elseif ($result.dryRun) {
|
||||
Add-ResetStep "unregister-$($script:OpenClawDistroName)" "DryRun" "Would terminate and unregister only the '$($script:OpenClawDistroName)' WSL distro." @{ distroName = $script:OpenClawDistroName }
|
||||
}
|
||||
else {
|
||||
# Exact-target only: --terminate <name>, never --shutdown.
|
||||
Invoke-CapturedCommand "wsl-terminate-$($script:OpenClawDistroName)" "wsl.exe" @("--terminate", $script:OpenClawDistroName) -IgnoreExitCode
|
||||
Invoke-CapturedCommand "wsl-unregister-$($script:OpenClawDistroName)" "wsl.exe" @("--unregister", $script:OpenClawDistroName)
|
||||
}
|
||||
|
||||
Backup-Directory -Path $appData -Label "appdata"
|
||||
Backup-Directory -Path $localAppData -Label "localappdata"
|
||||
if ($CleanInstallLocation) {
|
||||
if ([string]::IsNullOrWhiteSpace($InstallLocation)) {
|
||||
Add-ResetStep "backup-install-location" "Skipped" "No install location was supplied."
|
||||
}
|
||||
else {
|
||||
Backup-Directory -Path $InstallLocation -Label "install-location"
|
||||
}
|
||||
}
|
||||
else {
|
||||
Add-ResetStep "backup-install-location" "Skipped" "Install location cleanup was not requested."
|
||||
}
|
||||
Assert-CleanPostCondition -AppDataPath $appData -LocalAppDataPath $localAppData -InstallLocationPath $InstallLocation
|
||||
|
||||
$result.finishedAt = (Get-Date).ToString("o")
|
||||
$summaryPath = Join-Path $OutputDir "reset-summary.json"
|
||||
$result | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $summaryPath -Encoding UTF8
|
||||
if ($PassThruJson) {
|
||||
$result | ConvertTo-Json -Depth 10
|
||||
}
|
||||
else {
|
||||
Write-Host "Reset summary: $summaryPath"
|
||||
if (-not $result.dryRun) {
|
||||
Write-Host "Backup root: $BackupRoot"
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$result.finishedAt = (Get-Date).ToString("o")
|
||||
Add-ResetStep "reset" "Failed" $_.Exception.Message
|
||||
$summaryPath = Join-Path $OutputDir "reset-summary.json"
|
||||
$result | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $summaryPath -Encoding UTF8
|
||||
Write-Error $_.Exception.Message
|
||||
exit 1
|
||||
}
|
||||
@ -1,941 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Validate the OpenClaw WSL gateway local-setup product code path end-to-end.
|
||||
|
||||
.DESCRIPTION
|
||||
Phase 6 clean port. Drives the WinUI3 tray app from launch through the
|
||||
forked onboarding (SetupWarningPage -> "Set up locally" -> LocalSetupProgressPage)
|
||||
so the *product* code path that runs
|
||||
|
||||
wsl --install Ubuntu-24.04 --name OpenClawGateway --location <path> --no-launch --version 2
|
||||
|
||||
is exercised end-to-end. The script does NOT install WSL itself and does NOT
|
||||
invoke `wsl --install` directly: it expects the tray engine to do that and
|
||||
only verifies the postcondition.
|
||||
|
||||
Networking diagnostics are loopback-only. There is no WSL-IP / lan / auto
|
||||
fallback. Token / setup-code / private-key material is redacted in artifacts.
|
||||
|
||||
.PARAMETER Scenario
|
||||
PreflightOnly - Repo layout + WSL host status + relay probe (safe; no install).
|
||||
UpstreamInstall - Build/test, drive tray onboarding to install OpenClawGateway,
|
||||
run smoke + pairing proofs. Reuses an existing distro if present.
|
||||
FreshMachine - Like UpstreamInstall, but unregisters any existing
|
||||
OpenClawGateway distro first (simulates a clean machine).
|
||||
Recreate - Iterated FreshMachine (unregister between runs). Use `-Iterations`.
|
||||
|
||||
.NOTES
|
||||
Diagnostics on networking/lifecycle health failures point operators at
|
||||
https://aka.ms/wsllogs (per Craig).
|
||||
|
||||
File I/O against WSL is via `wsl bash -c` only. NEVER \\wsl$ / \\wsl.localhost.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet("PreflightOnly", "UpstreamInstall", "FreshMachine", "Recreate")]
|
||||
[string]$Scenario = "PreflightOnly",
|
||||
[string]$OutputDir = (Join-Path (Get-Location) "artifacts\wsl-gateway-validation"),
|
||||
[int]$Iterations = 1,
|
||||
[switch]$ConfirmDestructiveClean,
|
||||
[switch]$KeepFailedDistro,
|
||||
[bool]$CleanupAfterSuccess = $true,
|
||||
[switch]$ContinueOnCleanupFailure,
|
||||
[switch]$NoBuild,
|
||||
[int]$TimeoutSeconds = 600,
|
||||
[string]$DistroName = "OpenClawGateway",
|
||||
[string]$GatewayUrl = "ws://127.0.0.1:18789",
|
||||
[string]$RelayProbeUri,
|
||||
[switch]$RequireRelayProbe,
|
||||
[switch]$RequireRealGatewayBootstrap,
|
||||
[switch]$RequireOperatorPairing,
|
||||
[switch]$RequireWindowsNodePairing,
|
||||
[switch]$ContinueOnFailure
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
|
||||
$runStamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
$runRoot = Join-Path $OutputDir $runStamp
|
||||
$commandsRoot = Join-Path $runRoot "commands"
|
||||
$screenshotsRoot = Join-Path $runRoot "screenshots"
|
||||
$summaryPath = Join-Path $runRoot "summary.json"
|
||||
$summaryMarkdownPath = Join-Path $runRoot "summary.md"
|
||||
$trayProject = Join-Path $repoRoot "src\OpenClaw.Tray.WinUI\OpenClaw.Tray.WinUI.csproj"
|
||||
$runtimeIdentifier = if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { "win-arm64" } else { "win-x64" }
|
||||
$trayExe = Join-Path $repoRoot "src\OpenClaw.Tray.WinUI\bin\Debug\net10.0-windows10.0.19041.0\$runtimeIdentifier\OpenClaw.Tray.WinUI.exe"
|
||||
$cliProject = Join-Path $repoRoot "src\OpenClaw.Cli\OpenClaw.Cli.csproj"
|
||||
|
||||
# Always isolate AppData under run root for non-Preflight scenarios so we never
|
||||
# trample the operator's real Windows tray identity.
|
||||
$validationAppDataRoot = if ($Scenario -eq "PreflightOnly") { $env:APPDATA } else { Join-Path $runRoot "isolated\appdata" }
|
||||
$validationLocalAppDataRoot = if ($Scenario -eq "PreflightOnly") { $env:LOCALAPPDATA } else { Join-Path $runRoot "isolated\localappdata" }
|
||||
$setupStatePath = Join-Path $validationLocalAppDataRoot "OpenClawTray\setup-state.json"
|
||||
$settingsPath = Join-Path $validationAppDataRoot "settings.json"
|
||||
$wslInstallLocation = Join-Path $runRoot "wsl\$DistroName"
|
||||
|
||||
$script:summary = [ordered]@{
|
||||
script = "validate-wsl-gateway"
|
||||
scenario = $Scenario
|
||||
startedAt = (Get-Date).ToString("o")
|
||||
finishedAt = $null
|
||||
status = "Running"
|
||||
validationStatus = "Running"
|
||||
cleanupStatus = "NotStarted"
|
||||
repository = $repoRoot.Path
|
||||
outputDir = $runRoot
|
||||
networkingMode = "LocalhostOnly"
|
||||
activeDistroName = $DistroName
|
||||
activeInstallLocation = $wslInstallLocation
|
||||
selectedGatewayUrl = $GatewayUrl
|
||||
pairingValidation = [ordered]@{
|
||||
gatewayImplementation = "Unknown"
|
||||
bootstrapQrShape = "Unknown"
|
||||
realUpstreamBootstrapHandoff = $false
|
||||
operatorPaired = $false
|
||||
windowsNodePaired = $false
|
||||
}
|
||||
setupPhases = @()
|
||||
iterations = @()
|
||||
steps = @()
|
||||
error = $null
|
||||
}
|
||||
|
||||
function Add-Step {
|
||||
param([string]$Name, [string]$Status, [string]$Message, [hashtable]$Data = @{})
|
||||
$script:summary.steps += [ordered]@{
|
||||
name = $Name
|
||||
status = $Status
|
||||
message = $Message
|
||||
data = $Data
|
||||
timestamp = (Get-Date).ToString("o")
|
||||
}
|
||||
}
|
||||
|
||||
function Test-IsOpenClawOwnedDistroName {
|
||||
param([string]$Name)
|
||||
return $Name -eq "OpenClawGateway" -or $Name.StartsWith("OpenClawGateway", [System.StringComparison]::Ordinal)
|
||||
}
|
||||
|
||||
function Assert-DestructiveSafety {
|
||||
if ($Scenario -in @("FreshMachine", "Recreate") -and -not $ConfirmDestructiveClean) {
|
||||
throw "-ConfirmDestructiveClean is required when -Scenario is $Scenario (will unregister WSL distro '$DistroName')."
|
||||
}
|
||||
if ($Scenario -in @("FreshMachine", "Recreate") -and -not (Test-IsOpenClawOwnedDistroName -Name $DistroName)) {
|
||||
throw "Refusing destructive action for non-OpenClaw distro '$DistroName'. Distro name must start with 'OpenClawGateway'."
|
||||
}
|
||||
}
|
||||
|
||||
function Get-SafeUriDisplay {
|
||||
param([string]$Uri)
|
||||
try {
|
||||
$b = [System.UriBuilder]::new($Uri)
|
||||
$b.Query = $null; $b.Fragment = $null
|
||||
return $b.Uri.AbsoluteUri
|
||||
} catch {
|
||||
return "<invalid-uri>"
|
||||
}
|
||||
}
|
||||
|
||||
function Write-Summary {
|
||||
New-Item -ItemType Directory -Force -Path $runRoot | Out-Null
|
||||
$script:summary.finishedAt = (Get-Date).ToString("o")
|
||||
$script:summary | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $summaryPath -Encoding UTF8
|
||||
|
||||
$lines = @(
|
||||
"# OpenClaw WSL gateway validation",
|
||||
"",
|
||||
"- Scenario: $Scenario",
|
||||
"- Status: $($script:summary.status)",
|
||||
"- Validation: $($script:summary.validationStatus)",
|
||||
"- Cleanup: $($script:summary.cleanupStatus)",
|
||||
"- Networking mode: LocalhostOnly (loopback only)",
|
||||
"- Started: $($script:summary.startedAt)",
|
||||
"- Finished: $($script:summary.finishedAt)",
|
||||
"- Output: $runRoot",
|
||||
"",
|
||||
"## Steps"
|
||||
)
|
||||
foreach ($step in $script:summary.steps) {
|
||||
$lines += "- $($step.status): $($step.name) - $($step.message)"
|
||||
}
|
||||
if ($script:summary.error) {
|
||||
$lines += "", "## Error", $script:summary.error
|
||||
$lines += "", "Diagnostics: see https://aka.ms/wsllogs for WSL networking/lifecycle logs."
|
||||
}
|
||||
$lines | Set-Content -LiteralPath $summaryMarkdownPath -Encoding UTF8
|
||||
}
|
||||
|
||||
function Redact-SensitiveGatewayOutput {
|
||||
param([string]$Content)
|
||||
if ([string]::IsNullOrEmpty($Content)) { return $Content }
|
||||
$r = $Content -replace '("(?:bootstrapToken|bootstrap_token|deviceToken|device_token|token|setupCode|setup_code|PrivateKeyBase64|PublicKeyBase64)"\s*:\s*")[^"]+(")', '$1<redacted>$2'
|
||||
$r = $r -replace '(?i)((?:bootstrap|device|gateway|auth)[_-]?token\s*[:=]\s*)[^\s,"''}]+', '$1<redacted>'
|
||||
return $r
|
||||
}
|
||||
|
||||
function Read-TextFileWithRetry {
|
||||
param([string]$Path, [int]$Attempts = 10, [int]$DelayMilliseconds = 200)
|
||||
for ($i = 1; $i -le $Attempts; $i++) {
|
||||
try { return Get-Content -LiteralPath $Path -Raw -ErrorAction Stop }
|
||||
catch [System.IO.IOException] { if ($i -eq $Attempts) { throw } ; Start-Sleep -Milliseconds $DelayMilliseconds }
|
||||
}
|
||||
}
|
||||
|
||||
function Write-TextFileWithRetry {
|
||||
param([string]$Path, [string]$Content, [int]$Attempts = 10, [int]$DelayMilliseconds = 200)
|
||||
for ($i = 1; $i -le $Attempts; $i++) {
|
||||
try { $Content | Set-Content -LiteralPath $Path -Encoding UTF8 -ErrorAction Stop ; return }
|
||||
catch [System.IO.IOException] { if ($i -eq $Attempts) { throw } ; Start-Sleep -Milliseconds $DelayMilliseconds }
|
||||
}
|
||||
}
|
||||
|
||||
function Copy-RedactedFileIfExists {
|
||||
param([string]$SourcePath, [string]$DestinationPath)
|
||||
if (-not (Test-Path -LiteralPath $SourcePath)) { return $false }
|
||||
$content = Read-TextFileWithRetry -Path $SourcePath
|
||||
Write-TextFileWithRetry -Path $DestinationPath -Content (Redact-SensitiveGatewayOutput $content)
|
||||
return $true
|
||||
}
|
||||
|
||||
function Invoke-LoggedProcess {
|
||||
param(
|
||||
[string]$Name,
|
||||
[string]$FilePath,
|
||||
[string[]]$ArgumentList,
|
||||
[string]$WorkingDirectory = $repoRoot.Path,
|
||||
[hashtable]$Environment = @{},
|
||||
[switch]$IgnoreExitCode,
|
||||
[switch]$SensitiveOutput
|
||||
)
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $commandsRoot | Out-Null
|
||||
$safe = $Name -replace "[^a-zA-Z0-9_.-]", "-"
|
||||
$stdout = Join-Path $commandsRoot "$safe.stdout.txt"
|
||||
$stderr = Join-Path $commandsRoot "$safe.stderr.txt"
|
||||
$saved = @{}
|
||||
foreach ($k in $Environment.Keys) {
|
||||
$saved[$k] = [Environment]::GetEnvironmentVariable($k, "Process")
|
||||
[Environment]::SetEnvironmentVariable($k, [string]$Environment[$k], "Process")
|
||||
}
|
||||
Push-Location $WorkingDirectory
|
||||
try {
|
||||
& $FilePath @ArgumentList > $stdout 2> $stderr
|
||||
$exitCode = if ($null -eq $global:LASTEXITCODE) { 0 } else { $global:LASTEXITCODE }
|
||||
} finally {
|
||||
Pop-Location
|
||||
foreach ($k in $Environment.Keys) {
|
||||
[Environment]::SetEnvironmentVariable($k, $saved[$k], "Process")
|
||||
}
|
||||
}
|
||||
|
||||
if ($SensitiveOutput) {
|
||||
foreach ($p in @($stdout, $stderr)) {
|
||||
if (Test-Path -LiteralPath $p) {
|
||||
$c = Read-TextFileWithRetry -Path $p -Attempts 20 -DelayMilliseconds 250
|
||||
Write-TextFileWithRetry -Path $p -Content (Redact-SensitiveGatewayOutput $c) -Attempts 20 -DelayMilliseconds 250
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Add-Step $Name "Completed" "Command completed with exit code $exitCode." @{
|
||||
file = $FilePath; arguments = ($ArgumentList -join " "); exitCode = $exitCode; stdout = $stdout; stderr = $stderr
|
||||
}
|
||||
|
||||
if ($exitCode -ne 0 -and -not $IgnoreExitCode) {
|
||||
throw "$Name failed with exit code $exitCode. See $stdout and $stderr."
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-LoggedPowerShellScript {
|
||||
param([string]$Name, [string]$ScriptPath, [string[]]$ArgumentList = @())
|
||||
$hostExe = if ($PSHOME -and (Test-Path (Join-Path $PSHOME "pwsh.exe"))) { Join-Path $PSHOME "pwsh.exe" } else { "powershell.exe" }
|
||||
$args = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $ScriptPath) + $ArgumentList
|
||||
Invoke-LoggedProcess -Name $Name -FilePath $hostExe -ArgumentList $args
|
||||
}
|
||||
|
||||
function Invoke-RepositoryValidation {
|
||||
if ($NoBuild) {
|
||||
Add-Step "repository-validation" "Skipped" "Skipped build and tests because -NoBuild was set."
|
||||
return
|
||||
}
|
||||
Invoke-LoggedPowerShellScript "build" (Join-Path $repoRoot "build.ps1")
|
||||
Invoke-LoggedProcess "test-shared" "dotnet" @("test", ".\tests\OpenClaw.Shared.Tests\OpenClaw.Shared.Tests.csproj", "--no-restore")
|
||||
Invoke-LoggedProcess "test-tray" "dotnet" @("test", ".\tests\OpenClaw.Tray.Tests\OpenClaw.Tray.Tests.csproj", "--no-restore")
|
||||
}
|
||||
|
||||
function Invoke-Preflight {
|
||||
Invoke-LoggedProcess "dotnet-info" "dotnet" @("--info") -IgnoreExitCode
|
||||
Invoke-LoggedProcess "wsl-status" "wsl.exe" @("--status") -IgnoreExitCode
|
||||
Invoke-LoggedProcess "wsl-list-before" "wsl.exe" @("--list", "--verbose") -IgnoreExitCode
|
||||
|
||||
if (-not (Test-Path -LiteralPath $trayProject)) { throw "Tray project not found: $trayProject" }
|
||||
if (-not (Test-Path -LiteralPath $cliProject)) { throw "CLI project not found: $cliProject" }
|
||||
Add-Step "repo-layout" "Passed" "Required projects are present."
|
||||
|
||||
Invoke-RelayPrototypeProbe
|
||||
}
|
||||
|
||||
function Invoke-RelayPrototypeProbe {
|
||||
$probeUri = if (-not [string]::IsNullOrWhiteSpace($RelayProbeUri)) { $RelayProbeUri } else { [Environment]::GetEnvironmentVariable("OPENCLAW_RELAY_PROBE_URI", "Process") }
|
||||
if ([string]::IsNullOrWhiteSpace($probeUri)) {
|
||||
$msg = "No relay probe endpoint was supplied. Set -RelayProbeUri or OPENCLAW_RELAY_PROBE_URI."
|
||||
if ($RequireRelayProbe) { throw "RelayProbeMissing: $msg" }
|
||||
Add-Step "relay-prototype-probe" "NotAvailable" $msg
|
||||
return
|
||||
}
|
||||
$relayPath = Join-Path $commandsRoot "relay-prototype-probe.txt"
|
||||
New-Item -ItemType Directory -Force -Path $commandsRoot | Out-Null
|
||||
try {
|
||||
$r = Invoke-WebRequest -Uri $probeUri -TimeoutSec 15 -UseBasicParsing
|
||||
$body = if ($null -ne $r.Content) { $r.Content } else { "" }
|
||||
$body = $body -replace '(?i)(token=)[^&\s]+', '$1<redacted>'
|
||||
$body | Set-Content -LiteralPath $relayPath -Encoding UTF8
|
||||
Add-Step "relay-prototype-probe" "Passed" "Relay probe endpoint responded." @{
|
||||
uri = (Get-SafeUriDisplay $probeUri); statusCode = [int]$r.StatusCode; path = $relayPath
|
||||
}
|
||||
} catch {
|
||||
throw "RelayProbeFailed: relay probe failed for $(Get-SafeUriDisplay $probeUri): $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-LatestScreenshotPath {
|
||||
if (-not (Test-Path -LiteralPath $screenshotsRoot)) { return $null }
|
||||
$latest = Get-ChildItem -LiteralPath $screenshotsRoot -Filter "*.png" -File -Recurse |
|
||||
Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||
if ($null -eq $latest) { return $null }
|
||||
return $latest.FullName
|
||||
}
|
||||
|
||||
function Save-DiagnosticsSnapshot {
|
||||
param([string]$Reason)
|
||||
$diag = Join-Path $runRoot "diagnostics"
|
||||
New-Item -ItemType Directory -Force -Path $diag | Out-Null
|
||||
|
||||
if (Test-Path -LiteralPath $setupStatePath) {
|
||||
Copy-RedactedFileIfExists -SourcePath $setupStatePath -DestinationPath (Join-Path $diag "setup-state.redacted.json") | Out-Null
|
||||
}
|
||||
if (Test-Path -LiteralPath $settingsPath) {
|
||||
Copy-RedactedFileIfExists -SourcePath $settingsPath -DestinationPath (Join-Path $diag "settings.redacted.json") | Out-Null
|
||||
}
|
||||
$identityPath = Join-Path $validationAppDataRoot "OpenClawTray\device-key-ed25519.json"
|
||||
if (Test-Path -LiteralPath $identityPath) {
|
||||
Copy-RedactedFileIfExists -SourcePath $identityPath -DestinationPath (Join-Path $diag "device-key.shape.redacted.json") | Out-Null
|
||||
}
|
||||
|
||||
Add-Step "diagnostics-snapshot" "Completed" "Saved diagnostics snapshot for $Reason. See https://aka.ms/wsllogs for WSL networking/lifecycle logs." @{
|
||||
path = $diag
|
||||
latestScreenshot = (Get-LatestScreenshotPath)
|
||||
wslLogsHelp = "https://aka.ms/wsllogs"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ValidationAppEnvironment {
|
||||
return @{
|
||||
OPENCLAW_TRAY_DATA_DIR = $validationAppDataRoot
|
||||
OPENCLAW_TRAY_APPDATA_DIR = $validationAppDataRoot
|
||||
OPENCLAW_TRAY_LOCALAPPDATA_DIR = $validationLocalAppDataRoot
|
||||
}
|
||||
}
|
||||
|
||||
function Convert-SetupStatus {
|
||||
param([object]$Status)
|
||||
$v = [string]$Status
|
||||
if ($v -match '^\d+$') {
|
||||
# Aligned with LocalGatewaySetupStatus enum
|
||||
$names = @("Pending", "Running", "RequiresAdmin", "RequiresRestart", "Blocked",
|
||||
"FailedRetryable", "FailedTerminal", "Complete", "Cancelled")
|
||||
$i = [int]$v
|
||||
if ($i -ge 0 -and $i -lt $names.Count) { return $names[$i] }
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
function Convert-SetupPhase {
|
||||
param([object]$Phase)
|
||||
$v = [string]$Phase
|
||||
if ($v -match '^\d+$') {
|
||||
# Aligned with the clean LocalGatewaySetupPhase enum (worker / rootfs phases removed).
|
||||
$names = @(
|
||||
"NotStarted", "Preflight", "ElevationCheck",
|
||||
"EnsureWslEnabled", "CreateWslInstance", "ConfigureWslInstance",
|
||||
"InstallOpenClawCli", "PrepareGatewayConfig", "InstallGatewayService",
|
||||
"StartGateway", "WaitForGateway",
|
||||
"MintBootstrapToken", "PairOperator",
|
||||
"CheckWindowsNodeReadiness", "PairWindowsTrayNode",
|
||||
"VerifyEndToEnd", "Complete", "Failed", "Cancelled"
|
||||
)
|
||||
$i = [int]$v
|
||||
if ($i -ge 0 -and $i -lt $names.Count) { return $names[$i] }
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
function Wait-ForUiAutomationElement {
|
||||
param([string]$AutomationId, [int]$TimeoutSeconds)
|
||||
Add-Type -AssemblyName UIAutomationClient
|
||||
Add-Type -AssemblyName UIAutomationTypes
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||
$cond = New-Object System.Windows.Automation.PropertyCondition(
|
||||
[System.Windows.Automation.AutomationElement]::AutomationIdProperty, $AutomationId)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
$el = [System.Windows.Automation.AutomationElement]::RootElement.FindFirst(
|
||||
[System.Windows.Automation.TreeScope]::Descendants, $cond)
|
||||
if ($null -ne $el) { return $el }
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Invoke-UiAutomationClick {
|
||||
param([string]$AutomationId, [int]$TimeoutSeconds)
|
||||
$el = Wait-ForUiAutomationElement -AutomationId $AutomationId -TimeoutSeconds $TimeoutSeconds
|
||||
if ($null -ne $el) {
|
||||
$p = $el.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern)
|
||||
$p.Invoke()
|
||||
Add-Step "ui-click-$AutomationId" "Completed" "Clicked UI element with AutomationId '$AutomationId'."
|
||||
return
|
||||
}
|
||||
Save-DiagnosticsSnapshot -Reason "missing-ui-target-$AutomationId"
|
||||
throw "UI element with AutomationId '$AutomationId' was not found within $TimeoutSeconds seconds."
|
||||
}
|
||||
|
||||
function Stop-ExistingTrayProcesses {
|
||||
param([string]$Reason)
|
||||
$repoPrefix = [string]$repoRoot.Path
|
||||
$procs = Get-Process -Name "OpenClaw.Tray.WinUI" -ErrorAction SilentlyContinue |
|
||||
Where-Object {
|
||||
try { -not [string]::IsNullOrWhiteSpace($_.Path) -and $_.Path.StartsWith($repoPrefix, [System.StringComparison]::OrdinalIgnoreCase) }
|
||||
catch { $false }
|
||||
}
|
||||
foreach ($p in $procs) {
|
||||
$procId = $p.Id
|
||||
try {
|
||||
Stop-Process -Id $procId -Force -ErrorAction Stop
|
||||
Add-Step "stop-existing-tray" "Completed" "Stopped existing repo tray process by PID before validation." @{ pid = $procId; reason = $Reason }
|
||||
} catch [Microsoft.PowerShell.Commands.ProcessCommandException] {
|
||||
Add-Step "stop-existing-tray" "Skipped" "Repo tray process had already exited before cleanup." @{ pid = $procId; reason = $Reason }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Stop-WslKeepAliveProcesses {
|
||||
$target = $DistroName
|
||||
$procs = Get-CimInstance Win32_Process -Filter "Name = 'wsl.exe'" -ErrorAction SilentlyContinue |
|
||||
Where-Object {
|
||||
$_.CommandLine -and
|
||||
$_.CommandLine.Contains($target, [System.StringComparison]::OrdinalIgnoreCase) -and
|
||||
$_.CommandLine.Contains("sleep", [System.StringComparison]::OrdinalIgnoreCase) -and
|
||||
$_.CommandLine.Contains("2147483647", [System.StringComparison]::OrdinalIgnoreCase)
|
||||
}
|
||||
foreach ($p in $procs) {
|
||||
try {
|
||||
Stop-Process -Id $p.ProcessId -Force -ErrorAction Stop
|
||||
Add-Step "stop-wsl-keepalive" "Completed" "Stopped $target keepalive process by PID." @{ pid = $p.ProcessId; distroName = $target }
|
||||
} catch [Microsoft.PowerShell.Commands.ProcessCommandException] {
|
||||
Add-Step "stop-wsl-keepalive" "Skipped" "$target keepalive process had already exited." @{ pid = $p.ProcessId; distroName = $target }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Start-TrayForLocalSetup {
|
||||
Stop-ExistingTrayProcesses -Reason "pre-launch"
|
||||
|
||||
# Forked onboarding entry point is SetupWarning by default; we just force
|
||||
# onboarding mode and let the script click "Set up locally".
|
||||
$env = @{
|
||||
OPENCLAW_SKIP_UPDATE_CHECK = "1"
|
||||
OPENCLAW_FORCE_ONBOARDING = "1"
|
||||
OPENCLAW_WSL_DISTRO_NAME = $DistroName
|
||||
OPENCLAW_WSL_INSTALL_LOCATION = $wslInstallLocation
|
||||
OPENCLAW_WSL_ALLOW_EXISTING_DISTRO = if ($Scenario -eq "UpstreamInstall") { "1" } else { "0" }
|
||||
OPENCLAW_TRAY_DATA_DIR = $validationAppDataRoot
|
||||
OPENCLAW_TRAY_APPDATA_DIR = $validationAppDataRoot
|
||||
OPENCLAW_TRAY_LOCALAPPDATA_DIR = $validationLocalAppDataRoot
|
||||
OPENCLAW_VISUAL_TEST = "1"
|
||||
OPENCLAW_VISUAL_TEST_DIR = $screenshotsRoot
|
||||
}
|
||||
|
||||
$saved = @{}
|
||||
foreach ($k in $env.Keys) {
|
||||
$saved[$k] = [Environment]::GetEnvironmentVariable($k, "Process")
|
||||
[Environment]::SetEnvironmentVariable($k, [string]$env[$k], "Process")
|
||||
}
|
||||
|
||||
try {
|
||||
New-Item -ItemType Directory -Force -Path $screenshotsRoot | Out-Null
|
||||
if (-not (Test-Path -LiteralPath $trayExe)) {
|
||||
throw "Built tray executable not found at $trayExe. Run build.ps1 first or omit -NoBuild."
|
||||
}
|
||||
$proc = Start-Process -FilePath $trayExe -WorkingDirectory $repoRoot -PassThru
|
||||
Add-Step "launch-tray" "Completed" "Launched tray onboarding for WSL local setup." @{
|
||||
pid = $proc.Id; screenshots = $screenshotsRoot; file = $trayExe; runtimeIdentifier = $runtimeIdentifier
|
||||
}
|
||||
return $proc
|
||||
} finally {
|
||||
foreach ($k in $env.Keys) {
|
||||
[Environment]::SetEnvironmentVariable($k, $saved[$k], "Process")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Wait-ForSetupCompletion {
|
||||
param([int]$TimeoutSeconds)
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||
$lastPhase = ""; $lastStatus = ""
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
if (Test-Path -LiteralPath $setupStatePath) {
|
||||
$text = Read-TextFileWithRetry -Path $setupStatePath
|
||||
$state = $text | ConvertFrom-Json
|
||||
$copy = Join-Path $runRoot "setup-state.json"
|
||||
$text | Set-Content -LiteralPath $copy -Encoding UTF8
|
||||
|
||||
$phase = Convert-SetupPhase $state.Phase
|
||||
$status = Convert-SetupStatus $state.Status
|
||||
if ($phase -ne $lastPhase -or $status -ne $lastStatus) {
|
||||
$lastPhase = $phase; $lastStatus = $status
|
||||
$script:summary.setupPhases += [ordered]@{
|
||||
phase = $phase; status = $status; message = [string]$state.UserMessage; timestamp = (Get-Date).ToString("o")
|
||||
}
|
||||
Add-Step "setup-phase-$phase" $status ([string]$state.UserMessage) @{ phase = $phase; status = $status }
|
||||
}
|
||||
|
||||
if ($status -eq "Complete") {
|
||||
if ($state.PSObject.Properties.Name -contains "GatewayUrl" -and -not [string]::IsNullOrWhiteSpace([string]$state.GatewayUrl)) {
|
||||
$script:GatewayUrl = [string]$state.GatewayUrl
|
||||
$script:summary.selectedGatewayUrl = $script:GatewayUrl
|
||||
}
|
||||
Add-Step "setup-state" "Passed" "Setup reached $status." @{
|
||||
status = $status; phase = $phase; path = $copy
|
||||
gatewayUrl = (Get-SafeUriDisplay $script:GatewayUrl)
|
||||
}
|
||||
return
|
||||
}
|
||||
if ($status -in @("FailedRetryable", "FailedTerminal", "Blocked", "Cancelled")) {
|
||||
Save-DiagnosticsSnapshot -Reason "setup-failed-$phase"
|
||||
throw "Setup failed with status $status, phase $phase, code $($state.FailureCode): $($state.UserMessage). Diagnostics: https://aka.ms/wsllogs."
|
||||
}
|
||||
}
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
Save-DiagnosticsSnapshot -Reason "setup-timeout"
|
||||
throw "Setup did not reach Complete within $TimeoutSeconds seconds. Diagnostics: https://aka.ms/wsllogs."
|
||||
}
|
||||
|
||||
function Invoke-TrayLocalSetup {
|
||||
$proc = Start-TrayForLocalSetup
|
||||
Start-Sleep -Seconds 5
|
||||
|
||||
# SetupWarningPage hosts the "Set up locally" primary button.
|
||||
if ($null -eq (Wait-ForUiAutomationElement -AutomationId "OnboardingSetupLocal" -TimeoutSeconds 60)) {
|
||||
Save-DiagnosticsSnapshot -Reason "setup-local-button-not-found"
|
||||
throw "UI automation target OnboardingSetupLocal was not found on SetupWarningPage."
|
||||
}
|
||||
Invoke-UiAutomationClick -AutomationId "OnboardingSetupLocal" -TimeoutSeconds 5
|
||||
|
||||
# LocalSetupProgressPage starts the engine on appearance; just wait for state.
|
||||
Wait-ForSetupCompletion -TimeoutSeconds $TimeoutSeconds
|
||||
return $proc
|
||||
}
|
||||
|
||||
function Stop-TrayProcess {
|
||||
param([object]$Process)
|
||||
if ($null -ne $Process) {
|
||||
$procId = $Process.Id
|
||||
$live = Get-Process -Id $procId -ErrorAction SilentlyContinue
|
||||
if ($null -ne $live) {
|
||||
Stop-Process -Id $procId -Force
|
||||
Add-Step "stop-tray" "Completed" "Stopped tray process by PID after setup validation." @{ pid = $procId }
|
||||
} else {
|
||||
Add-Step "stop-tray" "Skipped" "Tray process had already exited before cleanup." @{ pid = $procId }
|
||||
}
|
||||
}
|
||||
Stop-ExistingTrayProcesses -Reason "post-validation"
|
||||
Stop-WslKeepAliveProcesses
|
||||
}
|
||||
|
||||
function Convert-GatewayUrlToHealthUri {
|
||||
param([string]$Url)
|
||||
$b = [System.UriBuilder]::new($Url)
|
||||
if ($b.Scheme -eq "ws") { $b.Scheme = "http" }
|
||||
elseif ($b.Scheme -eq "wss") { $b.Scheme = "https" }
|
||||
$b.Path = ($b.Path.TrimEnd("/") + "/health")
|
||||
return $b.Uri.AbsoluteUri
|
||||
}
|
||||
|
||||
function Save-LoopbackNetworkDiagnostics {
|
||||
param([string]$Reason)
|
||||
# Loopback only - no WSL IP, no `hostname -I`, no lan probes.
|
||||
$safe = $Reason -replace "[^a-zA-Z0-9_.-]", "-"
|
||||
$tcpPath = Join-Path $commandsRoot "network-$safe-windows-tcp-18789.json"
|
||||
try {
|
||||
$cs = @(Get-NetTCPConnection -LocalPort 18789 -ErrorAction Stop | ForEach-Object {
|
||||
[ordered]@{
|
||||
localAddress = $_.LocalAddress; localPort = $_.LocalPort
|
||||
state = $_.State.ToString(); owningProcess = $_.OwningProcess
|
||||
}
|
||||
})
|
||||
$cs | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $tcpPath -Encoding UTF8
|
||||
Add-Step "network-$safe-windows-tcp" "Completed" "Captured Windows TCP listener state for loopback gateway port." @{ path = $tcpPath }
|
||||
} catch {
|
||||
$_.Exception.Message | Set-Content -LiteralPath $tcpPath -Encoding UTF8
|
||||
Add-Step "network-$safe-windows-tcp" "Skipped" "Could not capture Windows TCP listener state. See https://aka.ms/wsllogs." @{ path = $tcpPath }
|
||||
}
|
||||
}
|
||||
|
||||
function Save-RedactedSettings {
|
||||
if (-not (Test-Path -LiteralPath $settingsPath)) {
|
||||
Add-Step "settings-redacted" "Skipped" "Tray settings file was not found."
|
||||
return
|
||||
}
|
||||
$copy = Join-Path $runRoot "settings.redacted.json"
|
||||
$c = Read-TextFileWithRetry -Path $settingsPath
|
||||
$c = $c -replace '("(?:Token|token|GatewayToken|BootstrapToken|bootstrapToken|bootstrap_token|NodeToken|nodeToken)"\s*:\s*")[^"]*(")', '$1<redacted>$2'
|
||||
$c | Set-Content -LiteralPath $copy -Encoding UTF8
|
||||
Add-Step "settings-redacted" "Completed" "Saved redacted tray settings." @{ path = $copy }
|
||||
}
|
||||
|
||||
function Test-SetupHistoryPhase {
|
||||
param([string]$Phase)
|
||||
if (-not (Test-Path -LiteralPath $setupStatePath)) { return $false }
|
||||
$state = Read-TextFileWithRetry -Path $setupStatePath | ConvertFrom-Json
|
||||
if (-not ($state.PSObject.Properties.Name -contains "History")) { return $false }
|
||||
foreach ($e in @($state.History)) {
|
||||
if ((Convert-SetupPhase $e.Phase) -eq $Phase -and (Convert-SetupStatus $e.Status) -in @("Running", "Complete")) {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
return (Convert-SetupPhase $state.Phase) -eq $Phase
|
||||
}
|
||||
|
||||
function Save-RedactedDeviceIdentityShape {
|
||||
$idp = Join-Path $validationAppDataRoot "OpenClawTray\device-key-ed25519.json"
|
||||
if (-not (Test-Path -LiteralPath $idp)) {
|
||||
Add-Step "device-identity" "Failed" "Device identity file was not found." @{ path = $idp }
|
||||
return $false
|
||||
}
|
||||
$copy = Join-Path $runRoot "device-key.shape.redacted.json"
|
||||
Copy-RedactedFileIfExists -SourcePath $idp -DestinationPath $copy | Out-Null
|
||||
try {
|
||||
$id = Get-Content -LiteralPath $idp -Raw | ConvertFrom-Json
|
||||
$hasOperatorToken = ($id.PSObject.Properties.Name -contains "DeviceToken" -and -not [string]::IsNullOrWhiteSpace([string]$id.DeviceToken)) -or
|
||||
($id.PSObject.Properties.Name -contains "OperatorDeviceToken" -and -not [string]::IsNullOrWhiteSpace([string]$id.OperatorDeviceToken))
|
||||
Add-Step "device-identity" ($(if ($hasOperatorToken) { "Passed" } else { "Failed" })) "Checked stored device identity token shape." @{
|
||||
path = $copy; hasOperatorToken = $hasOperatorToken
|
||||
}
|
||||
return $hasOperatorToken
|
||||
} catch {
|
||||
Add-Step "device-identity" "Failed" "Device identity JSON could not be parsed." @{ path = $copy }
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Test-JsonStringProperty {
|
||||
param([object]$Json, [string[]]$Names)
|
||||
foreach ($n in $Names) {
|
||||
if ($Json.PSObject.Properties.Name -contains $n) {
|
||||
$v = [string]$Json.$n
|
||||
if (-not [string]::IsNullOrWhiteSpace($v)) { return $true }
|
||||
}
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Get-JsonStringProperty {
|
||||
param([object]$Json, [string]$Name)
|
||||
if ($Json -and $Json.PSObject.Properties.Name -contains $Name) { return [string]$Json.$Name }
|
||||
return ""
|
||||
}
|
||||
|
||||
function Invoke-BootstrapHandoffProbe {
|
||||
# Real upstream setup-code / bootstrap proof.
|
||||
$stdout = Join-Path $commandsRoot "wsl-bootstrap-token.stdout.txt"
|
||||
$stderr = Join-Path $commandsRoot "wsl-bootstrap-token.stderr.txt"
|
||||
$args = @("-d", $DistroName, "--", "/opt/openclaw/bin/openclaw", "qr", "--json", "--url", $GatewayUrl)
|
||||
& wsl.exe @args > $stdout 2> $stderr
|
||||
$exitCode = if ($null -eq $global:LASTEXITCODE) { 0 } else { $global:LASTEXITCODE }
|
||||
$raw = if (Test-Path -LiteralPath $stdout) { Read-TextFileWithRetry -Path $stdout -Attempts 20 -DelayMilliseconds 250 } else { "" }
|
||||
Write-TextFileWithRetry -Path $stdout -Content (Redact-SensitiveGatewayOutput $raw) -Attempts 20 -DelayMilliseconds 250
|
||||
|
||||
if ($exitCode -ne 0) {
|
||||
Add-Step "wsl-bootstrap-token" "Failed" "Gateway QR command failed with exit code $exitCode." @{
|
||||
arguments = ($args -join " "); exitCode = $exitCode; stdout = $stdout; stderr = $stderr
|
||||
}
|
||||
throw "BootstrapTokenCommandFailed: openclaw qr --json failed. See $stdout and $stderr."
|
||||
}
|
||||
|
||||
$hasSetupCode = $false; $hasDirectToken = $false
|
||||
try {
|
||||
$qr = $raw | ConvertFrom-Json
|
||||
$hasSetupCode = Test-JsonStringProperty $qr @("setupCode", "setup_code")
|
||||
$hasDirectToken = Test-JsonStringProperty $qr @("bootstrapToken", "bootstrap_token", "token")
|
||||
} catch {
|
||||
throw "BootstrapTokenJsonInvalid: openclaw qr --json did not produce valid JSON: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
$shape = if ($hasSetupCode) { "UpstreamSetupCode" } elseif ($hasDirectToken) { "DirectBootstrapToken" } else { "Unknown" }
|
||||
$script:summary.pairingValidation["bootstrapQrShape"] = $shape
|
||||
$script:summary.pairingValidation["realUpstreamBootstrapHandoff"] = $hasSetupCode
|
||||
|
||||
Add-Step "wsl-bootstrap-token" "Completed" "Gateway QR command completed; bootstrap shape is $shape." @{
|
||||
arguments = ($args -join " "); exitCode = $exitCode; stdout = $stdout; stderr = $stderr; bootstrapQrShape = $shape; realUpstreamBootstrapHandoff = $hasSetupCode
|
||||
}
|
||||
|
||||
if ($RequireRealGatewayBootstrap -and -not $hasSetupCode) {
|
||||
throw "RealGatewayBootstrapRequired: expected upstream setupCode bootstrap handoff, but openclaw qr --json returned $shape."
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-OperatorPairingProof {
|
||||
if (-not $RequireOperatorPairing) {
|
||||
Add-Step "operator-pairing-proof" "Skipped" "Operator pairing proof was not required."
|
||||
return
|
||||
}
|
||||
if (-not (Test-SetupHistoryPhase -Phase "PairOperator")) {
|
||||
Save-DiagnosticsSnapshot -Reason "operator-pair-phase-missing"
|
||||
throw "OperatorPairingProofFailed: setup state did not record PairOperator."
|
||||
}
|
||||
if (-not (Save-RedactedDeviceIdentityShape)) {
|
||||
Save-DiagnosticsSnapshot -Reason "operator-device-token-missing"
|
||||
throw "OperatorPairingProofFailed: stored operator device token is missing."
|
||||
}
|
||||
Invoke-LoggedProcess "operator-stored-token-reconnect" "dotnet" @(
|
||||
"run", "--project", $cliProject, "--",
|
||||
"--probe-read", "--skip-chat", "--require-stored-device-token",
|
||||
"--connect-timeout-ms", "15000"
|
||||
) -Environment (Get-ValidationAppEnvironment) -SensitiveOutput
|
||||
|
||||
$script:summary.pairingValidation["operatorPaired"] = $true
|
||||
Add-Step "operator-pairing-proof" "Passed" "Stored operator device token reconnect succeeded."
|
||||
}
|
||||
|
||||
function Invoke-WindowsNodePairingProof {
|
||||
# Windows tray IS the node (per Mike). Confirm the PairWindowsTrayNode phase
|
||||
# ran and that gateway node.list returns the tray node.
|
||||
if (-not $RequireWindowsNodePairing) {
|
||||
Add-Step "windows-node-pairing-proof" "Skipped" "Windows tray node pairing proof was not required."
|
||||
return
|
||||
}
|
||||
if (-not (Test-SetupHistoryPhase -Phase "PairWindowsTrayNode")) {
|
||||
Save-DiagnosticsSnapshot -Reason "windows-node-pair-phase-missing"
|
||||
throw "WindowsNodePairingProofFailed: setup state did not record PairWindowsTrayNode."
|
||||
}
|
||||
Invoke-LoggedProcess "windows-node-list-proof" "dotnet" @(
|
||||
"run", "--project", $cliProject, "--",
|
||||
"--probe-read", "--skip-chat", "--require-stored-device-token", "--require-node",
|
||||
"--connect-timeout-ms", "90000"
|
||||
) -Environment (Get-ValidationAppEnvironment) -SensitiveOutput
|
||||
|
||||
$script:summary.pairingValidation["windowsNodePaired"] = $true
|
||||
Add-Step "windows-node-pairing-proof" "Passed" "Gateway node.list returned the Windows tray node."
|
||||
}
|
||||
|
||||
function Invoke-SmokeChecks {
|
||||
Invoke-LoggedProcess "wsl-list-after" "wsl.exe" @("--list", "--verbose") -IgnoreExitCode
|
||||
Save-LoopbackNetworkDiagnostics -Reason "post-install"
|
||||
|
||||
# Gateway in WSL via systemd user unit (UpstreamInstall layout).
|
||||
Invoke-LoggedProcess "wsl-openclaw-version" "wsl.exe" @(
|
||||
"-d", $DistroName, "-u", "openclaw", "--", "/opt/openclaw/bin/openclaw", "--version")
|
||||
Invoke-LoggedProcess "wsl-openclaw-config-validate" "wsl.exe" @(
|
||||
"-d", $DistroName, "-u", "openclaw", "--", "/opt/openclaw/bin/openclaw", "config", "validate")
|
||||
Invoke-LoggedProcess "wsl-gateway-journal" "wsl.exe" @(
|
||||
"-d", $DistroName, "-u", "root", "--", "journalctl", "--user", "-u", "openclaw-gateway",
|
||||
"--no-pager", "-n", "200") -IgnoreExitCode -SensitiveOutput
|
||||
|
||||
# Loopback-only health probe.
|
||||
$healthUri = Convert-GatewayUrlToHealthUri -Url $GatewayUrl
|
||||
$healthPath = Join-Path $commandsRoot "gateway-health.json"
|
||||
try {
|
||||
$h = Invoke-RestMethod -Uri $healthUri -TimeoutSec 10
|
||||
$h | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $healthPath -Encoding UTF8
|
||||
if (-not $h.ok) { throw "Gateway health response did not contain ok=true." }
|
||||
$gw = if ($h.PSObject.Properties.Name -contains "gateway") { $h.gateway } else { $null }
|
||||
$version = Get-JsonStringProperty $gw "version"
|
||||
$displayName = Get-JsonStringProperty $gw "displayName"
|
||||
$isDev = $version -like "*-dev*" -or $displayName -like "Dev OpenClaw*"
|
||||
$script:summary.pairingValidation["gatewayImplementation"] = if ($isDev) { "DevShim" } else { "ProductionCandidate" }
|
||||
Add-Step "gateway-health" "Passed" "Gateway health endpoint returned ok=true." @{ uri = $healthUri; path = $healthPath }
|
||||
} catch {
|
||||
throw "Gateway health check failed for ${healthUri}: $($_.Exception.Message). Diagnostics: https://aka.ms/wsllogs."
|
||||
}
|
||||
|
||||
Invoke-BootstrapHandoffProbe
|
||||
Save-RedactedSettings
|
||||
Invoke-OperatorPairingProof
|
||||
Invoke-WindowsNodePairingProof
|
||||
|
||||
$args = @(
|
||||
"run", "--project", $cliProject, "--",
|
||||
"--probe-read", "--skip-chat",
|
||||
"--message", "openclaw validation ping",
|
||||
"--connect-timeout-ms", "15000"
|
||||
)
|
||||
if ($RequireOperatorPairing) { $args += "--require-stored-device-token" }
|
||||
Invoke-LoggedProcess "openclaw-cli-probe" "dotnet" $args -Environment (Get-ValidationAppEnvironment) -SensitiveOutput
|
||||
}
|
||||
|
||||
function Invoke-DistroUnregisterIfPresent {
|
||||
param([string]$Reason)
|
||||
Stop-WslKeepAliveProcesses
|
||||
# Authoritative repair primitive: `wsl --unregister`. NEVER `wsl --shutdown`.
|
||||
Invoke-LoggedProcess "wsl-unregister-$Reason" "wsl.exe" @("--unregister", $DistroName) -IgnoreExitCode
|
||||
|
||||
if (Test-Path -LiteralPath $wslInstallLocation) {
|
||||
try {
|
||||
Remove-Item -LiteralPath $wslInstallLocation -Recurse -Force -ErrorAction Stop
|
||||
Add-Step "remove-install-location-$Reason" "Completed" "Removed install location directory." @{ path = $wslInstallLocation }
|
||||
} catch {
|
||||
Add-Step "remove-install-location-$Reason" "Skipped" "Could not remove install location: $($_.Exception.Message)" @{ path = $wslInstallLocation }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PreIterationCleanup {
|
||||
param([int]$Index)
|
||||
if ($Scenario -in @("FreshMachine", "Recreate")) {
|
||||
Invoke-DistroUnregisterIfPresent -Reason "iteration-$Index-pre"
|
||||
# Wipe isolated AppData so identity store starts empty.
|
||||
foreach ($p in @($validationAppDataRoot, $validationLocalAppDataRoot)) {
|
||||
if (Test-Path -LiteralPath $p) {
|
||||
try { Remove-Item -LiteralPath $p -Recurse -Force -ErrorAction Stop } catch { }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Stop-WslKeepAliveProcesses
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PostIterationCleanup {
|
||||
param([int]$Index, [bool]$IterationFailed)
|
||||
if ($Scenario -ne "Recreate") {
|
||||
$script:summary.cleanupStatus = if ($script:summary.cleanupStatus -eq "Failed") { "Failed" } else { "Skipped" }
|
||||
Add-Step "iteration-$Index-cleanup" "Skipped" "Post-iteration distro cleanup is only required in Recreate scenario."
|
||||
return "Skipped"
|
||||
}
|
||||
if ($IterationFailed -and $KeepFailedDistro) {
|
||||
$script:summary.cleanupStatus = if ($script:summary.cleanupStatus -eq "Failed") { "Failed" } else { "Skipped" }
|
||||
Add-Step "iteration-$Index-cleanup" "Skipped" "Keeping failed WSL distro for inspection (-KeepFailedDistro)." @{ distroName = $DistroName }
|
||||
return "Skipped"
|
||||
}
|
||||
if (-not $IterationFailed -and -not $CleanupAfterSuccess) {
|
||||
$script:summary.cleanupStatus = if ($script:summary.cleanupStatus -eq "Failed") { "Failed" } else { "Skipped" }
|
||||
Add-Step "iteration-$Index-cleanup" "Skipped" "Leaving successful distro (-CleanupAfterSuccess:`$false)." @{ distroName = $DistroName }
|
||||
return "Skipped"
|
||||
}
|
||||
try {
|
||||
$script:summary.cleanupStatus = "Running"
|
||||
Invoke-DistroUnregisterIfPresent -Reason "iteration-$Index-post"
|
||||
$script:summary.cleanupStatus = "Passed"
|
||||
Add-Step "iteration-$Index-cleanup" "Passed" "Cleaned recreated WSL distro after validation iteration." @{ distroName = $DistroName }
|
||||
return "Passed"
|
||||
} catch {
|
||||
$script:summary.cleanupStatus = "Failed"
|
||||
Add-Step "iteration-$Index-cleanup" "Failed" $_.Exception.Message
|
||||
if (-not $ContinueOnCleanupFailure) { throw }
|
||||
return "Failed"
|
||||
}
|
||||
}
|
||||
|
||||
function New-IterationRecord {
|
||||
param([int]$Index)
|
||||
return [ordered]@{
|
||||
index = $Index
|
||||
distroName = $DistroName
|
||||
installLocation = $wslInstallLocation
|
||||
validationStatus = "Running"
|
||||
cleanupStatus = "NotStarted"
|
||||
error = $null
|
||||
cleanupError = $null
|
||||
startedAt = (Get-Date).ToString("o")
|
||||
finishedAt = $null
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-ValidationIteration {
|
||||
param([int]$Index)
|
||||
$iteration = New-IterationRecord -Index $Index
|
||||
$script:summary.iterations += $iteration
|
||||
Add-Step "iteration-$Index" "Started" "Starting validation iteration $Index."
|
||||
$trayProcess = $null
|
||||
$iterationFailed = $false
|
||||
|
||||
try {
|
||||
Invoke-RepositoryValidation
|
||||
Invoke-PreIterationCleanup -Index $Index
|
||||
$trayProcess = Invoke-TrayLocalSetup
|
||||
Invoke-SmokeChecks
|
||||
|
||||
Add-Step "iteration-$Index" "Passed" "Validation iteration $Index passed."
|
||||
$iteration.validationStatus = "Passed"
|
||||
$script:summary.validationStatus = "Passed"
|
||||
} catch {
|
||||
$iterationFailed = $true
|
||||
$iteration.validationStatus = "Failed"
|
||||
$iteration.error = $_.Exception.Message
|
||||
$script:summary.validationStatus = "Failed"
|
||||
Save-DiagnosticsSnapshot -Reason "iteration-$Index-failed"
|
||||
throw
|
||||
} finally {
|
||||
try {
|
||||
Stop-TrayProcess -Process $trayProcess
|
||||
$iteration.cleanupStatus = Invoke-PostIterationCleanup -Index $Index -IterationFailed $iterationFailed
|
||||
} catch {
|
||||
$iteration.cleanupStatus = "Failed"
|
||||
$iteration.cleanupError = $_.Exception.Message
|
||||
throw
|
||||
} finally {
|
||||
$iteration.finishedAt = (Get-Date).ToString("o")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $runRoot, $commandsRoot, $screenshotsRoot | Out-Null
|
||||
|
||||
$exitCode = 0
|
||||
try {
|
||||
Assert-DestructiveSafety
|
||||
Invoke-Preflight
|
||||
|
||||
if ($Scenario -eq "PreflightOnly") {
|
||||
Add-Step "scenario" "Passed" "Preflight completed."
|
||||
$script:summary.validationStatus = "Passed"
|
||||
$script:summary.cleanupStatus = "Skipped"
|
||||
} elseif ($Scenario -eq "Recreate" -or $Iterations -gt 1) {
|
||||
if ($Iterations -lt 1) { throw "-Iterations must be at least 1." }
|
||||
for ($i = 1; $i -le $Iterations; $i++) {
|
||||
try { Invoke-ValidationIteration -Index $i }
|
||||
catch {
|
||||
Add-Step "iteration-$i" "Failed" $_.Exception.Message
|
||||
if (-not $ContinueOnFailure) { throw }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
# UpstreamInstall or FreshMachine, single shot.
|
||||
Invoke-ValidationIteration -Index 1
|
||||
}
|
||||
|
||||
if ($script:summary.validationStatus -eq "Running") { $script:summary.validationStatus = "Passed" }
|
||||
if ($script:summary.cleanupStatus -in @("Running", "NotStarted")) { $script:summary.cleanupStatus = "Skipped" }
|
||||
if ($script:summary.validationStatus -eq "Failed") {
|
||||
$script:summary.status = "Failed"; $exitCode = 1
|
||||
} else {
|
||||
$script:summary.status = if ($script:summary.cleanupStatus -eq "Failed") { "PassedWithCleanupFailure" } else { "Passed" }
|
||||
}
|
||||
} catch {
|
||||
$script:summary.status = "Failed"
|
||||
if ($script:summary.validationStatus -eq "Running") { $script:summary.validationStatus = "Failed" }
|
||||
if ($script:summary.cleanupStatus -eq "Running") { $script:summary.cleanupStatus = "Failed" }
|
||||
$script:summary.error = $_.Exception.Message
|
||||
Add-Step "validation" "Failed" $_.Exception.Message
|
||||
$exitCode = 1
|
||||
} finally {
|
||||
Write-Summary
|
||||
}
|
||||
|
||||
Write-Host "Validation summary: $summaryPath"
|
||||
if ($script:summary.status -eq "Failed") {
|
||||
Write-Host "Diagnostics: see https://aka.ms/wsllogs for WSL networking/lifecycle logs."
|
||||
}
|
||||
exit $exitCode
|
||||
@ -1,70 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenClaw.Shared.Audio;
|
||||
|
||||
/// <summary>Result of a speech-to-text transcription segment.</summary>
|
||||
public sealed class TranscriptionResult
|
||||
{
|
||||
public string Text { get; init; } = "";
|
||||
public TimeSpan Start { get; init; }
|
||||
public TimeSpan End { get; init; }
|
||||
public string Language { get; init; } = "en";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated result of a single silence-bounded utterance — i.e. all the
|
||||
/// Whisper segments produced from one VAD-bounded speech burst, combined.
|
||||
/// Consumers that need "what the user said" (chat submission, stt.listen)
|
||||
/// should listen for this event instead of per-segment TranscriptionResult
|
||||
/// to avoid sending partial text.
|
||||
/// </summary>
|
||||
public sealed class UtteranceResult
|
||||
{
|
||||
/// <summary>Concatenated text across all segments, single-spaced.</summary>
|
||||
public string Text { get; init; } = "";
|
||||
/// <summary>Language detected on the first segment, or null if no segments.</summary>
|
||||
public string? Language { get; init; }
|
||||
/// <summary>Start of the first segment relative to capture start.</summary>
|
||||
public TimeSpan Start { get; init; }
|
||||
/// <summary>End of the last segment relative to capture start.</summary>
|
||||
public TimeSpan End { get; init; }
|
||||
/// <summary>Immutable snapshot of the per-segment results.</summary>
|
||||
public IReadOnlyList<TranscriptionResult> Segments { get; init; } = Array.Empty<TranscriptionResult>();
|
||||
}
|
||||
|
||||
/// <summary>Voice-activity detection event.</summary>
|
||||
public sealed class VadEvent
|
||||
{
|
||||
public bool IsSpeaking { get; init; }
|
||||
public float Probability { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Configuration for the audio pipeline.</summary>
|
||||
public sealed class AudioPipelineOptions
|
||||
{
|
||||
/// <summary>Path to the Whisper GGML model file.</summary>
|
||||
public string ModelPath { get; init; } = "";
|
||||
|
||||
/// <summary>Language code for STT (e.g. "en", "auto").</summary>
|
||||
public string Language { get; init; } = "auto";
|
||||
|
||||
/// <summary>Seconds of silence before a speech segment is finalized.</summary>
|
||||
public float SilenceTimeoutSeconds { get; init; } = 1.5f;
|
||||
|
||||
/// <summary>Optional audio device ID. Null = system default microphone.</summary>
|
||||
public string? DeviceId { get; init; }
|
||||
|
||||
/// <summary>VAD probability threshold (0.0–1.0). Audio above this is considered speech.</summary>
|
||||
public float VadThreshold { get; init; } = 0.3f;
|
||||
}
|
||||
|
||||
/// <summary>Pipeline state.</summary>
|
||||
public enum AudioPipelineState
|
||||
{
|
||||
Stopped,
|
||||
Starting,
|
||||
Listening,
|
||||
Processing,
|
||||
Error
|
||||
}
|
||||
@ -1,390 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace OpenClaw.Shared.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Manages downloads and on-disk lifecycle for Piper TTS voices.
|
||||
///
|
||||
/// Each "voice" is a sherpa-onnx pre-packaged tarball that contains
|
||||
/// everything needed for offline synthesis — the .onnx model, the
|
||||
/// tokens.txt phoneme map, and the language-specific espeak-ng-data.
|
||||
/// We use the sherpa-onnx repackaged distribution rather than the raw
|
||||
/// HuggingFace Piper voices because the latter requires the user (or
|
||||
/// us) to ship espeak-ng-data separately (~80 MB shared across voices).
|
||||
///
|
||||
/// Storage layout under the tray's data directory:
|
||||
/// models/piper/<voice-id>/
|
||||
/// <voice-id>.onnx
|
||||
/// tokens.txt
|
||||
/// espeak-ng-data/...
|
||||
///
|
||||
/// Each voice is ~50 MB compressed, ~80 MB extracted (with espeak data).
|
||||
///
|
||||
/// **TODO (pre-GA):** SHA-256 verification of downloaded tarballs before
|
||||
/// extraction (Audio_FollowUps.md §2). The current implementation trusts
|
||||
/// HTTPS + the system trust chain only.
|
||||
/// </summary>
|
||||
public sealed class PiperVoiceManager
|
||||
{
|
||||
private readonly string _voicesDirectory;
|
||||
private readonly IOpenClawLogger _logger;
|
||||
// Per-voice single-flight gate: prevents racing the same voice download
|
||||
// from two callers (e.g. UI and a programmatic caller). Static so two
|
||||
// PiperVoiceManager instances over the same data directory still
|
||||
// coalesce against the same in-flight task.
|
||||
private static readonly ConcurrentDictionary<string, Lazy<Task>> InFlightDownloads = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Curated catalog of Piper voices we offer in the UI. Each entry is
|
||||
/// a sherpa-onnx pre-packaged tarball from the project's GitHub
|
||||
/// releases. To add a voice: pick its key from
|
||||
/// https://github.com/k2-fsa/sherpa-onnx/releases/tag/tts-models,
|
||||
/// download the tarball, compute its SHA-256, and pin it below.
|
||||
/// Sizes shown in the UI are approximate compressed sizes.
|
||||
///
|
||||
/// SECURITY — pinned SHA-256 hashes (lowercase hex) verified against
|
||||
/// the sherpa-onnx GitHub release on 2026-05-05. Downloads with a
|
||||
/// different hash are rejected and the partial tarball is deleted.
|
||||
/// Before any public release: re-verify each hash from an independent
|
||||
/// source and document provenance in Audio_FollowUps.md §2.
|
||||
/// </summary>
|
||||
public static readonly PiperVoiceInfo[] AvailableVoices =
|
||||
[
|
||||
new("en_US-amy-low", "English (US) — Amy (low quality, fast)", "en-US",
|
||||
"https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-en_US-amy-low.tar.bz2",
|
||||
"c70f5284a09a7fd4ed203b39b2ff51cac1432b422b852eb647b481dade3cf639"),
|
||||
new("en_US-libritts-high","English (US) — LibriTTS (high quality)", "en-US",
|
||||
"https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-en_US-libritts-high.tar.bz2",
|
||||
"d9d35056703fd38ed38e95c202a50f603fefdc8a92a7b6332c4f1a41616eac72"),
|
||||
new("en_GB-alan-low", "English (GB) — Alan (low quality, fast)", "en-GB",
|
||||
"https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-en_GB-alan-low.tar.bz2",
|
||||
"1308e730b7a12c3b64b669d65daa0138fcb83b1a086edee92fa9fa68cb0290dd"),
|
||||
new("fr_FR-siwis-low", "Français (FR) — Siwis (low quality, fast)","fr-FR",
|
||||
"https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-fr_FR-siwis-low.tar.bz2",
|
||||
"3d69170c160c8375c4123901a72a3845222b39456d39ab74f5bbd7310952b5af"),
|
||||
new("de_DE-thorsten-low","Deutsch (DE) — Thorsten (low quality)", "de-DE",
|
||||
"https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-de_DE-thorsten-low.tar.bz2",
|
||||
"41fab35910fdcec4696b031951d8fd6c262e594cf77b35e1068fadbeb5a091a6"),
|
||||
new("zh_CN-huayan-medium","中文 (CN) — Huayan (medium quality)", "zh-CN",
|
||||
"https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-zh_CN-huayan-medium.tar.bz2",
|
||||
"dbdfec42b91d9cee31cce9ff4b3e9c305eb6fbf60546d071f7e46273554cce6b"),
|
||||
];
|
||||
|
||||
public PiperVoiceManager(string dataDirectory, IOpenClawLogger logger)
|
||||
{
|
||||
_voicesDirectory = Path.Combine(dataDirectory, "models", "piper");
|
||||
_logger = logger;
|
||||
Directory.CreateDirectory(_voicesDirectory);
|
||||
}
|
||||
|
||||
/// <summary>Root directory where this voice's files live (created lazily).</summary>
|
||||
public string GetVoiceDirectory(string voiceId)
|
||||
{
|
||||
var info = FindVoice(voiceId);
|
||||
return Path.Combine(_voicesDirectory, info.VoiceId);
|
||||
}
|
||||
|
||||
/// <summary>Path to the .onnx model file for a downloaded voice.</summary>
|
||||
public string GetModelPath(string voiceId)
|
||||
{
|
||||
var dir = GetVoiceDirectory(voiceId);
|
||||
// sherpa-onnx tarballs put files at the root of the voice dir; the
|
||||
// model file is named after the voice id.
|
||||
return Path.Combine(dir, $"{voiceId}.onnx");
|
||||
}
|
||||
|
||||
/// <summary>Path to tokens.txt (phoneme map).</summary>
|
||||
public string GetTokensPath(string voiceId) => Path.Combine(GetVoiceDirectory(voiceId), "tokens.txt");
|
||||
|
||||
/// <summary>Path to the espeak-ng-data directory bundled with this voice.</summary>
|
||||
public string GetEspeakDataDir(string voiceId) => Path.Combine(GetVoiceDirectory(voiceId), "espeak-ng-data");
|
||||
|
||||
/// <summary>True when all three files are present on disk.</summary>
|
||||
public bool IsVoiceDownloaded(string voiceId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.Exists(GetModelPath(voiceId))
|
||||
&& File.Exists(GetTokensPath(voiceId))
|
||||
&& Directory.Exists(GetEspeakDataDir(voiceId));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// FindVoice throws on unknown voiceId — treat as not-downloaded.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Download and extract a Piper voice from the sherpa-onnx release.
|
||||
/// Reports progress as bytes downloaded / total bytes (extraction
|
||||
/// progress is not reported separately).
|
||||
/// Per-voice single-flight: concurrent calls for the same voice await
|
||||
/// the in-flight download instead of racing on the same temp tarball.
|
||||
/// </summary>
|
||||
public Task DownloadVoiceAsync(
|
||||
string voiceId,
|
||||
IProgress<(long downloaded, long total)>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var info = FindVoice(voiceId);
|
||||
if (IsVoiceDownloaded(info.VoiceId))
|
||||
{
|
||||
_logger.Info($"Piper voice '{info.VoiceId}' already downloaded");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Preflight: bail out before downloading 50-150 MB if the OS isn't
|
||||
// capable of extracting the .tar.bz2 we'd produce. tar.exe ships with
|
||||
// Windows 10 1803+; older systems would fail at the extract step
|
||||
// after a long, wasted download.
|
||||
EnsureExtractorAvailable();
|
||||
|
||||
var key = info.VoiceId;
|
||||
return SingleFlightDownload.RunAsync(
|
||||
InFlightDownloads,
|
||||
key,
|
||||
token => DownloadVoiceCoreAsync(info, progress, token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task DownloadVoiceCoreAsync(
|
||||
PiperVoiceInfo info,
|
||||
IProgress<(long downloaded, long total)>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// SECURITY: refuse to install any voice that doesn't have a pinned
|
||||
// hash. See Audio_FollowUps.md §2.
|
||||
if (string.IsNullOrWhiteSpace(info.Sha256))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Piper voice '{info.VoiceId}' has no pinned SHA-256; refusing to download. " +
|
||||
"Add a verified hash to AvailableVoices before enabling this voice.");
|
||||
}
|
||||
|
||||
var voiceDir = Path.Combine(_voicesDirectory, info.VoiceId);
|
||||
Directory.CreateDirectory(voiceDir);
|
||||
var tarballPath = Path.Combine(voiceDir, $"{info.VoiceId}.tar.bz2.tmp");
|
||||
_logger.Info($"Downloading Piper voice '{info.VoiceId}' from {info.DownloadUrl}");
|
||||
|
||||
try
|
||||
{
|
||||
using var httpClient = new HttpClient();
|
||||
httpClient.Timeout = TimeSpan.FromMinutes(10);
|
||||
using var response = await httpClient.GetAsync(info.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var totalBytes = response.Content.Headers.ContentLength ?? 0;
|
||||
using (var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false))
|
||||
using (var fileStream = new FileStream(tarballPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920))
|
||||
{
|
||||
var buffer = new byte[81920];
|
||||
long downloaded = 0;
|
||||
int bytesRead;
|
||||
while ((bytesRead = await contentStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken).ConfigureAwait(false);
|
||||
downloaded += bytesRead;
|
||||
progress?.Report((downloaded, totalBytes));
|
||||
}
|
||||
}
|
||||
|
||||
// SECURITY: verify SHA-256 of the downloaded tarball BEFORE we
|
||||
// hand it to the extractor. tar reads file contents to disk; an
|
||||
// attacker-controlled tarball could plant arbitrary files (path
|
||||
// traversal aside, the .onnx model itself is loaded into the
|
||||
// process). Fail closed on mismatch — partial dir cleanup runs
|
||||
// in the catch block below.
|
||||
await VerifyHashAsync(tarballPath, info.Sha256, info.VoiceId, cancellationToken);
|
||||
|
||||
_logger.Info($"Extracting Piper voice '{info.VoiceId}'");
|
||||
ExtractTarBz2(tarballPath, voiceDir, cancellationToken);
|
||||
|
||||
// Verify the extraction produced the files we expect; if not,
|
||||
// tear the half-extracted dir down so a retry starts clean.
|
||||
if (!IsVoiceDownloaded(info.VoiceId))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Extraction of Piper voice '{info.VoiceId}' did not produce the expected layout.");
|
||||
}
|
||||
|
||||
_logger.Info($"Piper voice '{info.VoiceId}' verified and ready at {voiceDir}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort cleanup — leaves the user able to retry without
|
||||
// leftover partial files.
|
||||
try { if (File.Exists(tarballPath)) File.Delete(tarballPath); } catch { /* swallow */ }
|
||||
try { if (Directory.Exists(voiceDir) && !IsVoiceDownloaded(info.VoiceId)) Directory.Delete(voiceDir, recursive: true); } catch { /* swallow */ }
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (File.Exists(tarballPath)) File.Delete(tarballPath); } catch { /* swallow */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute SHA-256 of <paramref name="filePath"/> and compare to
|
||||
/// <paramref name="expectedHex"/>. Throws on mismatch (caller is
|
||||
/// expected to delete the file). Does not echo the actual hash to
|
||||
/// avoid handing attackers a confirmation oracle.
|
||||
/// </summary>
|
||||
private static async Task VerifyHashAsync(string filePath, string expectedHex, string assetName, CancellationToken cancellationToken)
|
||||
{
|
||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||
await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true);
|
||||
var actual = await sha.ComputeHashAsync(stream, cancellationToken);
|
||||
var actualHex = Convert.ToHexString(actual).ToLowerInvariant();
|
||||
if (!string.Equals(actualHex, expectedHex, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new System.Security.SecurityException(
|
||||
$"Piper voice '{assetName}' failed integrity check. The downloaded tarball does not match the pinned SHA-256.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Delete a downloaded voice directory.</summary>
|
||||
public bool DeleteVoice(string voiceId)
|
||||
{
|
||||
var info = FindVoice(voiceId);
|
||||
var dir = Path.Combine(_voicesDirectory, info.VoiceId);
|
||||
if (!Directory.Exists(dir)) return false;
|
||||
Directory.Delete(dir, recursive: true);
|
||||
_logger.Info($"Deleted Piper voice '{info.VoiceId}'");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Total disk usage of a downloaded voice, or 0 if not downloaded.</summary>
|
||||
public long GetVoiceSize(string voiceId)
|
||||
{
|
||||
var info = FindVoice(voiceId);
|
||||
var dir = Path.Combine(_voicesDirectory, info.VoiceId);
|
||||
if (!Directory.Exists(dir)) return 0;
|
||||
long total = 0;
|
||||
foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
try { total += new FileInfo(f).Length; } catch { /* skip */ }
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Probe the bundled OS tar.exe used by <see cref="ExtractTarBz2"/>.
|
||||
/// Throws a clear error before any network I/O happens so users on
|
||||
/// downlevel Windows aren't left with a half-downloaded tarball.
|
||||
/// </summary>
|
||||
private static void EnsureExtractorAvailable()
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "tar",
|
||||
ArgumentList = { "--version" },
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
using var proc = System.Diagnostics.Process.Start(psi);
|
||||
if (proc == null)
|
||||
{
|
||||
throw new InvalidOperationException("tar.exe not found on PATH.");
|
||||
}
|
||||
proc.WaitForExit(2000);
|
||||
if (!proc.HasExited)
|
||||
{
|
||||
try { proc.Kill(entireProcessTree: true); } catch { /* swallow */ }
|
||||
throw new InvalidOperationException("tar.exe didn't respond to --version.");
|
||||
}
|
||||
if (proc.ExitCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"tar.exe --version returned exit code {proc.ExitCode}.");
|
||||
}
|
||||
}
|
||||
catch (System.ComponentModel.Win32Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Piper voices need bundled tar (Windows 10 1803+). " +
|
||||
"Your system doesn't have tar on PATH; please update Windows or install a tar utility.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract a .tar.bz2 archive in-place. We use SharpCompress (already a
|
||||
/// transitive dependency via PiperSharp's ecosystem, but explicit here)
|
||||
/// so we don't need to shell out to tar.exe.
|
||||
/// </summary>
|
||||
private static void ExtractTarBz2(string archivePath, string destinationDir, CancellationToken cancellationToken)
|
||||
{
|
||||
// SharpCompress isn't a direct dep of OpenClaw.Shared today; we
|
||||
// intentionally use the BCL .tar reader on top of a bzip2 stream
|
||||
// from a small inline implementation. Keeping the dep surface small
|
||||
// matters in this assembly because everything here is also referenced
|
||||
// from OpenClaw.Cli.
|
||||
//
|
||||
// .NET 7+ ships System.Formats.Tar; bzip2 is not in the BCL, so we
|
||||
// bring it in via a thin wrapper. For now the simplest-correct path
|
||||
// is to call out to the OS-bundled `tar` (Win10 1803+ ships it),
|
||||
// which transparently handles bz2.
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "tar",
|
||||
ArgumentList = { "-xjf", archivePath, "-C", destinationDir, "--strip-components=1" },
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
using var proc = System.Diagnostics.Process.Start(psi)
|
||||
?? throw new InvalidOperationException("Could not start tar to extract Piper voice");
|
||||
|
||||
// Cancellation: kill the tar process if requested.
|
||||
using var reg = cancellationToken.Register(() => { try { proc.Kill(entireProcessTree: true); } catch { /* swallow */ } });
|
||||
|
||||
proc.WaitForExit();
|
||||
if (proc.ExitCode != 0)
|
||||
{
|
||||
var err = proc.StandardError.ReadToEnd();
|
||||
throw new InvalidOperationException($"tar extraction failed (exit {proc.ExitCode}): {err}");
|
||||
}
|
||||
}
|
||||
|
||||
private static PiperVoiceInfo FindVoice(string voiceId)
|
||||
{
|
||||
foreach (var v in AvailableVoices)
|
||||
{
|
||||
if (string.Equals(v.VoiceId, voiceId, StringComparison.OrdinalIgnoreCase))
|
||||
return v;
|
||||
}
|
||||
var available = string.Join(", ", AvailableVoicesIds());
|
||||
throw new ArgumentException($"Unknown Piper voice: '{voiceId}'. Available: {available}");
|
||||
}
|
||||
|
||||
private static IEnumerable<string> AvailableVoicesIds()
|
||||
{
|
||||
foreach (var v in AvailableVoices) yield return v.VoiceId;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Metadata about a Piper voice variant.</summary>
|
||||
/// <param name="VoiceId">Short id, e.g. "en_US-amy-low".</param>
|
||||
/// <param name="DisplayName">Human-readable label for UI.</param>
|
||||
/// <param name="LanguageTag">BCP-47 tag.</param>
|
||||
/// <param name="DownloadUrl">HTTPS URL of the .tar.bz2.</param>
|
||||
/// <param name="Sha256">Pinned lowercase hex SHA-256 of the downloaded
|
||||
/// tarball. MUST be set; downloads are refused when null. See the catalog
|
||||
/// for the "verified on" date — these need re-verification before any
|
||||
/// public release (see Audio_FollowUps.md §2).</param>
|
||||
public sealed record PiperVoiceInfo(
|
||||
string VoiceId,
|
||||
string DisplayName,
|
||||
string LanguageTag,
|
||||
string DownloadUrl,
|
||||
string? Sha256);
|
||||
@ -1,28 +0,0 @@
|
||||
namespace OpenClaw.Shared.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Pinned descriptor for the Silero VAD ONNX model that the audio
|
||||
/// pipeline auto-downloads on first use.
|
||||
///
|
||||
/// SECURITY — same fail-closed verification discipline as
|
||||
/// <see cref="WhisperModelManager"/> and <see cref="PiperVoiceManager"/>:
|
||||
/// the runtime checks the downloaded file's SHA-256 against
|
||||
/// <see cref="Sha256"/> before installing it. The pinned hash here was
|
||||
/// captured against the upstream raw URL on 2026-05-05; re-verify from
|
||||
/// an independent source before any public release (Audio_FollowUps.md
|
||||
/// §2 captures the broader signed-manifest plan).
|
||||
/// </summary>
|
||||
public static class SileroVadModelManifest
|
||||
{
|
||||
public const string FileName = "silero_vad.onnx";
|
||||
|
||||
public const string DownloadUrl =
|
||||
"https://github.com/snakers4/silero-vad/raw/master/src/silero_vad/data/silero_vad.onnx";
|
||||
|
||||
/// <summary>Lowercase hex SHA-256 of the canonical upstream file.</summary>
|
||||
public const string Sha256 = "1a153a22f4509e292a94e67d6f9b85e8deb25b4988682b7e174c65279d8788e3";
|
||||
|
||||
/// <summary>Approximate compressed size in bytes (UI hint; actual size
|
||||
/// is asserted via the SHA-256 check).</summary>
|
||||
public const long ApproximateSizeBytes = 2_327_524;
|
||||
}
|
||||
@ -1,52 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace OpenClaw.Shared.Audio;
|
||||
|
||||
internal static class SingleFlightDownload
|
||||
{
|
||||
public static Task RunAsync(
|
||||
ConcurrentDictionary<string, Lazy<Task>> inFlight,
|
||||
string key,
|
||||
Func<CancellationToken, Task> startDownload,
|
||||
CancellationToken waitCancellationToken = default)
|
||||
{
|
||||
var candidate = new Lazy<Task>(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return startDownload(CancellationToken.None)
|
||||
?? Task.FromException(new InvalidOperationException("Download factory returned null."));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromException(ex);
|
||||
}
|
||||
}, LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
var lazy = inFlight.GetOrAdd(key, candidate);
|
||||
Task task;
|
||||
try
|
||||
{
|
||||
task = lazy.Value;
|
||||
}
|
||||
catch
|
||||
{
|
||||
inFlight.TryRemove(new KeyValuePair<string, Lazy<Task>>(key, lazy));
|
||||
throw;
|
||||
}
|
||||
|
||||
_ = task.ContinueWith(
|
||||
_ => inFlight.TryRemove(new KeyValuePair<string, Lazy<Task>>(key, lazy)),
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
|
||||
return waitCancellationToken.CanBeCanceled
|
||||
? task.WaitAsync(waitCancellationToken)
|
||||
: task;
|
||||
}
|
||||
}
|
||||
@ -1,182 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Whisper.net;
|
||||
using Whisper.net.Ggml;
|
||||
|
||||
namespace OpenClaw.Shared.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps Whisper.net for speech-to-text transcription.
|
||||
/// Lazily loads the model on first use and caches the factory.
|
||||
/// Thread-safe: concurrent calls are serialized by a semaphore.
|
||||
/// </summary>
|
||||
public sealed class SpeechToTextService : IDisposable
|
||||
{
|
||||
private readonly IOpenClawLogger _logger;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private WhisperFactory? _factory;
|
||||
private string? _loadedModelPath;
|
||||
|
||||
public bool IsModelLoaded => _factory != null;
|
||||
public string? LoadedModelPath => _loadedModelPath;
|
||||
|
||||
public SpeechToTextService(IOpenClawLogger logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Load (or reload) the Whisper model from disk.</summary>
|
||||
public void LoadModel(string modelPath)
|
||||
{
|
||||
if (!System.IO.File.Exists(modelPath))
|
||||
throw new System.IO.FileNotFoundException($"Whisper model not found: {modelPath}");
|
||||
|
||||
_factory?.Dispose();
|
||||
_factory = WhisperFactory.FromPath(modelPath);
|
||||
_loadedModelPath = modelPath;
|
||||
_logger.Info($"Whisper model loaded: {modelPath}");
|
||||
}
|
||||
|
||||
/// <summary>Unload the current model and free memory.</summary>
|
||||
public void UnloadModel()
|
||||
{
|
||||
_factory?.Dispose();
|
||||
_factory = null;
|
||||
_loadedModelPath = null;
|
||||
_logger.Info("Whisper model unloaded");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transcribe raw 16 kHz mono PCM float samples.
|
||||
/// Returns all detected segments.
|
||||
/// </summary>
|
||||
public async Task<List<TranscriptionResult>> TranscribeAsync(
|
||||
float[] samples,
|
||||
string language = "auto",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_factory == null)
|
||||
throw new InvalidOperationException("No Whisper model is loaded. Call LoadModel first.");
|
||||
|
||||
await _gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
// Whisper.net's WithLanguage expects either "auto" or a 2-letter
|
||||
// ISO 639-1 code. The capability validator accepts the broader
|
||||
// BCP-47 shape ("en-US", "zh-Hans-CN") because that's what the
|
||||
// public docs advertise; normalize down here so Whisper actually
|
||||
// sees something it understands.
|
||||
var whisperLang = NormalizeForWhisper(language);
|
||||
var builder = _factory.CreateBuilder()
|
||||
.WithLanguage(whisperLang)
|
||||
.WithThreads(Math.Max(1, Environment.ProcessorCount / 2));
|
||||
|
||||
using var processor = builder.Build();
|
||||
|
||||
using var wavStream = PcmToWavStream(samples, 16000);
|
||||
|
||||
var results = new List<TranscriptionResult>();
|
||||
await foreach (var segment in processor.ProcessAsync(wavStream, cancellationToken))
|
||||
{
|
||||
var text = segment.Text?.Trim();
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
{
|
||||
results.Add(new TranscriptionResult
|
||||
{
|
||||
Text = text,
|
||||
Start = segment.Start,
|
||||
End = segment.End,
|
||||
Language = whisperLang
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert raw 16-bit PCM float samples to a WAV MemoryStream.
|
||||
/// Whisper.net processes WAV streams natively.
|
||||
/// </summary>
|
||||
private static System.IO.MemoryStream PcmToWavStream(float[] samples, int sampleRate)
|
||||
{
|
||||
var ms = new System.IO.MemoryStream();
|
||||
using var writer = new System.IO.BinaryWriter(ms, System.Text.Encoding.UTF8, leaveOpen: true);
|
||||
|
||||
int bitsPerSample = 16;
|
||||
short channels = 1;
|
||||
int byteRate = sampleRate * channels * bitsPerSample / 8;
|
||||
short blockAlign = (short)(channels * bitsPerSample / 8);
|
||||
int dataSize = samples.Length * blockAlign;
|
||||
|
||||
// RIFF header
|
||||
writer.Write("RIFF"u8);
|
||||
writer.Write(36 + dataSize);
|
||||
writer.Write("WAVE"u8);
|
||||
|
||||
// fmt subchunk
|
||||
writer.Write("fmt "u8);
|
||||
writer.Write(16); // subchunk size
|
||||
writer.Write((short)1); // PCM format
|
||||
writer.Write(channels);
|
||||
writer.Write(sampleRate);
|
||||
writer.Write(byteRate);
|
||||
writer.Write(blockAlign);
|
||||
writer.Write((short)bitsPerSample);
|
||||
|
||||
// data subchunk
|
||||
writer.Write("data"u8);
|
||||
writer.Write(dataSize);
|
||||
|
||||
// Convert float [-1.0, 1.0] to int16
|
||||
foreach (var sample in samples)
|
||||
{
|
||||
var clamped = Math.Clamp(sample, -1.0f, 1.0f);
|
||||
var int16 = (short)(clamped * 32767);
|
||||
writer.Write(int16);
|
||||
}
|
||||
|
||||
writer.Flush();
|
||||
ms.Position = 0;
|
||||
return ms;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reduce a BCP-47 tag (e.g. "en-US", "zh-Hans-CN") to the 2-letter
|
||||
/// language subtag that Whisper.net's WithLanguage call expects.
|
||||
/// "auto" passes through unchanged. Returns "auto" for nulls/whitespace
|
||||
/// or values that don't begin with at least 2 ASCII letters.
|
||||
/// </summary>
|
||||
internal static string NormalizeForWhisper(string? language)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(language)) return "auto";
|
||||
var trimmed = language.Trim();
|
||||
if (string.Equals(trimmed, "auto", StringComparison.OrdinalIgnoreCase)) return "auto";
|
||||
|
||||
// Take everything up to the first '-' (the primary subtag) and lowercase.
|
||||
var dash = trimmed.IndexOf('-');
|
||||
var primary = (dash >= 0 ? trimmed[..dash] : trimmed).ToLowerInvariant();
|
||||
|
||||
// Whisper expects 2-letter ISO 639-1. If the caller handed us a
|
||||
// 3-letter ISO 639-3 tag (no good cross-walk without a table) or
|
||||
// garbage, fall back to auto-detection rather than silently
|
||||
// sending an invalid value.
|
||||
if (primary.Length != 2 || primary[0] is < 'a' or > 'z' || primary[1] is < 'a' or > 'z')
|
||||
return "auto";
|
||||
|
||||
return primary;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_factory?.Dispose();
|
||||
_gate.Dispose();
|
||||
}
|
||||
}
|
||||
@ -1,108 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.ML.OnnxRuntime;
|
||||
using Microsoft.ML.OnnxRuntime.Tensors;
|
||||
|
||||
namespace OpenClaw.Shared.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Voice Activity Detection using Silero VAD ONNX model.
|
||||
/// Processes 16 kHz mono audio in 512-sample chunks (~32 ms each)
|
||||
/// and returns a speech probability per chunk.
|
||||
/// </summary>
|
||||
public sealed class VoiceActivityDetector : IDisposable
|
||||
{
|
||||
private InferenceSession? _session;
|
||||
private float[] _state; // internal RNN state: shape [2, 1, 128]
|
||||
private readonly int _stateSize;
|
||||
private readonly IOpenClawLogger _logger;
|
||||
|
||||
/// <summary>Expected sample rate for input audio.</summary>
|
||||
public const int SampleRate = 16000;
|
||||
|
||||
/// <summary>Number of samples per VAD chunk (512 @ 16 kHz = 32 ms).</summary>
|
||||
public const int ChunkSamples = 512;
|
||||
|
||||
public bool IsLoaded => _session != null;
|
||||
|
||||
public VoiceActivityDetector(IOpenClawLogger logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_stateSize = 2 * 1 * 128;
|
||||
_state = new float[_stateSize];
|
||||
}
|
||||
|
||||
/// <summary>Load the Silero VAD ONNX model from disk.</summary>
|
||||
public void LoadModel(string modelPath)
|
||||
{
|
||||
if (!System.IO.File.Exists(modelPath))
|
||||
throw new System.IO.FileNotFoundException($"VAD model not found: {modelPath}");
|
||||
|
||||
var opts = new SessionOptions
|
||||
{
|
||||
InterOpNumThreads = 1,
|
||||
IntraOpNumThreads = 1,
|
||||
EnableCpuMemArena = true
|
||||
};
|
||||
opts.GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL;
|
||||
|
||||
_session?.Dispose();
|
||||
_session = new InferenceSession(modelPath, opts);
|
||||
ResetState();
|
||||
_logger.Info($"Silero VAD model loaded: {modelPath}");
|
||||
}
|
||||
|
||||
/// <summary>Reset the internal RNN state (call between utterances).</summary>
|
||||
public void ResetState()
|
||||
{
|
||||
Array.Clear(_state, 0, _state.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process a single chunk of audio and return the speech probability (0.0–1.0).
|
||||
/// Input must be exactly <see cref="ChunkSamples"/> float samples at 16 kHz.
|
||||
/// </summary>
|
||||
public float ProcessChunk(float[] audioChunk)
|
||||
{
|
||||
if (_session == null)
|
||||
throw new InvalidOperationException("VAD model not loaded. Call LoadModel first.");
|
||||
|
||||
if (audioChunk.Length != ChunkSamples)
|
||||
throw new ArgumentException($"Audio chunk must be exactly {ChunkSamples} samples, got {audioChunk.Length}");
|
||||
|
||||
// Build input tensors matching Silero VAD v5 expected shapes.
|
||||
// See: github.com/snakers4/silero-vad/blob/master/examples/csharp/SileroVadOnnxModel.cs
|
||||
var inputTensor = new DenseTensor<float>(audioChunk, new[] { 1, ChunkSamples });
|
||||
var srTensor = new DenseTensor<long>(new long[] { SampleRate }, new[] { 1 });
|
||||
var stateTensor = new DenseTensor<float>(_state, new[] { 2, 1, 128 });
|
||||
|
||||
using var results = _session.Run(new List<NamedOnnxValue>
|
||||
{
|
||||
NamedOnnxValue.CreateFromTensor("input", inputTensor),
|
||||
NamedOnnxValue.CreateFromTensor("sr", srTensor),
|
||||
NamedOnnxValue.CreateFromTensor("state", stateTensor)
|
||||
});
|
||||
|
||||
float probability = 0f;
|
||||
foreach (var result in results)
|
||||
{
|
||||
if (result.Name == "output")
|
||||
{
|
||||
var tensor = result.AsTensor<float>();
|
||||
probability = tensor.Length > 0 ? tensor.GetValue(0) : 0f;
|
||||
}
|
||||
else if (result.Name == "stateN")
|
||||
{
|
||||
var newState = result.AsTensor<float>();
|
||||
for (int i = 0; i < _stateSize && i < newState.Length; i++)
|
||||
_state[i] = newState.GetValue(i);
|
||||
}
|
||||
}
|
||||
|
||||
return probability;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_session?.Dispose();
|
||||
}
|
||||
}
|
||||
@ -1,223 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace OpenClaw.Shared.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Manages Whisper GGML model downloads, storage, and lifecycle.
|
||||
/// Models are stored in <c>%APPDATA%\OpenClawTray\models\</c> (or the
|
||||
/// configured data directory).
|
||||
/// </summary>
|
||||
public sealed class WhisperModelManager
|
||||
{
|
||||
private readonly string _modelsDirectory;
|
||||
private readonly IOpenClawLogger _logger;
|
||||
// Per-model single-flight gate: a manual auto-download (VoiceService
|
||||
// EnsureInitializedAsync) and a UI-triggered download for the same
|
||||
// model would otherwise both write the same .tmp file. Static so an
|
||||
// additional manager instance constructed elsewhere (e.g. the Settings
|
||||
// page's status-only check) doesn't bypass the lock.
|
||||
private static readonly ConcurrentDictionary<string, Lazy<Task>> InFlightDownloads = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Known Whisper model definitions.
|
||||
///
|
||||
/// SECURITY — pinned SHA-256 hashes (lowercase hex) verified against
|
||||
/// HuggingFace on 2026-05-05. Downloads with a different hash are
|
||||
/// rejected and the partial file is deleted. Before any public release:
|
||||
/// re-verify each hash from an independent source and document the
|
||||
/// provenance in Audio_FollowUps.md §2 (also consider replacing this
|
||||
/// inline table with a signed manifest).
|
||||
/// </summary>
|
||||
public static readonly WhisperModelInfo[] AvailableModels =
|
||||
[
|
||||
new("ggml-tiny.bin", "tiny", 77_691_713, "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin",
|
||||
"be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21"),
|
||||
new("ggml-base.bin", "base", 147_951_465, "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin",
|
||||
"60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe"),
|
||||
new("ggml-small.bin", "small", 487_601_967, "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin",
|
||||
"1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b"),
|
||||
];
|
||||
|
||||
public WhisperModelManager(string dataDirectory, IOpenClawLogger logger)
|
||||
{
|
||||
_modelsDirectory = Path.Combine(dataDirectory, "models");
|
||||
_logger = logger;
|
||||
Directory.CreateDirectory(_modelsDirectory);
|
||||
}
|
||||
|
||||
/// <summary>Full file path for a given model name.</summary>
|
||||
public string GetModelPath(string modelName)
|
||||
{
|
||||
var info = FindModel(modelName);
|
||||
return Path.Combine(_modelsDirectory, info.FileName);
|
||||
}
|
||||
|
||||
/// <summary>Check whether a model file already exists on disk.</summary>
|
||||
public bool IsModelDownloaded(string modelName)
|
||||
{
|
||||
var path = GetModelPath(modelName);
|
||||
return File.Exists(path);
|
||||
}
|
||||
|
||||
/// <summary>Get the size of a downloaded model, or 0 if not downloaded.</summary>
|
||||
public long GetModelSize(string modelName)
|
||||
{
|
||||
var path = GetModelPath(modelName);
|
||||
return File.Exists(path) ? new FileInfo(path).Length : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Download a model from HuggingFace if not already present.
|
||||
/// Reports progress as bytes downloaded / total bytes.
|
||||
/// Per-model single-flight: concurrent calls for the same model await
|
||||
/// the in-flight download instead of racing on the same .tmp file.
|
||||
/// </summary>
|
||||
public Task DownloadModelAsync(
|
||||
string modelName,
|
||||
IProgress<(long downloaded, long total)>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var info = FindModel(modelName);
|
||||
var destPath = Path.Combine(_modelsDirectory, info.FileName);
|
||||
|
||||
if (File.Exists(destPath))
|
||||
{
|
||||
_logger.Info($"Model '{modelName}' already exists at {destPath}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Use the canonical key (FileName) so two callers that pass "base"
|
||||
// and "ggml-base.bin" still coalesce.
|
||||
var key = info.FileName;
|
||||
return SingleFlightDownload.RunAsync(
|
||||
InFlightDownloads,
|
||||
key,
|
||||
token => DownloadModelCoreAsync(info, destPath, progress, token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task DownloadModelCoreAsync(
|
||||
WhisperModelInfo info,
|
||||
string destPath,
|
||||
IProgress<(long downloaded, long total)>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// SECURITY: a missing pinned hash is treated as a hard failure so we
|
||||
// never install an unverified asset. The catalog above pins all
|
||||
// shipped models; if you add a new one without a hash, this is the
|
||||
// place that refuses to download it. See Audio_FollowUps.md §2.
|
||||
if (string.IsNullOrWhiteSpace(info.Sha256))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Whisper model '{info.Name}' has no pinned SHA-256; refusing to download. " +
|
||||
"Add a verified hash to AvailableModels before enabling this model.");
|
||||
}
|
||||
|
||||
_logger.Info($"Downloading model '{info.Name}' from {info.DownloadUrl}");
|
||||
var tempPath = destPath + ".tmp";
|
||||
|
||||
try
|
||||
{
|
||||
using var httpClient = new HttpClient();
|
||||
httpClient.Timeout = TimeSpan.FromMinutes(30);
|
||||
using var response = await httpClient.GetAsync(info.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var totalBytes = response.Content.Headers.ContentLength ?? info.ApproximateSizeBytes;
|
||||
using (var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken))
|
||||
using (var fileStream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920))
|
||||
{
|
||||
var buffer = new byte[81920];
|
||||
long downloadedBytes = 0;
|
||||
int bytesRead;
|
||||
|
||||
while ((bytesRead = await contentStream.ReadAsync(buffer, cancellationToken)) > 0)
|
||||
{
|
||||
await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
|
||||
downloadedBytes += bytesRead;
|
||||
progress?.Report((downloadedBytes, totalBytes));
|
||||
}
|
||||
|
||||
await fileStream.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// SECURITY: verify SHA-256 BEFORE the atomic rename, so a
|
||||
// tampered file never lands at the canonical path. On mismatch
|
||||
// we delete the temp file (no partial install) and surface a
|
||||
// sanitized error — we deliberately do NOT echo the actual
|
||||
// hash because that gives an attacker a confirmation oracle.
|
||||
await VerifyHashAsync(tempPath, info.Sha256, info.Name, cancellationToken);
|
||||
|
||||
File.Move(tempPath, destPath, overwrite: true);
|
||||
_logger.Info($"Model '{info.Name}' downloaded and verified");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Clean up partial download
|
||||
try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { /* best effort */ }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute SHA-256 of <paramref name="filePath"/> and compare to
|
||||
/// <paramref name="expectedHex"/>. Throws on mismatch (and the caller
|
||||
/// is expected to delete the file). Does not echo the actual hash to
|
||||
/// avoid handing attackers a confirmation oracle.
|
||||
/// </summary>
|
||||
private static async Task VerifyHashAsync(string filePath, string expectedHex, string assetName, CancellationToken cancellationToken)
|
||||
{
|
||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||
await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true);
|
||||
var actual = await sha.ComputeHashAsync(stream, cancellationToken);
|
||||
var actualHex = Convert.ToHexString(actual).ToLowerInvariant();
|
||||
if (!string.Equals(actualHex, expectedHex, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new System.Security.SecurityException(
|
||||
$"Whisper model '{assetName}' failed integrity check. The downloaded file does not match the pinned SHA-256.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Delete a downloaded model file.</summary>
|
||||
public bool DeleteModel(string modelName)
|
||||
{
|
||||
var path = GetModelPath(modelName);
|
||||
if (!File.Exists(path)) return false;
|
||||
File.Delete(path);
|
||||
_logger.Info($"Deleted model '{modelName}'");
|
||||
return true;
|
||||
}
|
||||
|
||||
private static WhisperModelInfo FindModel(string modelName)
|
||||
{
|
||||
foreach (var m in AvailableModels)
|
||||
{
|
||||
if (string.Equals(m.Name, modelName, StringComparison.OrdinalIgnoreCase))
|
||||
return m;
|
||||
}
|
||||
throw new ArgumentException($"Unknown model: '{modelName}'. Available: tiny, base, small");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Metadata about a Whisper model variant.</summary>
|
||||
/// <param name="FileName">On-disk filename (e.g. "ggml-base.bin").</param>
|
||||
/// <param name="Name">Short identifier used by callers ("tiny" / "base" / "small").</param>
|
||||
/// <param name="ApproximateSizeBytes">Approximate size hint for UI; the
|
||||
/// actual size is asserted against <paramref name="Sha256"/> after download.</param>
|
||||
/// <param name="DownloadUrl">HTTPS URL of the model file.</param>
|
||||
/// <param name="Sha256">Pinned lowercase hex SHA-256 of the downloaded file.
|
||||
/// MUST be set; downloads are refused when null. See the catalog for the
|
||||
/// "verified on" date — these need re-verification before any public
|
||||
/// release (see Audio_FollowUps.md §2).</param>
|
||||
public sealed record WhisperModelInfo(
|
||||
string FileName,
|
||||
string Name,
|
||||
long ApproximateSizeBytes,
|
||||
string DownloadUrl,
|
||||
string? Sha256);
|
||||
@ -1,154 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace OpenClaw.Shared.Capabilities;
|
||||
|
||||
/// <summary>
|
||||
/// App-level capability exposing navigation, status, and configuration
|
||||
/// through the MCP server for programmatic testing and CLI agents.
|
||||
/// </summary>
|
||||
public class AppCapability : NodeCapabilityBase
|
||||
{
|
||||
public override string Category => "app";
|
||||
|
||||
private static readonly string[] _commands = new[]
|
||||
{
|
||||
"app.navigate",
|
||||
"app.status",
|
||||
"app.sessions",
|
||||
"app.agents",
|
||||
"app.nodes",
|
||||
"app.config.get",
|
||||
"app.settings.get",
|
||||
"app.settings.set",
|
||||
"app.menu",
|
||||
"app.search",
|
||||
};
|
||||
|
||||
public override IReadOnlyList<string> Commands => _commands;
|
||||
|
||||
// Handler delegates — wired up by App.xaml.cs after construction.
|
||||
public Func<string, Task<object?>>? NavigateHandler;
|
||||
public Func<object?>? StatusHandler;
|
||||
public Func<string?, Task<object?>>? SessionsHandler;
|
||||
public Func<Task<object?>>? AgentsHandler;
|
||||
public Func<object?>? NodesHandler;
|
||||
public Func<string?, Task<object?>>? ConfigGetHandler;
|
||||
public Func<string, object?>? SettingsGetHandler;
|
||||
public Func<string, string, object?>? SettingsSetHandler;
|
||||
public Func<object?>? MenuHandler;
|
||||
public Func<string, object?>? SearchHandler;
|
||||
|
||||
public AppCapability(IOpenClawLogger logger) : base(logger) { }
|
||||
|
||||
public override async Task<NodeInvokeResponse> ExecuteAsync(NodeInvokeRequest request)
|
||||
{
|
||||
return request.Command switch
|
||||
{
|
||||
"app.navigate" => await HandleNavigate(request),
|
||||
"app.status" => HandleStatus(),
|
||||
"app.sessions" => await HandleSessions(request),
|
||||
"app.agents" => await HandleAgents(),
|
||||
"app.nodes" => HandleNodes(),
|
||||
"app.config.get" => await HandleConfigGet(request),
|
||||
"app.settings.get" => HandleSettingsGet(request),
|
||||
"app.settings.set" => HandleSettingsSet(request),
|
||||
"app.menu" => HandleMenu(),
|
||||
"app.search" => HandleSearch(request),
|
||||
_ => Error($"Unknown command: {request.Command}")
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<NodeInvokeResponse> HandleNavigate(NodeInvokeRequest request)
|
||||
{
|
||||
var page = GetStringArg(request.Args, "page");
|
||||
if (string.IsNullOrEmpty(page))
|
||||
return Error("Missing required arg: page");
|
||||
if (NavigateHandler == null)
|
||||
return Error("Navigate handler not registered");
|
||||
var result = await NavigateHandler(page);
|
||||
return Success(result);
|
||||
}
|
||||
|
||||
private NodeInvokeResponse HandleStatus()
|
||||
{
|
||||
if (StatusHandler == null)
|
||||
return Error("Status handler not registered");
|
||||
return Success(StatusHandler());
|
||||
}
|
||||
|
||||
private async Task<NodeInvokeResponse> HandleSessions(NodeInvokeRequest request)
|
||||
{
|
||||
var agentId = GetStringArg(request.Args, "agentId");
|
||||
if (SessionsHandler == null)
|
||||
return Error("Sessions handler not registered");
|
||||
var result = await SessionsHandler(agentId);
|
||||
return Success(result);
|
||||
}
|
||||
|
||||
private async Task<NodeInvokeResponse> HandleAgents()
|
||||
{
|
||||
if (AgentsHandler == null)
|
||||
return Error("Agents handler not registered");
|
||||
var result = await AgentsHandler();
|
||||
return Success(result);
|
||||
}
|
||||
|
||||
private NodeInvokeResponse HandleNodes()
|
||||
{
|
||||
if (NodesHandler == null)
|
||||
return Error("Nodes handler not registered");
|
||||
return Success(NodesHandler());
|
||||
}
|
||||
|
||||
private async Task<NodeInvokeResponse> HandleConfigGet(NodeInvokeRequest request)
|
||||
{
|
||||
var path = GetStringArg(request.Args, "path");
|
||||
if (ConfigGetHandler == null)
|
||||
return Error("Config handler not registered");
|
||||
var result = await ConfigGetHandler(path);
|
||||
return Success(result);
|
||||
}
|
||||
|
||||
private NodeInvokeResponse HandleSettingsGet(NodeInvokeRequest request)
|
||||
{
|
||||
var name = GetStringArg(request.Args, "name");
|
||||
if (string.IsNullOrEmpty(name))
|
||||
return Error("Missing required arg: name");
|
||||
if (SettingsGetHandler == null)
|
||||
return Error("Settings handler not registered");
|
||||
return Success(SettingsGetHandler(name));
|
||||
}
|
||||
|
||||
private NodeInvokeResponse HandleSettingsSet(NodeInvokeRequest request)
|
||||
{
|
||||
var name = GetStringArg(request.Args, "name");
|
||||
var value = GetStringArg(request.Args, "value");
|
||||
if (string.IsNullOrEmpty(name))
|
||||
return Error("Missing required arg: name");
|
||||
if (value == null)
|
||||
return Error("Missing required arg: value");
|
||||
if (SettingsSetHandler == null)
|
||||
return Error("Settings handler not registered");
|
||||
return Success(SettingsSetHandler(name, value));
|
||||
}
|
||||
|
||||
private NodeInvokeResponse HandleMenu()
|
||||
{
|
||||
if (MenuHandler == null)
|
||||
return Error("Menu handler not registered");
|
||||
return Success(MenuHandler());
|
||||
}
|
||||
|
||||
private NodeInvokeResponse HandleSearch(NodeInvokeRequest request)
|
||||
{
|
||||
var query = GetStringArg(request.Args, "query");
|
||||
if (string.IsNullOrEmpty(query))
|
||||
return Error("Missing required arg: query");
|
||||
if (SearchHandler == null)
|
||||
return Error("Search handler not registered");
|
||||
return Success(SearchHandler(query));
|
||||
}
|
||||
}
|
||||
@ -60,7 +60,7 @@ public class CameraCapability : NodeCapabilityBase
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Camera list failed", ex);
|
||||
return Error("List failed");
|
||||
return Error($"List failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@ -106,7 +106,7 @@ public class CameraCapability : NodeCapabilityBase
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Camera snap failed", ex);
|
||||
return Error("Snap failed");
|
||||
return Error($"Snap failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@ -147,7 +147,7 @@ public class CameraCapability : NodeCapabilityBase
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Camera clip failed", ex);
|
||||
return Error("Clip failed");
|
||||
return Error($"Clip failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -404,10 +404,8 @@ public class CanvasCapability : NodeCapabilityBase
|
||||
}
|
||||
|
||||
using var stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
// GetFinalPathFromHandle is a Windows-only guard (returns "" on non-Windows); skip the
|
||||
// containment check when no resolved path is available — prior symlink resolution covers that case.
|
||||
var finalPath = GetFinalPathFromHandle(stream.SafeFileHandle);
|
||||
if (!string.IsNullOrEmpty(finalPath) && !IsPathWithinRoot(finalPath, tempRoot))
|
||||
if (!IsPathWithinRoot(finalPath, tempRoot))
|
||||
{
|
||||
Logger.Warn($"{command}: jsonlPath file handle resolves outside temp directory: {finalPath}");
|
||||
throw new InvalidOperationException("jsonlPath must resolve within the system temp directory");
|
||||
|
||||
@ -64,7 +64,7 @@ public class LocationCapability : NodeCapabilityBase
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("location.get failed", ex);
|
||||
return Error("Location failed");
|
||||
return Error($"Location failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -84,7 +84,7 @@ public class ScreenCapability : NodeCapabilityBase
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Screen capture failed", ex);
|
||||
return Error("Capture failed");
|
||||
return Error($"Capture failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@ -134,7 +134,7 @@ public class ScreenCapability : NodeCapabilityBase
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Screen recording failed", ex);
|
||||
return Error("Recording failed");
|
||||
return Error($"Recording failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,339 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace OpenClaw.Shared.Capabilities;
|
||||
|
||||
/// <summary>
|
||||
/// Speech-to-text node capability. Three commands:
|
||||
///
|
||||
/// * <see cref="TranscribeCommand"/> — bounded fixed-duration capture + transcription.
|
||||
/// Caller must specify <c>maxDurationMs</c> (capped at <see cref="MaxTranscribeDurationMs"/>).
|
||||
/// Useful for quick "give me 5 seconds of audio" prompts.
|
||||
///
|
||||
/// * <see cref="ListenCommand"/> — VAD-driven capture that returns when speech ends
|
||||
/// or after <c>timeoutMs</c> (default <see cref="DefaultListenTimeoutMs"/>, range
|
||||
/// <see cref="MinListenTimeoutMs"/>..<see cref="MaxListenTimeoutMs"/>).
|
||||
/// Useful for conversational "listen until I stop talking" prompts.
|
||||
///
|
||||
/// * <see cref="StatusCommand"/> — reports engine readiness (no PII).
|
||||
///
|
||||
/// The actual engine lives in the tray (Whisper.net + NAudio + Silero VAD).
|
||||
/// Whisper is local-first and privacy-respecting; the legacy WinRT
|
||||
/// <c>SpeechRecognizer</c> + desktop SAPI fallback was removed because both
|
||||
/// stacks are old, can leak audio to the Microsoft cloud (online-speech),
|
||||
/// and don't work in unpackaged builds.
|
||||
///
|
||||
/// **Privacy invariants for the response surface:**
|
||||
/// - Validation errors never echo the caller-supplied language string.
|
||||
/// - Handler exceptions never propagate their <c>Message</c> into the response;
|
||||
/// full detail stays in the local logger only. This is critical because
|
||||
/// failed-invoke errors land in recent activity / support bundles.
|
||||
/// - <see cref="StatusCommand"/> response carries no PII (no transcript fragments,
|
||||
/// no language history, no device IDs, no model paths).
|
||||
/// </summary>
|
||||
public sealed class SttCapability : NodeCapabilityBase
|
||||
{
|
||||
public const string TranscribeCommand = "stt.transcribe";
|
||||
public const string ListenCommand = "stt.listen";
|
||||
public const string StatusCommand = "stt.status";
|
||||
|
||||
public const int MaxTranscribeDurationMs = 30_000;
|
||||
public const int MinListenTimeoutMs = 1_000;
|
||||
public const int MaxListenTimeoutMs = 120_000;
|
||||
public const int DefaultListenTimeoutMs = 30_000;
|
||||
|
||||
public const string DefaultLanguage = "en-US";
|
||||
public const string AutoLanguage = "auto";
|
||||
|
||||
/// <summary>
|
||||
/// Engine identifier returned in <c>engineEffective</c> on every successful
|
||||
/// stt.* response. Currently always <c>"whisper"</c>; the field exists so
|
||||
/// adding a future engine doesn't break the wire shape.
|
||||
/// </summary>
|
||||
public const string EngineWhisper = "whisper";
|
||||
|
||||
private static readonly string[] _commands = [TranscribeCommand, ListenCommand, StatusCommand];
|
||||
|
||||
// Conservative BCP-47 check: 2-3 letter language, optional script
|
||||
// (4 letter), optional region (2 letter or 3 digit), each separated
|
||||
// by a hyphen. Rejects whitespace and punctuation that would otherwise
|
||||
// trip Windows.Globalization.Language ctor. The literal "auto"
|
||||
// sentinel is accepted in addition (Whisper supports auto-detect).
|
||||
private static readonly Regex BcpTagRegex = new(
|
||||
"^[A-Za-z]{2,3}(?:-[A-Za-z]{4})?(?:-(?:[A-Za-z]{2}|[0-9]{3}))?$",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
public override string Category => "stt";
|
||||
public override IReadOnlyList<string> Commands => _commands;
|
||||
|
||||
/// <summary>
|
||||
/// Tray-side handler for <see cref="TranscribeCommand"/>: bounded fixed-duration
|
||||
/// capture + transcription.
|
||||
/// </summary>
|
||||
public event Func<SttTranscribeArgs, CancellationToken, Task<SttTranscribeResult>>? TranscribeRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Tray-side handler for <see cref="ListenCommand"/>: VAD-driven capture that
|
||||
/// returns on end-of-speech or after <c>timeoutMs</c>.
|
||||
/// </summary>
|
||||
public event Func<SttListenArgs, CancellationToken, Task<SttListenResult>>? ListenRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Tray-side handler for <see cref="StatusCommand"/>: returns per-engine readiness.
|
||||
/// </summary>
|
||||
public event Func<CancellationToken, Task<SttStatusResult>>? StatusRequested;
|
||||
|
||||
public SttCapability(IOpenClawLogger logger) : base(logger) { }
|
||||
|
||||
/// <summary>
|
||||
/// Trim and validate a single language tag. Returns the trimmed tag on
|
||||
/// success, the literal <see cref="AutoLanguage"/> sentinel on a case-insensitive
|
||||
/// "auto" input, or <c>null</c> if the input is neither.
|
||||
/// Public so UI surfaces can validate against the same rule the wire applies.
|
||||
/// </summary>
|
||||
public static string? NormalizeLanguageTag(string tag)
|
||||
{
|
||||
var trimmed = tag.Trim();
|
||||
if (string.Equals(trimmed, AutoLanguage, StringComparison.OrdinalIgnoreCase))
|
||||
return AutoLanguage;
|
||||
return BcpTagRegex.IsMatch(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the language to use for a recognition call: per-call argument
|
||||
/// wins, then configured setting, then <see cref="DefaultLanguage"/>.
|
||||
/// Returns <c>null</c> if the resolved string fails validation.
|
||||
/// </summary>
|
||||
public static string? ResolveLanguage(string? requested, string? configured)
|
||||
{
|
||||
var candidate = !string.IsNullOrWhiteSpace(requested)
|
||||
? requested
|
||||
: (!string.IsNullOrWhiteSpace(configured) ? configured : DefaultLanguage);
|
||||
|
||||
return NormalizeLanguageTag(candidate!);
|
||||
}
|
||||
|
||||
public override Task<NodeInvokeResponse> ExecuteAsync(NodeInvokeRequest request)
|
||||
=> ExecuteAsync(request, CancellationToken.None);
|
||||
|
||||
public override async Task<NodeInvokeResponse> ExecuteAsync(
|
||||
NodeInvokeRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return request.Command switch
|
||||
{
|
||||
TranscribeCommand => await HandleTranscribeAsync(request, cancellationToken).ConfigureAwait(false),
|
||||
ListenCommand => await HandleListenAsync(request, cancellationToken).ConfigureAwait(false),
|
||||
StatusCommand => await HandleStatusAsync(cancellationToken).ConfigureAwait(false),
|
||||
_ => Error($"Unknown command: {request.Command}")
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<NodeInvokeResponse> HandleTranscribeAsync(
|
||||
NodeInvokeRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// maxDurationMs is required and bounded server-side. We deliberately
|
||||
// reject 0/negative rather than substituting a default — callers
|
||||
// explicitly choose how much mic time they're spending.
|
||||
var maxDurationMs = GetIntArg(request.Args, "maxDurationMs", 0);
|
||||
if (maxDurationMs <= 0)
|
||||
return Error("Missing required maxDurationMs");
|
||||
if (maxDurationMs > MaxTranscribeDurationMs)
|
||||
return Error($"maxDurationMs exceeds {MaxTranscribeDurationMs} ms");
|
||||
|
||||
var requestedLanguage = GetStringArg(request.Args, "language");
|
||||
string? resolvedLanguage = null;
|
||||
if (!string.IsNullOrWhiteSpace(requestedLanguage))
|
||||
{
|
||||
resolvedLanguage = NormalizeLanguageTag(requestedLanguage);
|
||||
if (resolvedLanguage == null)
|
||||
return Error("Invalid language tag");
|
||||
}
|
||||
|
||||
if (TranscribeRequested == null)
|
||||
return Error("STT transcribe not available");
|
||||
|
||||
var args = new SttTranscribeArgs
|
||||
{
|
||||
MaxDurationMs = maxDurationMs,
|
||||
Language = resolvedLanguage // null lets the tray fall back to its configured setting
|
||||
};
|
||||
|
||||
Logger.Info($"stt.transcribe: maxDurationMs={args.MaxDurationMs}, language={args.Language ?? "(default)"}");
|
||||
|
||||
try
|
||||
{
|
||||
var result = await TranscribeRequested(args, cancellationToken).ConfigureAwait(false);
|
||||
return Success(new
|
||||
{
|
||||
transcribed = result.Transcribed,
|
||||
text = result.Text,
|
||||
durationMs = result.DurationMs,
|
||||
language = result.Language,
|
||||
engineEffective = result.EngineEffective
|
||||
});
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return Error("Transcribe canceled");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Privacy: never echo raw exception text into the response. The
|
||||
// exception flows through the failed-invoke path and may be
|
||||
// persisted to recent activity / support bundles. Full detail
|
||||
// stays in the local log only.
|
||||
Logger.Error("STT transcribe failed", ex);
|
||||
return Error("Transcribe failed");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<NodeInvokeResponse> HandleListenAsync(
|
||||
NodeInvokeRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// timeoutMs is optional with a sane default; bounded both ways so
|
||||
// a hostile caller can't pin the mic open for an hour.
|
||||
var timeoutMs = GetIntArg(request.Args, "timeoutMs", DefaultListenTimeoutMs);
|
||||
if (timeoutMs < MinListenTimeoutMs) timeoutMs = MinListenTimeoutMs;
|
||||
if (timeoutMs > MaxListenTimeoutMs) timeoutMs = MaxListenTimeoutMs;
|
||||
|
||||
var requestedLanguage = GetStringArg(request.Args, "language");
|
||||
string resolvedLanguage = AutoLanguage;
|
||||
if (!string.IsNullOrWhiteSpace(requestedLanguage))
|
||||
{
|
||||
var normalized = NormalizeLanguageTag(requestedLanguage);
|
||||
if (normalized == null)
|
||||
return Error("Invalid language tag");
|
||||
resolvedLanguage = normalized;
|
||||
}
|
||||
|
||||
if (ListenRequested == null)
|
||||
return Error("STT listen not available");
|
||||
|
||||
var args = new SttListenArgs
|
||||
{
|
||||
TimeoutMs = timeoutMs,
|
||||
Language = resolvedLanguage
|
||||
};
|
||||
|
||||
Logger.Info($"stt.listen: timeoutMs={timeoutMs}, language={resolvedLanguage}");
|
||||
|
||||
try
|
||||
{
|
||||
var result = await ListenRequested(args, cancellationToken).ConfigureAwait(false);
|
||||
return Success(new
|
||||
{
|
||||
text = result.Text,
|
||||
language = result.Language,
|
||||
durationMs = result.DurationMs,
|
||||
segments = result.Segments,
|
||||
engineEffective = result.EngineEffective
|
||||
});
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return Error("Listen canceled");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Same privacy invariant as Transcribe.
|
||||
Logger.Error("STT listen failed", ex);
|
||||
return Error("Listen failed");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<NodeInvokeResponse> HandleStatusAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (StatusRequested == null)
|
||||
return Error("STT status not available");
|
||||
|
||||
try
|
||||
{
|
||||
var result = await StatusRequested(cancellationToken).ConfigureAwait(false);
|
||||
return Success(new
|
||||
{
|
||||
engine = result.Engine,
|
||||
readiness = result.Readiness,
|
||||
modelDownloadProgress = result.ModelDownloadProgress,
|
||||
isListenWithVadSupported = result.IsListenWithVadSupported,
|
||||
isBoundedTranscribeSupported = result.IsBoundedTranscribeSupported
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Status must not leak engine internals; carry only a fixed message.
|
||||
Logger.Error("STT status failed", ex);
|
||||
return Error("Status failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SttTranscribeArgs
|
||||
{
|
||||
public int MaxDurationMs { get; set; }
|
||||
/// <summary>
|
||||
/// BCP-47 tag (e.g., "en-US"), the literal "auto" sentinel, or null
|
||||
/// to let the tray fall back to its configured <c>SttLanguage</c> setting.
|
||||
/// </summary>
|
||||
public string? Language { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SttTranscribeResult
|
||||
{
|
||||
public bool Transcribed { get; set; }
|
||||
public string Text { get; set; } = "";
|
||||
public int DurationMs { get; set; }
|
||||
public string Language { get; set; } = SttCapability.DefaultLanguage;
|
||||
|
||||
/// <summary>
|
||||
/// Engine that served this call. Always <see cref="SttCapability.EngineWhisper"/>
|
||||
/// today; the field exists so a future engine doesn't break the wire.
|
||||
/// </summary>
|
||||
public string EngineEffective { get; set; } = SttCapability.EngineWhisper;
|
||||
}
|
||||
|
||||
public sealed class SttListenArgs
|
||||
{
|
||||
public int TimeoutMs { get; set; }
|
||||
/// <summary>
|
||||
/// BCP-47 tag (e.g., "en-US"), or the literal "auto" sentinel
|
||||
/// (default; lets Whisper auto-detect).
|
||||
/// </summary>
|
||||
public string Language { get; set; } = SttCapability.AutoLanguage;
|
||||
}
|
||||
|
||||
public sealed class SttListenResult
|
||||
{
|
||||
public string Text { get; set; } = "";
|
||||
public string Language { get; set; } = SttCapability.AutoLanguage;
|
||||
public int DurationMs { get; set; }
|
||||
public IReadOnlyList<SttSegment> Segments { get; set; } = Array.Empty<SttSegment>();
|
||||
|
||||
public string EngineEffective { get; set; } = SttCapability.EngineWhisper;
|
||||
}
|
||||
|
||||
public sealed class SttSegment
|
||||
{
|
||||
public string Text { get; set; } = "";
|
||||
public int StartMs { get; set; }
|
||||
public int EndMs { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SttStatusResult
|
||||
{
|
||||
public string Engine { get; set; } = SttCapability.EngineWhisper;
|
||||
|
||||
/// <summary>One of "ready", "initializing", "model-downloading", "model-not-downloaded", "unavailable".</summary>
|
||||
public string Readiness { get; set; } = "unavailable";
|
||||
|
||||
/// <summary>0..1 download progress when <see cref="Readiness"/> == "model-downloading"; null otherwise.</summary>
|
||||
public double? ModelDownloadProgress { get; set; }
|
||||
|
||||
public bool IsListenWithVadSupported { get; set; }
|
||||
public bool IsBoundedTranscribeSupported { get; set; }
|
||||
}
|
||||
@ -271,7 +271,7 @@ public class SystemCapability : NodeCapabilityBase
|
||||
{
|
||||
// Rail 1: no silent fallback — handler exceptions become typed denies.
|
||||
Logger.Error($"[system.run] corr={correlationId} path=v2 handler threw", ex);
|
||||
v2Result = ExecApprovalV2Result.ValidationFailed("Handler exception");
|
||||
v2Result = ExecApprovalV2Result.ValidationFailed($"Handler exception: {ex.Message}");
|
||||
}
|
||||
|
||||
Logger.Info($"[system.run] corr={correlationId} decision={v2Result.Code} reason={v2Result.Reason}");
|
||||
@ -413,7 +413,7 @@ public class SystemCapability : NodeCapabilityBase
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("system.run failed", ex);
|
||||
return Error("Execution failed");
|
||||
return Error($"Execution failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@ -614,7 +614,7 @@ public class SystemCapability : NodeCapabilityBase
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("execApprovals.set failed", ex);
|
||||
return Error("Failed to update policy");
|
||||
return Error($"Failed to update policy: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -10,11 +10,6 @@ public sealed class TtsCapability : NodeCapabilityBase
|
||||
public const string SpeakCommand = "tts.speak";
|
||||
public const string WindowsProvider = "windows";
|
||||
public const string ElevenLabsProvider = "elevenlabs";
|
||||
/// <summary>
|
||||
/// Local neural TTS via Sherpa-ONNX wrapping Piper voices. No network
|
||||
/// egress; voice models download once to %LOCALAPPDATA%.
|
||||
/// </summary>
|
||||
public const string PiperProvider = "piper";
|
||||
public const int MaxTextLength = 5000;
|
||||
|
||||
private static readonly string[] _commands = [SpeakCommand];
|
||||
@ -35,7 +30,7 @@ public sealed class TtsCapability : NodeCapabilityBase
|
||||
: requestedProvider;
|
||||
|
||||
return string.IsNullOrWhiteSpace(provider)
|
||||
? PiperProvider
|
||||
? WindowsProvider
|
||||
: provider.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
@ -86,14 +81,8 @@ public sealed class TtsCapability : NodeCapabilityBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Privacy: never echo raw exception text into the response. The
|
||||
// exception flows through the failed-invoke path and may be
|
||||
// persisted to recent activity / support bundles. ElevenLabs
|
||||
// error messages can contain key prefixes; OS speech errors
|
||||
// can contain device names. Full detail stays in the local
|
||||
// log only. (Same pattern as SttCapability.)
|
||||
Logger.Error("TTS speak failed", ex);
|
||||
return Error("Speak failed");
|
||||
return Error($"Speak failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -20,13 +20,10 @@ public static class DeepLinkParser
|
||||
if (!uri.StartsWith(Scheme, StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
var remainder = uri[Scheme.Length..];
|
||||
var remainder = uri[Scheme.Length..].TrimEnd('/');
|
||||
var queryIndex = remainder.IndexOf('?');
|
||||
var query = queryIndex >= 0 ? remainder[(queryIndex + 1)..] : "";
|
||||
// Trim trailing slash AFTER splitting off the query so the
|
||||
// Windows-canonicalized form `openclaw://send/?args=...` (slash
|
||||
// BEFORE the `?`) yields path "send", not "send/".
|
||||
var path = (queryIndex >= 0 ? remainder[..queryIndex] : remainder).TrimEnd('/');
|
||||
var path = queryIndex >= 0 ? remainder[..queryIndex] : remainder;
|
||||
|
||||
var parameters = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var part in query.Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||
|
||||
@ -1,11 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using OpenClaw.Shared.Mcp;
|
||||
using NSec.Cryptography;
|
||||
|
||||
namespace OpenClaw.Shared;
|
||||
@ -21,25 +18,15 @@ public class DeviceIdentity
|
||||
private PublicKey? _publicKey;
|
||||
private string? _deviceId;
|
||||
private string? _deviceToken;
|
||||
private string[]? _deviceTokenScopes;
|
||||
private string? _nodeDeviceToken;
|
||||
private string[]? _nodeDeviceTokenScopes;
|
||||
|
||||
private static readonly SignatureAlgorithm Ed25519Algorithm = SignatureAlgorithm.Ed25519;
|
||||
|
||||
public string DeviceId => _deviceId ?? throw new InvalidOperationException("Device not initialized");
|
||||
public string PublicKeyBase64Url => _publicKey != null ? Base64UrlEncode(_publicKey.Export(KeyBlobFormat.RawPublicKey)) : throw new InvalidOperationException("Device not initialized");
|
||||
public string? DeviceToken => _deviceToken;
|
||||
public IReadOnlyList<string>? DeviceTokenScopes => _deviceTokenScopes;
|
||||
public string? NodeDeviceToken => _nodeDeviceToken;
|
||||
public IReadOnlyList<string>? NodeDeviceTokenScopes => _nodeDeviceTokenScopes;
|
||||
|
||||
public static string? TryReadStoredDeviceToken(string dataPath, IOpenClawLogger? logger = null) =>
|
||||
TryReadStoredDeviceTokenForRole(dataPath, "operator", logger);
|
||||
|
||||
public static string? TryReadStoredDeviceTokenForRole(string dataPath, string role, IOpenClawLogger? logger = null)
|
||||
public static string? TryReadStoredDeviceToken(string dataPath, IOpenClawLogger? logger = null)
|
||||
{
|
||||
var tokenRole = ParseDeviceTokenRole(role);
|
||||
var keyPath = Path.Combine(dataPath, "device-key-ed25519.json");
|
||||
if (!File.Exists(keyPath))
|
||||
{
|
||||
@ -49,11 +36,7 @@ public class DeviceIdentity
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(keyPath));
|
||||
var tokenPropertyName = tokenRole == DeviceTokenRole.Node
|
||||
? nameof(DeviceKeyData.NodeDeviceToken)
|
||||
: nameof(DeviceKeyData.DeviceToken);
|
||||
|
||||
if (doc.RootElement.TryGetProperty(tokenPropertyName, out var deviceToken) &&
|
||||
if (doc.RootElement.TryGetProperty(nameof(DeviceKeyData.DeviceToken), out var deviceToken) &&
|
||||
deviceToken.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var value = deviceToken.GetString();
|
||||
@ -78,9 +61,6 @@ public class DeviceIdentity
|
||||
|
||||
public static bool HasStoredDeviceToken(string dataPath, IOpenClawLogger? logger = null) =>
|
||||
!string.IsNullOrWhiteSpace(TryReadStoredDeviceToken(dataPath, logger));
|
||||
|
||||
public static bool HasStoredDeviceTokenForRole(string dataPath, string role, IOpenClawLogger? logger = null) =>
|
||||
!string.IsNullOrWhiteSpace(TryReadStoredDeviceTokenForRole(dataPath, role, logger));
|
||||
|
||||
public DeviceIdentity(string dataPath, IOpenClawLogger? logger = null)
|
||||
{
|
||||
@ -122,9 +102,6 @@ public class DeviceIdentity
|
||||
_publicKey = _privateKey.PublicKey;
|
||||
_deviceId = data.DeviceId;
|
||||
_deviceToken = data.DeviceToken;
|
||||
_deviceTokenScopes = NormalizeScopes(data.DeviceTokenScopes);
|
||||
_nodeDeviceToken = data.NodeDeviceToken;
|
||||
_nodeDeviceTokenScopes = NormalizeScopes(data.NodeDeviceTokenScopes);
|
||||
|
||||
_logger.Info($"Loaded Ed25519 device identity: {_deviceId?[..16]}...");
|
||||
}
|
||||
@ -169,11 +146,8 @@ public class DeviceIdentity
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
McpAuthToken.TryRestrictDataDirectoryAcl(dir);
|
||||
|
||||
File.WriteAllText(_keyPath, JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }));
|
||||
McpAuthToken.TryRestrictSensitiveFileAcl(_keyPath);
|
||||
_logger.Info($"Generated new Ed25519 device identity: {_deviceId}");
|
||||
}
|
||||
|
||||
@ -333,40 +307,7 @@ public class DeviceIdentity
|
||||
/// </summary>
|
||||
public void StoreDeviceToken(string token)
|
||||
{
|
||||
StoreDeviceTokenCore(token, null);
|
||||
}
|
||||
|
||||
public void StoreDeviceTokenWithScopes(string token, IEnumerable<string>? scopes)
|
||||
{
|
||||
StoreDeviceTokenCore(token, NormalizeScopes(scopes));
|
||||
}
|
||||
|
||||
public void StoreDeviceTokenForRole(string role, string token, IEnumerable<string>? scopes = null)
|
||||
{
|
||||
var tokenRole = ParseDeviceTokenRole(role);
|
||||
if (tokenRole == DeviceTokenRole.Node)
|
||||
{
|
||||
StoreNodeDeviceTokenCore(token, NormalizeScopes(scopes));
|
||||
return;
|
||||
}
|
||||
|
||||
StoreDeviceTokenCore(token, NormalizeScopes(scopes));
|
||||
}
|
||||
|
||||
private static DeviceTokenRole ParseDeviceTokenRole(string role) => role switch
|
||||
{
|
||||
"operator" => DeviceTokenRole.Operator,
|
||||
"node" => DeviceTokenRole.Node,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(role), "Device token role must be 'operator' or 'node'.")
|
||||
};
|
||||
|
||||
private void StoreDeviceTokenCore(string token, string[]? scopes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
throw new ArgumentException("Device token cannot be empty.", nameof(token));
|
||||
|
||||
_deviceToken = token;
|
||||
_deviceTokenScopes = scopes;
|
||||
|
||||
// Update the key file with the token
|
||||
try
|
||||
@ -378,9 +319,7 @@ public class DeviceIdentity
|
||||
if (data != null)
|
||||
{
|
||||
data.DeviceToken = token;
|
||||
data.DeviceTokenScopes = scopes;
|
||||
File.WriteAllText(_keyPath, JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }));
|
||||
McpAuthToken.TryRestrictSensitiveFileAcl(_keyPath);
|
||||
_logger.Info("Device token stored");
|
||||
}
|
||||
}
|
||||
@ -390,48 +329,6 @@ public class DeviceIdentity
|
||||
_logger.Error($"Failed to store device token: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void StoreNodeDeviceTokenCore(string token, string[]? scopes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
throw new ArgumentException("Device token cannot be empty.", nameof(token));
|
||||
|
||||
_nodeDeviceToken = token;
|
||||
_nodeDeviceTokenScopes = scopes;
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(_keyPath))
|
||||
{
|
||||
var json = File.ReadAllText(_keyPath);
|
||||
var data = JsonSerializer.Deserialize<DeviceKeyData>(json);
|
||||
if (data != null)
|
||||
{
|
||||
data.NodeDeviceToken = token;
|
||||
data.NodeDeviceTokenScopes = scopes;
|
||||
File.WriteAllText(_keyPath, JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }));
|
||||
_logger.Info("Node device token stored");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Failed to store node device token: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string[]? NormalizeScopes(IEnumerable<string>? scopes)
|
||||
{
|
||||
if (scopes == null)
|
||||
return null;
|
||||
|
||||
var normalized = scopes
|
||||
.Where(scope => !string.IsNullOrWhiteSpace(scope))
|
||||
.Select(scope => scope.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
return normalized.Length == 0 ? null : normalized;
|
||||
}
|
||||
|
||||
private static string Base64UrlEncode(byte[] data)
|
||||
{
|
||||
@ -441,21 +338,12 @@ public class DeviceIdentity
|
||||
.TrimEnd('=');
|
||||
}
|
||||
|
||||
private enum DeviceTokenRole
|
||||
{
|
||||
Operator,
|
||||
Node
|
||||
}
|
||||
|
||||
private class DeviceKeyData
|
||||
{
|
||||
public string? PrivateKeyBase64 { get; set; }
|
||||
public string? PublicKeyBase64 { get; set; }
|
||||
public string? DeviceId { get; set; }
|
||||
public string? DeviceToken { get; set; }
|
||||
public string[]? DeviceTokenScopes { get; set; }
|
||||
public string? NodeDeviceToken { get; set; }
|
||||
public string[]? NodeDeviceTokenScopes { get; set; }
|
||||
public string? Algorithm { get; set; }
|
||||
public long CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
@ -249,8 +249,14 @@ public class ExecApprovalPolicy
|
||||
var dir = Path.GetDirectoryName(_policyFilePath);
|
||||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
var json = JsonSerializer.Serialize(GetPolicyData(), _jsonOptions);
|
||||
|
||||
var data = new ExecPolicyData
|
||||
{
|
||||
DefaultAction = _defaultAction,
|
||||
Rules = _rules
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(data, _jsonOptions);
|
||||
File.WriteAllText(_policyFilePath, json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@ -1,70 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenClaw.Shared.ExecApprovals;
|
||||
|
||||
// Architectural barrier produced by PR3.
|
||||
// Equivalent to ExecHostValidatedRequest in the macOS reference, extended with resolution outputs.
|
||||
// No module from PR4 onward may accept ValidatedRunRequest as direct input (research doc 05 line 439).
|
||||
// Rail 15: a single canonical representation reused across evaluation, logging, prompting, execution.
|
||||
public sealed class CanonicalCommandIdentity
|
||||
{
|
||||
// ── Normalization outputs ─────────────────────────────────────────────────
|
||||
|
||||
// Argv exactly as produced by PR2 (no trimming; coding contract process-argv-semantics).
|
||||
public IReadOnlyList<string> Command { get; }
|
||||
|
||||
// Canonical display form generated from argv. Never rawCommand from the agent.
|
||||
// Used by logging and prompting. Research doc 05 decision 2.
|
||||
public string DisplayCommand { get; }
|
||||
|
||||
// Safe rawCommand for executable resolution. Null in Windows v1 (rawCommand not in
|
||||
// system.run protocol; research doc 05 OQ-V4 / decision 10).
|
||||
public string? EvaluationRawCommand { get; }
|
||||
|
||||
// ── Resolution outputs ────────────────────────────────────────────────────
|
||||
|
||||
// Singular resolution for the state machine (PR5).
|
||||
// Null if the primary executable cannot be determined.
|
||||
public ExecCommandResolution? Resolution { get; }
|
||||
|
||||
// Per-segment resolutions for the allowlist matcher (PR4/PR5).
|
||||
// Empty list means fail-closed — no allowlist satisfaction possible.
|
||||
public IReadOnlyList<ExecCommandResolution> AllowlistResolutions { get; }
|
||||
|
||||
// Suggested allowlist patterns for prompt/UI (PR6). Not a security decision.
|
||||
public IReadOnlyList<string> AllowAlwaysPatterns { get; }
|
||||
|
||||
// ── Request context (carried from ValidatedRunRequest) ────────────────────
|
||||
|
||||
public string? Cwd { get; }
|
||||
public int TimeoutMs { get; }
|
||||
public IReadOnlyDictionary<string, string>? Env { get; }
|
||||
public string? AgentId { get; }
|
||||
public string? SessionKey { get; }
|
||||
|
||||
internal CanonicalCommandIdentity(
|
||||
IReadOnlyList<string> command,
|
||||
string displayCommand,
|
||||
string? evaluationRawCommand,
|
||||
ExecCommandResolution? resolution,
|
||||
IReadOnlyList<ExecCommandResolution> allowlistResolutions,
|
||||
IReadOnlyList<string> allowAlwaysPatterns,
|
||||
string? cwd,
|
||||
int timeoutMs,
|
||||
IReadOnlyDictionary<string, string>? env,
|
||||
string? agentId,
|
||||
string? sessionKey)
|
||||
{
|
||||
Command = command;
|
||||
DisplayCommand = displayCommand;
|
||||
EvaluationRawCommand = evaluationRawCommand;
|
||||
Resolution = resolution;
|
||||
AllowlistResolutions = allowlistResolutions;
|
||||
AllowAlwaysPatterns = allowAlwaysPatterns;
|
||||
Cwd = cwd;
|
||||
TimeoutMs = timeoutMs;
|
||||
Env = env;
|
||||
AgentId = agentId;
|
||||
SessionKey = sessionKey;
|
||||
}
|
||||
}
|
||||
@ -1,85 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenClaw.Shared.ExecApprovals;
|
||||
|
||||
// Either a CanonicalCommandIdentity (IsResolved=true) or a typed denial (IsResolved=false).
|
||||
// Produced by ExecApprovalV2Normalizer; consumed by the coordinator pipeline (PR7).
|
||||
public sealed class ExecApprovalV2NormalizationOutcome
|
||||
{
|
||||
public bool IsResolved { get; }
|
||||
public CanonicalCommandIdentity? Identity { get; }
|
||||
public ExecApprovalV2Result? Error { get; }
|
||||
|
||||
private ExecApprovalV2NormalizationOutcome(CanonicalCommandIdentity identity)
|
||||
{
|
||||
IsResolved = true;
|
||||
Identity = identity;
|
||||
}
|
||||
|
||||
private ExecApprovalV2NormalizationOutcome(ExecApprovalV2Result error)
|
||||
{
|
||||
IsResolved = false;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
public static ExecApprovalV2NormalizationOutcome Ok(CanonicalCommandIdentity identity)
|
||||
=> new(identity);
|
||||
|
||||
public static ExecApprovalV2NormalizationOutcome Fail(ExecApprovalV2Result error)
|
||||
=> new(error);
|
||||
}
|
||||
|
||||
// Rail 18 steps 2-4: normalize command form → resolve executable → build canonical identity.
|
||||
// Stateless — safe to call concurrently.
|
||||
public static class ExecApprovalV2Normalizer
|
||||
{
|
||||
public static ExecApprovalV2NormalizationOutcome Normalize(ValidatedRunRequest request)
|
||||
{
|
||||
var argv = request.Argv;
|
||||
var cwd = request.Cwd;
|
||||
var env = request.Env as IReadOnlyDictionary<string, string>;
|
||||
|
||||
// displayCommand is always derived from argv, never from rawCommand (research doc 05 decision 2).
|
||||
var displayCommand = ShellQuoting.FormatExecCommand(argv);
|
||||
|
||||
// rawCommand is null in Windows v1 (system.run does not carry it; research doc 05 OQ-V4).
|
||||
// EvaluationRawCommand stays null — correct and documented conservative output.
|
||||
string? evaluationRawCommand = null;
|
||||
|
||||
// Singular resolution for state machine.
|
||||
var resolution = ExecCommandResolver.Resolve(argv, cwd, env);
|
||||
|
||||
// Multi-segment resolution for allowlist.
|
||||
// Empty list is fail-closed: no allowlist satisfaction possible (research doc 04 R2).
|
||||
// An empty list is NOT itself a denial at this step — the evaluator decides.
|
||||
var allowlistResolutions = ExecCommandResolver.ResolveForAllowlist(
|
||||
argv, evaluationRawCommand, cwd, env);
|
||||
|
||||
// UX patterns for prompting.
|
||||
var allowAlwaysPatterns = ExecCommandResolver.ResolveAllowAlwaysPatterns(argv, cwd, env);
|
||||
|
||||
// Rail 6: if argv is non-empty but resolution is entirely impossible, deny.
|
||||
// "Ambiguous or inconsistent" → typed deny, not silent allow.
|
||||
if (resolution is null && allowlistResolutions.Count == 0)
|
||||
return Fail("executable-resolution-failed");
|
||||
|
||||
var identity = new CanonicalCommandIdentity(
|
||||
argv,
|
||||
displayCommand,
|
||||
evaluationRawCommand,
|
||||
resolution,
|
||||
allowlistResolutions,
|
||||
allowAlwaysPatterns,
|
||||
cwd,
|
||||
request.TimeoutMs,
|
||||
env,
|
||||
request.AgentId,
|
||||
request.SessionKey);
|
||||
|
||||
return ExecApprovalV2NormalizationOutcome.Ok(identity);
|
||||
}
|
||||
|
||||
private static ExecApprovalV2NormalizationOutcome Fail(string reason)
|
||||
=> ExecApprovalV2NormalizationOutcome.Fail(
|
||||
ExecApprovalV2Result.ResolutionFailed(reason));
|
||||
}
|
||||
@ -1,501 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace OpenClaw.Shared.ExecApprovals;
|
||||
|
||||
// Resolved identity of a single executable token.
|
||||
// Shape mirrors macOS ExecCommandResolution struct.
|
||||
public readonly record struct ExecCommandResolution(
|
||||
string RawExecutable,
|
||||
string? ResolvedPath,
|
||||
string ExecutableName,
|
||||
string? Cwd);
|
||||
|
||||
// The three resolution functions required by the pipeline.
|
||||
// resolve() → singular, for state machine
|
||||
// ResolveForAllowlist() → multi-segment, fail-closed, for allowlist matching
|
||||
// ResolveAllowAlwaysPatterns() → UX suggestions for prompt
|
||||
internal static class ExecCommandResolver
|
||||
{
|
||||
// Windows executable extensions, tried in order for basename search.
|
||||
private static readonly string[] s_extensions = [".exe", ".cmd", ".bat", ".com"];
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────────
|
||||
|
||||
// Singular resolution of the primary executable for the state machine.
|
||||
// Returns null if the command is empty or resolution is impossible.
|
||||
// Unwraps transparent env prefixes (no modifiers).
|
||||
internal static ExecCommandResolution? Resolve(
|
||||
IReadOnlyList<string> command,
|
||||
string? cwd,
|
||||
IReadOnlyDictionary<string, string>? env)
|
||||
{
|
||||
var effective = ExecEnvInvocationUnwrapper.UnwrapForResolution(command);
|
||||
if (effective.Count == 0) return null;
|
||||
var raw = effective[0].Trim();
|
||||
return raw.Length == 0 ? null : ResolveExecutable(raw, cwd, env);
|
||||
}
|
||||
|
||||
// Multi-segment resolution for allowlist matching.
|
||||
// Detects shell wrappers; splits payload chain; resolves one executable per segment.
|
||||
// Returns empty list (fail-closed) on any ambiguity, command substitution, or env manipulation.
|
||||
internal static IReadOnlyList<ExecCommandResolution> ResolveForAllowlist(
|
||||
IReadOnlyList<string> command,
|
||||
string? evaluationRawCommand,
|
||||
string? cwd,
|
||||
IReadOnlyDictionary<string, string>? env)
|
||||
{
|
||||
// Fail-closed: any env invocation with modifiers (flags or VAR=val assignments).
|
||||
// The allowlist cannot verify which executable will actually run under a modified env —
|
||||
// the resolver uses the original env while execution uses the modified one.
|
||||
// Subsumes the previous shell-wrapper-only check (Hanselman review finding #2).
|
||||
if (command.Count > 0
|
||||
&& ExecCommandToken.IsEnv(command[0].Trim())
|
||||
&& ExecEnvInvocationUnwrapper.HasModifiers(command))
|
||||
return [];
|
||||
|
||||
var wrapper = ExecShellWrapperNormalizer.Extract(command);
|
||||
if (wrapper.IsWrapper)
|
||||
{
|
||||
if (wrapper.InlineCommand is null) return [];
|
||||
var segments = SplitShellCommandChain(wrapper.InlineCommand);
|
||||
if (segments is null) return [];
|
||||
|
||||
var resolutions = new List<ExecCommandResolution>(segments.Count);
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
var token = ParseFirstToken(segment);
|
||||
if (token is null) return [];
|
||||
// -EncodedCommand and aliases in segment position: fail-closed (research doc 04 S1).
|
||||
if (SegmentUsesEncodedCommand(segment, token)) return [];
|
||||
var res = ResolveExecutable(token, cwd, env);
|
||||
if (res is null) return [];
|
||||
resolutions.Add(res.Value);
|
||||
}
|
||||
return resolutions;
|
||||
}
|
||||
|
||||
// Direct exec: fail-closed if powershell/pwsh invoked directly with -EncodedCommand.
|
||||
// Covers top-level `["powershell", "-enc", ...]` and transparent `["env", "pwsh", "-enc", ...]`.
|
||||
if (DirectExecUsesEncodedCommand(command)) return [];
|
||||
|
||||
var single = ResolveSingle(command, evaluationRawCommand, cwd, env);
|
||||
return single is null ? [] : [single.Value];
|
||||
}
|
||||
|
||||
// UX suggestions of allowlist patterns for prompting.
|
||||
// Unlike ResolveForAllowlist, this unwraps env with modifiers to surface the real executable.
|
||||
internal static IReadOnlyList<string> ResolveAllowAlwaysPatterns(
|
||||
IReadOnlyList<string> command,
|
||||
string? cwd,
|
||||
IReadOnlyDictionary<string, string>? env)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var patterns = new List<string>();
|
||||
CollectPatterns(command, cwd, env, seen, patterns, 0);
|
||||
return patterns;
|
||||
}
|
||||
|
||||
// ── Resolution helpers ───────────────────────────────────────────────────
|
||||
|
||||
private static ExecCommandResolution? ResolveSingle(
|
||||
IReadOnlyList<string> command,
|
||||
string? rawCommand,
|
||||
string? cwd,
|
||||
IReadOnlyDictionary<string, string>? env)
|
||||
{
|
||||
// Prefer first token of evaluationRawCommand when present.
|
||||
if (!string.IsNullOrWhiteSpace(rawCommand))
|
||||
{
|
||||
var token = ParseFirstToken(rawCommand);
|
||||
if (token is not null) return ResolveExecutable(token, cwd, env);
|
||||
}
|
||||
return Resolve(command, cwd, env);
|
||||
}
|
||||
|
||||
private static ExecCommandResolution? ResolveExecutable(
|
||||
string rawExecutable,
|
||||
string? cwd,
|
||||
IReadOnlyDictionary<string, string>? env)
|
||||
{
|
||||
try
|
||||
{
|
||||
var expanded = ExpandTilde(rawExecutable);
|
||||
var hasSep = expanded.Contains('/') || expanded.Contains('\\');
|
||||
|
||||
string? resolvedPath;
|
||||
if (hasSep)
|
||||
{
|
||||
// Reject paths with ':' in non-volume-separator positions (ADS, non-standard forms).
|
||||
if (HasNonStandardColon(expanded)) return null;
|
||||
|
||||
resolvedPath = Path.IsPathFullyQualified(expanded)
|
||||
? Path.GetFullPath(expanded)
|
||||
: Path.GetFullPath(expanded, string.IsNullOrWhiteSpace(cwd)
|
||||
? Directory.GetCurrentDirectory()
|
||||
: cwd.Trim());
|
||||
}
|
||||
else
|
||||
{
|
||||
resolvedPath = FindInPath(expanded, GetSearchPaths(env), GetPathExtensions(env));
|
||||
}
|
||||
|
||||
var name = resolvedPath is not null ? Path.GetFileName(resolvedPath) : expanded;
|
||||
return new ExecCommandResolution(expanded, resolvedPath, name, cwd);
|
||||
}
|
||||
catch { return null; } // fail-closed; intentionally broad — add diagnostic tracing here if needed
|
||||
}
|
||||
|
||||
// ── Shell command chain splitting ────────────────────────────────────────
|
||||
|
||||
// Splits a shell command string on ;, &&, ||, |, &, \n.
|
||||
// Returns null (fail-closed) on command/process substitution: $(...), `...`, <(...), >(...).
|
||||
// Returns null on unclosed quotes or unresolved escapes.
|
||||
private static IReadOnlyList<string>? SplitShellCommandChain(string command)
|
||||
{
|
||||
var trimmed = command.Trim();
|
||||
if (trimmed.Length == 0) return null;
|
||||
|
||||
var segments = new List<string>();
|
||||
var current = new StringBuilder();
|
||||
bool inSingle = false, inDouble = false, escaped = false;
|
||||
var chars = trimmed.ToCharArray();
|
||||
|
||||
for (var i = 0; i < chars.Length; i++)
|
||||
{
|
||||
var ch = chars[i];
|
||||
char? next = i + 1 < chars.Length ? chars[i + 1] : null;
|
||||
|
||||
if (escaped) { current.Append(ch); escaped = false; continue; }
|
||||
if (ch == '\\' && !inSingle) { current.Append(ch); escaped = true; continue; }
|
||||
if (ch == '\'' && !inDouble) { inSingle = !inSingle; current.Append(ch); continue; }
|
||||
if (ch == '"' && !inSingle) { inDouble = !inDouble; current.Append(ch); continue; }
|
||||
|
||||
// Fail-closed on command/process substitution.
|
||||
if (!inSingle && IsCommandSubstitution(ch, next, inDouble)) return null;
|
||||
|
||||
if (!inSingle && !inDouble)
|
||||
{
|
||||
var step = DelimiterStep(ch, i > 0 ? chars[i - 1] : (char?)null, next);
|
||||
if (step.HasValue)
|
||||
{
|
||||
var seg = current.ToString().Trim();
|
||||
if (seg.Length == 0) return null;
|
||||
segments.Add(seg);
|
||||
current.Clear();
|
||||
i += step.Value - 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
current.Append(ch);
|
||||
}
|
||||
|
||||
if (escaped || inSingle || inDouble) return null;
|
||||
|
||||
var last = current.ToString().Trim();
|
||||
if (last.Length == 0) return null;
|
||||
segments.Add(last);
|
||||
return segments;
|
||||
}
|
||||
|
||||
private static bool IsCommandSubstitution(char ch, char? next, bool inDouble)
|
||||
{
|
||||
if (inDouble) return ch == '`' || (ch == '$' && next == '(');
|
||||
return ch == '`' ||
|
||||
(ch == '$' && next == '(') ||
|
||||
(ch == '<' && next == '(') ||
|
||||
(ch == '>' && next == '(');
|
||||
}
|
||||
|
||||
private static int? DelimiterStep(char ch, char? prev, char? next)
|
||||
{
|
||||
if (ch == ';' || ch == '\n') return 1;
|
||||
if (ch == '&')
|
||||
{
|
||||
if (next == '&') return 2;
|
||||
return (prev == '>' || next == '>') ? null : (int?)1;
|
||||
}
|
||||
if (ch == '|')
|
||||
{
|
||||
if (next == '|' || next == '&') return 2;
|
||||
return 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extracts the first shell-tokenized word from a command string.
|
||||
private static string? ParseFirstToken(string command)
|
||||
{
|
||||
var trimmed = command.Trim();
|
||||
if (trimmed.Length == 0) return null;
|
||||
var first = trimmed[0];
|
||||
if (first == '"' || first == '\'')
|
||||
{
|
||||
var rest = trimmed.AsSpan(1);
|
||||
var end = rest.IndexOf(first);
|
||||
if (end < 0) return null; // unclosed quote — fail-closed; do not guess the token
|
||||
var inner = rest[..end].ToString();
|
||||
if (inner.Length == 0) return null;
|
||||
// Preserve any suffix after the closing quote up to the next whitespace.
|
||||
// Handles `"git".exe` → "git.exe" and `"C:\Program Files\Git\bin\git".exe` → *.exe.
|
||||
var afterClose = rest[(end + 1)..];
|
||||
var suffixEnd = afterClose.IndexOfAny(' ', '\t');
|
||||
var suffix = suffixEnd >= 0 ? afterClose[..suffixEnd].ToString() : afterClose.ToString();
|
||||
return suffix.Length > 0 ? inner + suffix : inner;
|
||||
}
|
||||
var space = trimmed.AsSpan().IndexOfAny(' ', '\t');
|
||||
return space >= 0 ? trimmed[..space] : trimmed;
|
||||
}
|
||||
|
||||
// ── allowAlwaysPatterns collection ───────────────────────────────────────
|
||||
|
||||
private static void CollectPatterns(
|
||||
IReadOnlyList<string> command,
|
||||
string? cwd,
|
||||
IReadOnlyDictionary<string, string>? env,
|
||||
HashSet<string> seen,
|
||||
List<string> patterns,
|
||||
int depth)
|
||||
{
|
||||
if (depth >= 3 || command.Count == 0) return;
|
||||
|
||||
var wrapper = ExecShellWrapperNormalizer.Extract(command);
|
||||
if (wrapper.IsWrapper && wrapper.InlineCommand is not null)
|
||||
{
|
||||
var segments = SplitShellCommandChain(wrapper.InlineCommand);
|
||||
if (segments is null) return;
|
||||
foreach (var seg in segments)
|
||||
{
|
||||
// allowAlwaysPatterns does NOT fail-closed on -EncodedCommand: it's UX only.
|
||||
var token = ParseFirstToken(seg);
|
||||
if (token is null) continue;
|
||||
var res = ResolveExecutable(token, cwd, env);
|
||||
if (res is null) continue;
|
||||
var pattern = res.Value.ResolvedPath ?? res.Value.RawExecutable;
|
||||
if (seen.Add(pattern)) patterns.Add(pattern);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// For direct exec, unwrap env including with-modifier cases for pattern discovery.
|
||||
var effective = ExecEnvInvocationUnwrapper.UnwrapForResolution(command);
|
||||
if (effective.Count == 0) return;
|
||||
var rawToken = effective[0].Trim();
|
||||
if (rawToken.Length == 0) return;
|
||||
var resolution = ResolveExecutable(rawToken, cwd, env);
|
||||
if (resolution is null) return;
|
||||
var pat = resolution.Value.ResolvedPath ?? resolution.Value.RawExecutable;
|
||||
if (seen.Add(pat)) patterns.Add(pat);
|
||||
}
|
||||
|
||||
// ── -EncodedCommand detection ─────────────────────────────────────────────
|
||||
|
||||
// Research doc 04 S1: if a chain segment invokes PowerShell with -EncodedCommand (or any
|
||||
// alias / unambiguous prefix abbreviation), the payload is opaque base64 — fail-closed.
|
||||
// Only triggers when the first token IS a PowerShell binary AND the segment contains the flag.
|
||||
// `powershell -c 'Get-Date'` (no -enc) must NOT be fail-closed.
|
||||
private static bool SegmentUsesEncodedCommand(string segment, string firstToken)
|
||||
{
|
||||
var b = ExecCommandToken.NormalizedBasename(firstToken);
|
||||
if (b is not ("powershell" or "pwsh")) return false;
|
||||
|
||||
var rest = segment.AsSpan();
|
||||
while (rest.Length > 0)
|
||||
{
|
||||
var i = 0;
|
||||
while (i < rest.Length && char.IsWhiteSpace(rest[i])) i++;
|
||||
rest = rest[i..];
|
||||
if (rest.Length == 0) break;
|
||||
|
||||
// Extract next token — quoted strings count as one unit so `"-enc"` is detected.
|
||||
int end;
|
||||
if (rest[0] is '"' or '\'')
|
||||
{
|
||||
var q = rest[0];
|
||||
end = 1;
|
||||
while (end < rest.Length && rest[end] != q) end++;
|
||||
if (end < rest.Length) end++; // include closing quote
|
||||
}
|
||||
else
|
||||
{
|
||||
end = 0;
|
||||
while (end < rest.Length && !char.IsWhiteSpace(rest[end])) end++;
|
||||
}
|
||||
|
||||
var token = rest[..end].ToString();
|
||||
rest = rest[end..];
|
||||
|
||||
if (IsEncodedCommandFlag(token)) return true;
|
||||
if (token == "--") break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Returns true when a raw flag token (possibly quoted, possibly with colon/equals value suffix)
|
||||
// represents -EncodedCommand or any of its unambiguous prefix abbreviations.
|
||||
// Covers: "-EncodedCommand", "-enc", "-ec", "-e", `"-enc"`, `-enc:payload`, `-encod`, etc.
|
||||
private static bool IsEncodedCommandFlag(string rawToken)
|
||||
{
|
||||
var t = rawToken;
|
||||
if (t.Length >= 2 && t[0] is '"' or '\'' && t[^1] == t[0])
|
||||
t = t[1..^1]; // strip matching outer quotes
|
||||
if (t.Length == 0 || t[0] != '-') return false;
|
||||
// Strip trailing :value or =value (e.g. -EncodedCommand:base64).
|
||||
var sep = t.AsSpan(1).IndexOfAny('=', ':');
|
||||
var flag = (sep >= 0 ? t[..(sep + 1)] : t).ToLowerInvariant();
|
||||
// -e is accepted by Windows PowerShell as a short alias for -EncodedCommand.
|
||||
if (flag is "-e" or "-ec" or "-enc" or "-encodedcommand") return true;
|
||||
// Any unambiguous prefix abbreviation of -encodedcommand beginning at -en.
|
||||
const string full = "-encodedcommand";
|
||||
return flag.Length >= 3 && full.StartsWith(flag, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// True when direct exec (no shell wrapper) is a PowerShell invocation with -EncodedCommand.
|
||||
// Unwraps transparent env prefixes so `["env", "pwsh", "-enc", ...]` is also caught.
|
||||
private static bool DirectExecUsesEncodedCommand(IReadOnlyList<string> command)
|
||||
{
|
||||
var effective = ExecEnvInvocationUnwrapper.UnwrapForResolution(command);
|
||||
if (effective.Count < 2) return false;
|
||||
var b = ExecCommandToken.NormalizedBasename(effective[0].Trim());
|
||||
if (b is not ("powershell" or "pwsh")) return false;
|
||||
for (var i = 1; i < effective.Count; i++)
|
||||
{
|
||||
var t = effective[i].Trim();
|
||||
if (t == "--") break;
|
||||
if (IsEncodedCommandFlag(t)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── PATH search ───────────────────────────────────────────────────────────
|
||||
|
||||
private static string? GetEnvValueIgnoreCase(IReadOnlyDictionary<string, string>? env, string key)
|
||||
{
|
||||
if (env is null) return null;
|
||||
foreach (var kvp in env)
|
||||
{
|
||||
if (string.Equals(kvp.Key, key, StringComparison.OrdinalIgnoreCase))
|
||||
return kvp.Value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? FindInPath(
|
||||
string name,
|
||||
IReadOnlyList<string> searchPaths,
|
||||
IReadOnlyList<string> extensions)
|
||||
{
|
||||
foreach (var dir in searchPaths)
|
||||
{
|
||||
if (string.IsNullOrEmpty(dir)) continue;
|
||||
var candidate = Path.Combine(dir, name);
|
||||
// PATHEXT extensions first — matches Windows CreateProcess resolution order.
|
||||
// A no-extension shadow in PATH must not shadow a PATHEXT binary of the same stem.
|
||||
// Note: PATHEXT is probed even when `name` already carries an extension (git.exe →
|
||||
// tries git.exe.exe, git.exe.cmd, …). This matches CreateProcess behavior — the extra
|
||||
// File.Exists calls are harmless and avoiding them would require extension detection here.
|
||||
foreach (var ext in extensions)
|
||||
{
|
||||
var withExt = candidate + ext;
|
||||
if (File.Exists(withExt)) return TryNormalizePath(withExt);
|
||||
}
|
||||
// Bare name as final fallback (covers names that already have an explicit extension).
|
||||
if (File.Exists(candidate)) return TryNormalizePath(candidate);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> GetSearchPaths(IReadOnlyDictionary<string, string>? env)
|
||||
{
|
||||
var rawPath = GetEnvValueIgnoreCase(env, "PATH");
|
||||
if (!string.IsNullOrEmpty(rawPath))
|
||||
{
|
||||
var parts = rawPath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length > 0) return parts;
|
||||
}
|
||||
// Fallback to process PATH.
|
||||
var processPath = Environment.GetEnvironmentVariable("PATH");
|
||||
if (!string.IsNullOrEmpty(processPath))
|
||||
{
|
||||
var parts = processPath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length > 0) return parts;
|
||||
}
|
||||
return WellKnownPaths();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> GetPathExtensions(IReadOnlyDictionary<string, string>? env)
|
||||
{
|
||||
var rawPathExt = GetEnvValueIgnoreCase(env, "PATHEXT");
|
||||
if (!string.IsNullOrEmpty(rawPathExt))
|
||||
{
|
||||
var parts = rawPathExt.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length > 0) return parts;
|
||||
}
|
||||
var processPathExt = Environment.GetEnvironmentVariable("PATHEXT");
|
||||
if (!string.IsNullOrEmpty(processPathExt))
|
||||
{
|
||||
var parts = processPathExt.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length > 0) return parts;
|
||||
}
|
||||
return s_extensions;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> WellKnownPaths()
|
||||
{
|
||||
var sys32 = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.Windows), "System32");
|
||||
var sys = Environment.GetFolderPath(Environment.SpecialFolder.System);
|
||||
var pf = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
|
||||
return
|
||||
[
|
||||
sys32,
|
||||
sys,
|
||||
Path.Combine(sys32, "OpenSSH"),
|
||||
Path.Combine(pf, "Git", "usr", "bin"),
|
||||
Path.Combine(pf, "Git", "bin"),
|
||||
];
|
||||
}
|
||||
|
||||
// ── Path helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private static string ExpandTilde(string path)
|
||||
{
|
||||
if (!path.StartsWith('~')) return path;
|
||||
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
return path.Length == 1 ? home : home + path[1..];
|
||||
}
|
||||
|
||||
// Paths with ':' outside the volume-separator position are rejected (ADS, non-standard forms).
|
||||
// Research doc 04 section 3 / S3.
|
||||
private static bool HasNonStandardColon(string path)
|
||||
{
|
||||
// Extended-length prefix — strip it and evaluate the remainder (\\?\C:\ is valid).
|
||||
var effective = path.StartsWith(@"\\?\", StringComparison.Ordinal) ? path[4..] : path;
|
||||
|
||||
// UNC paths (\\server\share) and extended UNC (\\?\UNC\...) have no drive colon — fine.
|
||||
if (effective.StartsWith(@"\\", StringComparison.Ordinal)) return false;
|
||||
|
||||
var colonIdx = effective.IndexOf(':');
|
||||
if (colonIdx < 0) return false; // no colon — fine
|
||||
// Drive-letter form: single ASCII letter at index 0 followed by ':' — fine if no second colon.
|
||||
// '1', '!' etc. at index 0 are not valid drive letters and must be rejected.
|
||||
if (colonIdx == 1 && char.IsAsciiLetter(effective[0]))
|
||||
return effective.IndexOf(':', 2) >= 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Attempt 8.3 → long path normalization for paths that exist on disk.
|
||||
// Only applied to resolved paths from PATH search (existence already confirmed).
|
||||
// Research doc 04 section canonicalization / 8.3 short names.
|
||||
private static string TryNormalizePath(string path)
|
||||
{
|
||||
// GetFullPath resolves . and .. but does not expand 8.3 short names.
|
||||
// Full GetLongPathName P/Invoke is left as OQ-R1 in the research docs.
|
||||
try { return Path.GetFullPath(path); }
|
||||
catch { return path; } // hostile path must not throw out of resolution
|
||||
}
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace OpenClaw.Shared.ExecApprovals;
|
||||
|
||||
// Utility helpers for command token classification.
|
||||
internal static class ExecCommandToken
|
||||
{
|
||||
// Returns the lowercased last path component (basename) of a token, without extension.
|
||||
internal static string BasenameLower(string token)
|
||||
{
|
||||
var trimmed = token.Trim();
|
||||
if (trimmed.Length == 0) return string.Empty;
|
||||
var name = Path.GetFileName(trimmed.Replace('\\', '/'));
|
||||
if (name.Length == 0) name = trimmed;
|
||||
return name.ToLowerInvariant();
|
||||
}
|
||||
|
||||
// Returns the basename without .exe suffix (lowercased).
|
||||
internal static string NormalizedBasename(string token)
|
||||
{
|
||||
var b = BasenameLower(token);
|
||||
return b.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) ? b[..^4] : b;
|
||||
}
|
||||
|
||||
internal static bool IsEnv(string token) =>
|
||||
NormalizedBasename(token) == "env";
|
||||
}
|
||||
@ -1,100 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace OpenClaw.Shared.ExecApprovals;
|
||||
|
||||
// Strips `env [OPTIONS] [VAR=VAL...] COMMAND [ARGS...]` so the true executable can be resolved.
|
||||
// Fail-closed: returns null when any unknown flag is encountered or the command cannot be safely
|
||||
// unwrapped. Mirrors ExecEnvInvocationUnwrapper in the windows-app reference.
|
||||
internal static class ExecEnvInvocationUnwrapper
|
||||
{
|
||||
internal const int MaxWrapperDepth = 4;
|
||||
|
||||
private static readonly Regex s_envAssignment =
|
||||
new(@"^[A-Za-z_][A-Za-z0-9_]*=", RegexOptions.Compiled);
|
||||
|
||||
// Strips one level of `env` wrapper.
|
||||
// Returns the remaining argv starting at the real COMMAND token, or null on any ambiguity.
|
||||
internal static IReadOnlyList<string>? Unwrap(IReadOnlyList<string> command)
|
||||
{
|
||||
var idx = 1;
|
||||
var expectsOptionValue = false;
|
||||
|
||||
while (idx < command.Count)
|
||||
{
|
||||
var token = command[idx].Trim();
|
||||
if (token.Length == 0) { idx++; continue; }
|
||||
|
||||
if (expectsOptionValue) { expectsOptionValue = false; idx++; continue; }
|
||||
|
||||
if (token == "--" || token == "-") { idx++; break; }
|
||||
|
||||
if (s_envAssignment.IsMatch(token)) { idx++; continue; }
|
||||
|
||||
if (token.StartsWith('-') && token != "-")
|
||||
{
|
||||
var lower = token.ToLowerInvariant();
|
||||
var flag = lower.Split('=', 2)[0];
|
||||
|
||||
if (ExecEnvOptions.FlagOnly.Contains(flag)) { idx++; continue; }
|
||||
|
||||
if (ExecEnvOptions.WithValue.Contains(flag))
|
||||
{
|
||||
if (!lower.Contains('=')) expectsOptionValue = true;
|
||||
idx++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ExecEnvOptions.InlineValuePrefixes.Any(p => lower.StartsWith(p, StringComparison.Ordinal)))
|
||||
{
|
||||
idx++;
|
||||
continue;
|
||||
}
|
||||
|
||||
return null; // Unknown flag — fail-closed.
|
||||
}
|
||||
|
||||
break; // Executable token found.
|
||||
}
|
||||
|
||||
if (idx >= command.Count) return null;
|
||||
return command.Skip(idx).ToList();
|
||||
}
|
||||
|
||||
// Returns true when the env invocation has flags or VAR=val assignments before the command.
|
||||
// `--` ends option processing without modifying the environment → not a modifier.
|
||||
// `-` alone replaces the environment entirely → modifier.
|
||||
internal static bool HasModifiers(IReadOnlyList<string> command)
|
||||
{
|
||||
for (var i = 1; i < command.Count; i++)
|
||||
{
|
||||
var token = command[i].Trim();
|
||||
if (token.Length == 0) continue;
|
||||
if (token == "--") return false;
|
||||
if (token == "-") return true;
|
||||
if (token.StartsWith('-')) return true;
|
||||
if (s_envAssignment.IsMatch(token)) return true;
|
||||
return false; // first non-modifier token is the command
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Iteratively strips env wrappers for executable resolution only.
|
||||
internal static IReadOnlyList<string> UnwrapForResolution(IReadOnlyList<string> command)
|
||||
{
|
||||
var current = command;
|
||||
for (var depth = 0; depth < MaxWrapperDepth; depth++)
|
||||
{
|
||||
if (current.Count == 0) break;
|
||||
var token = current[0].Trim();
|
||||
if (token.Length == 0) break;
|
||||
if (!ExecCommandToken.IsEnv(token)) break;
|
||||
var unwrapped = Unwrap(current);
|
||||
if (unwrapped is null || unwrapped.Count == 0) break;
|
||||
current = unwrapped;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenClaw.Shared.ExecApprovals;
|
||||
|
||||
// Option grammar of the POSIX `env` command.
|
||||
// Mirrors the constants in the windows-app reference (ExecEnvOptions.cs).
|
||||
internal static class ExecEnvOptions
|
||||
{
|
||||
// Options that consume the next argument as their value (or use inline = form).
|
||||
internal static readonly HashSet<string> WithValue = new(System.StringComparer.Ordinal)
|
||||
{
|
||||
"-u", "--unset",
|
||||
"-c", "--chdir",
|
||||
"-s", "--split-string",
|
||||
"--default-signal",
|
||||
"--ignore-signal",
|
||||
"--block-signal",
|
||||
};
|
||||
|
||||
// Options that are standalone flags (take no value at all).
|
||||
internal static readonly HashSet<string> FlagOnly = new(System.StringComparer.Ordinal)
|
||||
{
|
||||
"-i", "--ignore-environment",
|
||||
"-0", "--null",
|
||||
};
|
||||
|
||||
// Prefixes for the inline-value form (e.g. `-uFOO` or `--unset=FOO`).
|
||||
internal static readonly IReadOnlyList<string> InlineValuePrefixes =
|
||||
[
|
||||
"-u", "-c", "-s",
|
||||
"--unset=",
|
||||
"--chdir=",
|
||||
"--split-string=",
|
||||
"--default-signal=",
|
||||
"--ignore-signal=",
|
||||
"--block-signal=",
|
||||
];
|
||||
}
|
||||
@ -1,118 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenClaw.Shared.ExecApprovals;
|
||||
|
||||
// Single-level shell wrapper detection for the V2 exec approval pipeline.
|
||||
// Differs from the legacy ExecShellWrapperParser.Expand (BFS multi-level, string-based).
|
||||
// This normalizer operates on argv (IReadOnlyList<string>) and performs one level of
|
||||
// wrapper detection, with recursive env-prefix unwrapping up to MaxWrapperDepth.
|
||||
// Rail 18 step 2: normalize command form.
|
||||
internal static class ExecShellWrapperNormalizer
|
||||
{
|
||||
private enum WrapperKind { Posix, Cmd, PowerShell }
|
||||
|
||||
private sealed record WrapperSpec(WrapperKind Kind, HashSet<string> Names);
|
||||
|
||||
private static readonly HashSet<string> s_posixInlineFlags =
|
||||
new(StringComparer.OrdinalIgnoreCase) { "-lc", "-c", "--command" };
|
||||
|
||||
private static readonly HashSet<string> s_powerShellInlineFlags =
|
||||
new(StringComparer.OrdinalIgnoreCase) { "-c", "-command", "--command" };
|
||||
|
||||
private static readonly WrapperSpec[] s_specs =
|
||||
[
|
||||
new(WrapperKind.Posix, new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{ "ash", "sh", "bash", "zsh", "dash", "ksh", "fish" }),
|
||||
new(WrapperKind.Cmd, new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{ "cmd", "cmd.exe" }),
|
||||
new(WrapperKind.PowerShell, new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{ "powershell", "powershell.exe", "pwsh", "pwsh.exe" }),
|
||||
];
|
||||
|
||||
internal sealed record ParsedWrapper(bool IsWrapper, string? InlineCommand);
|
||||
|
||||
internal static readonly ParsedWrapper NotWrapper = new(false, null);
|
||||
|
||||
// Detects a single-level shell wrapper in argv.
|
||||
// rawCommand is always null in Windows v1 (not in system.run protocol; research doc 05 OQ-V4).
|
||||
// Detection is on argv only; rawCommand is accepted for API compatibility with future use.
|
||||
internal static ParsedWrapper Extract(IReadOnlyList<string> command, string? rawCommand = null)
|
||||
=> ExtractInner(command, rawCommand, 0);
|
||||
|
||||
private static ParsedWrapper ExtractInner(
|
||||
IReadOnlyList<string> command, string? rawCommand, int depth)
|
||||
{
|
||||
if (depth >= ExecEnvInvocationUnwrapper.MaxWrapperDepth) return NotWrapper;
|
||||
if (command.Count == 0) return NotWrapper;
|
||||
|
||||
var token0 = command[0].Trim();
|
||||
if (token0.Length == 0) return NotWrapper;
|
||||
|
||||
// Recursively unwrap transparent env prefixes.
|
||||
if (ExecCommandToken.IsEnv(token0))
|
||||
{
|
||||
var unwrapped = ExecEnvInvocationUnwrapper.Unwrap(command);
|
||||
if (unwrapped is null) return NotWrapper;
|
||||
return ExtractInner(unwrapped, rawCommand, depth + 1);
|
||||
}
|
||||
|
||||
var basename = ExecCommandToken.NormalizedBasename(token0);
|
||||
var spec = Array.Find(s_specs, s => s.Names.Contains(basename));
|
||||
if (spec is null) return NotWrapper;
|
||||
|
||||
var payload = ExtractPayload(command, spec);
|
||||
if (payload is null) return NotWrapper;
|
||||
|
||||
return new ParsedWrapper(true, payload);
|
||||
}
|
||||
|
||||
private static string? ExtractPayload(IReadOnlyList<string> command, WrapperSpec spec) =>
|
||||
spec.Kind switch
|
||||
{
|
||||
WrapperKind.Posix => ExtractPosixPayload(command),
|
||||
WrapperKind.Cmd => ExtractCmdPayload(command),
|
||||
WrapperKind.PowerShell => ExtractPowerShellPayload(command),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static string? ExtractPosixPayload(IReadOnlyList<string> command)
|
||||
{
|
||||
if (command.Count < 2) return null;
|
||||
var flag = command[1].Trim();
|
||||
if (!s_posixInlineFlags.Contains(flag)) return null;
|
||||
if (command.Count < 3) return null;
|
||||
var payload = command[2].Trim();
|
||||
return payload.Length == 0 ? null : payload;
|
||||
}
|
||||
|
||||
private static string? ExtractCmdPayload(IReadOnlyList<string> command)
|
||||
{
|
||||
for (var i = 1; i < command.Count; i++)
|
||||
{
|
||||
if (string.Equals(command[i].Trim(), "/c", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var tail = string.Join(" ", command.Skip(i + 1)).Trim();
|
||||
return tail.Length == 0 ? null : tail;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ExtractPowerShellPayload(IReadOnlyList<string> command)
|
||||
{
|
||||
for (var i = 1; i < command.Count; i++)
|
||||
{
|
||||
var t = command[i].Trim().ToLowerInvariant();
|
||||
if (t.Length == 0) continue;
|
||||
if (t == "--") break;
|
||||
if (s_powerShellInlineFlags.Contains(t))
|
||||
{
|
||||
if (i + 1 >= command.Count) return null;
|
||||
var payload = command[i + 1].Trim();
|
||||
return payload.Length == 0 ? null : payload;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -90,20 +90,9 @@ internal static class ExecEnvSanitizer
|
||||
if (name.IndexOfAny(['=', '\0', '\r', '\n']) >= 0)
|
||||
return true;
|
||||
|
||||
// Vectorized scan: any char in [0x00, 0x20] covers all ASCII control characters
|
||||
// (0x01–0x1F) plus space (0x20) in a single SIMD pass — the common fast path for
|
||||
// the ASCII-only names that make up virtually all environment variable keys.
|
||||
var span = name.AsSpan();
|
||||
if (span.IndexOfAnyInRange('\x00', '\x20') >= 0)
|
||||
return true;
|
||||
// DEL (0x7F) — control char outside the range above.
|
||||
if (span.IndexOf('\x7F') >= 0)
|
||||
return true;
|
||||
// Non-ASCII Unicode control / whitespace (rare; UTF-8 env var names are uncommon).
|
||||
for (var i = 0; i < name.Length; i++)
|
||||
foreach (var c in name)
|
||||
{
|
||||
var c = name[i];
|
||||
if (c > '\x7F' && (char.IsControl(c) || char.IsWhiteSpace(c)))
|
||||
if (char.IsControl(c) || char.IsWhiteSpace(c))
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@ -135,26 +135,8 @@ internal static class ExecShellWrapperParser
|
||||
for (var i = 1; i < tokens.Length; i++)
|
||||
{
|
||||
var option = tokens[i];
|
||||
|
||||
// Check for inline separator form first: -flag:value or -flag=value
|
||||
var sepIdx = IndexOfFlagSeparator(option);
|
||||
if (sepIdx > 0)
|
||||
{
|
||||
var flagPart = option[..sepIdx];
|
||||
var valuePart = option[(sepIdx + 1)..];
|
||||
|
||||
if (IsCommandFlag(flagPart))
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(valuePart)
|
||||
? ("", shell, "Shell wrapper payload was empty")
|
||||
: (valuePart, shell, null);
|
||||
}
|
||||
|
||||
if (IsEncodedCommandFlag(flagPart))
|
||||
return DecodeEncodedPayload(valuePart, shell);
|
||||
}
|
||||
|
||||
if (IsCommandFlag(option))
|
||||
if (option.Equals("-Command", StringComparison.OrdinalIgnoreCase) ||
|
||||
option.Equals("-c", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var payload = string.Join(" ", tokens, i + 1, tokens.Length - i - 1).Trim();
|
||||
return string.IsNullOrWhiteSpace(payload)
|
||||
@ -162,68 +144,32 @@ internal static class ExecShellWrapperParser
|
||||
: (payload, shell, null);
|
||||
}
|
||||
|
||||
if (IsEncodedCommandFlag(option))
|
||||
if (option.Equals("-EncodedCommand", StringComparison.OrdinalIgnoreCase) ||
|
||||
option.Equals("-enc", StringComparison.OrdinalIgnoreCase) ||
|
||||
option.Equals("-ec", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var encoded = i + 1 < tokens.Length ? tokens[i + 1] : null;
|
||||
return DecodeEncodedPayload(encoded, shell);
|
||||
if (string.IsNullOrWhiteSpace(encoded))
|
||||
return ("", shell, "Shell wrapper payload was empty");
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(encoded);
|
||||
var payload = Encoding.Unicode.GetString(bytes).Trim();
|
||||
return string.IsNullOrWhiteSpace(payload)
|
||||
? ("", shell, "EncodedCommand decoded to an empty payload")
|
||||
: (payload, shell, null);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return ("", shell, "EncodedCommand could not be decoded");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
// Returns the index of the first ':' or '=' in a flag token (after the leading '-').
|
||||
private static int IndexOfFlagSeparator(string token)
|
||||
{
|
||||
for (var i = 1; i < token.Length; i++)
|
||||
{
|
||||
if (token[i] == ':' || token[i] == '=')
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Matches -Command and -c (documented PowerShell -Command aliases).
|
||||
private static bool IsCommandFlag(string flag) =>
|
||||
flag.Equals("-Command", StringComparison.OrdinalIgnoreCase) ||
|
||||
flag.Equals("-c", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Matches -e/-ec aliases and all unique prefix abbreviations of -EncodedCommand.
|
||||
// Windows PowerShell accepts -e as EncodedCommand despite the apparent ambiguity with
|
||||
// -ExecutionPolicy, so the parser must fail closed and decode it.
|
||||
private static bool IsEncodedCommandFlag(string flag)
|
||||
{
|
||||
if (flag.Equals("-e", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
if (flag.Equals("-ec", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
const string fullFlag = "-encodedcommand";
|
||||
return flag.Length >= 3 && // minimum: -en
|
||||
flag.Length <= fullFlag.Length &&
|
||||
fullFlag.StartsWith(flag, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static (string? Payload, string? Shell, string? Error) DecodeEncodedPayload(string? encoded, string shell)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(encoded))
|
||||
return ("", shell, "Shell wrapper payload was empty");
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(encoded);
|
||||
var payload = Encoding.Unicode.GetString(bytes).Trim();
|
||||
return string.IsNullOrWhiteSpace(payload)
|
||||
? ("", shell, "EncodedCommand decoded to an empty payload")
|
||||
: (payload, shell, null);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return ("", shell, "EncodedCommand could not be decoded");
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> SplitTopLevelCommands(string command)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
@ -1,25 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace OpenClaw.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Shared literal-host classifier for gateway URLs that point at the local machine.
|
||||
/// </summary>
|
||||
public static class LocalGatewayUrlClassifier
|
||||
{
|
||||
public static bool IsLocalGatewayUrl(string url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)) return false;
|
||||
|
||||
try
|
||||
{
|
||||
var uri = new Uri(url);
|
||||
var host = uri.Host.ToLowerInvariant();
|
||||
return host is "localhost" or "127.0.0.1" or "::1" or "[::1]";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -96,7 +96,7 @@ public static class McpAuthToken
|
||||
try
|
||||
{
|
||||
File.WriteAllText(tempPath, token, Encoding.UTF8);
|
||||
TryRestrictSensitiveFileAcl(tempPath);
|
||||
TryRestrictFileAcl(tempPath);
|
||||
File.Move(tempPath, path, overwrite: true);
|
||||
}
|
||||
catch
|
||||
@ -104,7 +104,7 @@ public static class McpAuthToken
|
||||
try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { }
|
||||
throw;
|
||||
}
|
||||
TryRestrictSensitiveFileAcl(path);
|
||||
TryRestrictFileAcl(path);
|
||||
return token;
|
||||
}
|
||||
|
||||
@ -127,7 +127,7 @@ public static class McpAuthToken
|
||||
try
|
||||
{
|
||||
File.WriteAllText(tempPath, token, Encoding.UTF8);
|
||||
TryRestrictSensitiveFileAcl(tempPath);
|
||||
TryRestrictFileAcl(tempPath);
|
||||
File.Move(tempPath, path, overwrite: true);
|
||||
}
|
||||
catch
|
||||
@ -137,7 +137,7 @@ public static class McpAuthToken
|
||||
}
|
||||
// Move on Windows preserves the source's DACL; re-apply defensively in
|
||||
// case a future rename strategy substitutes a different file.
|
||||
TryRestrictSensitiveFileAcl(path);
|
||||
TryRestrictFileAcl(path);
|
||||
return token;
|
||||
}
|
||||
|
||||
@ -183,9 +183,8 @@ public static class McpAuthToken
|
||||
catch { /* best-effort; acl restriction is defense-in-depth, not load-bearing */ }
|
||||
}
|
||||
|
||||
public static void TryRestrictSensitiveFileAcl(string path)
|
||||
private static void TryRestrictFileAcl(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return;
|
||||
if (!OperatingSystem.IsWindows()) return;
|
||||
try { RestrictFileAclWindows(path); }
|
||||
catch { /* see above */ }
|
||||
|
||||
@ -89,10 +89,11 @@ public sealed class McpHttpServer : IDisposable
|
||||
_port = port;
|
||||
_authToken = string.IsNullOrEmpty(authToken) ? null : authToken;
|
||||
_listener = new HttpListener();
|
||||
// Loopback binding — not reachable from other machines. Use only the
|
||||
// numeric host on Windows so non-elevated startup does not require a
|
||||
// separate netsh http urlacl reservation for http://localhost:port/.
|
||||
// Loopback binding — not reachable from other machines.
|
||||
// Register both numeric and hostname forms so clients that connect
|
||||
// via http://localhost:port/ (common on Linux/macOS) are also served.
|
||||
_listener.Prefixes.Add($"http://127.0.0.1:{port}/");
|
||||
_listener.Prefixes.Add($"http://localhost:{port}/");
|
||||
}
|
||||
|
||||
public void Start()
|
||||
|
||||
@ -236,40 +236,9 @@ public class McpToolBridge
|
||||
["camera.clip"] =
|
||||
"Record a short clip from a camera. Args: deviceId (string, optional), durationMs (int, required, max 60000), format ('mp4'|'webm', default 'mp4'), maxWidth (int, default 1280). Returns { format, durationMs, base64 }.",
|
||||
|
||||
// stt.* — microphone capture → text. Default-off; privacy-sensitive.
|
||||
// Single engine: Whisper.net runs locally on the device.
|
||||
["stt.transcribe"] =
|
||||
"Capture microphone audio for a bounded duration and return the transcribed text. Args: maxDurationMs (int, required, > 0, max 30000), language (string, optional BCP-47 tag like 'en-US' or 'auto' — falls back to the configured SttLanguage setting). Returns { transcribed, text, durationMs, language, engineEffective ('whisper') }. Whisper model is downloaded on first use; until then this returns an error pointing to Voice Settings. Requires NodeSttEnabled.",
|
||||
["stt.listen"] =
|
||||
"Capture microphone audio with voice-activity detection and return when the user stops speaking, or after timeoutMs. Args: timeoutMs (int, optional, default 30000, range 1000..120000), language (string, optional BCP-47 tag or 'auto', default 'auto'). Returns { text, language, durationMs, segments[{ text, startMs, endMs }], engineEffective ('whisper') }. Result is the full silence-bounded utterance (all Whisper segments concatenated), not a partial first segment. Requires NodeSttEnabled.",
|
||||
["stt.status"] =
|
||||
"Report STT engine readiness. No args. Returns { engine ('whisper'), readiness ('ready'|'initializing'|'model-downloading'|'model-not-downloaded'|'unavailable'), modelDownloadProgress (0..1 or null), isListenWithVadSupported (bool), isBoundedTranscribeSupported (bool) }. Carries no PII (no transcript history, no language history, no device IDs, no model paths).",
|
||||
|
||||
// tts.*
|
||||
["tts.speak"] =
|
||||
"Speak text aloud on the Windows node. Args: text (string, required), provider ('piper'|'windows'|'elevenlabs', optional — falls back to the configured TtsProvider setting, default 'piper' for fresh installs), voiceId (string, optional — overrides the per-provider configured voice), model (string, optional, ElevenLabs only), interrupt (bool, default false — interrupts any in-progress playback). Returns { spoken, provider, contentType, durationMs }.",
|
||||
|
||||
// app.*
|
||||
["app.navigate"] =
|
||||
"Navigate the companion app to a specific page (e.g., 'home', 'sessions', 'settings'). Args: page (string, required). Returns { navigated, page }.",
|
||||
["app.status"] =
|
||||
"Get current connection status, node state, and gateway info. Returns { connectionStatus, nodeConnected, nodePaired, nodePendingApproval, gatewayVersion, sessionCount, nodeCount }.",
|
||||
["app.sessions"] =
|
||||
"List active sessions with optional agent filter. Args: agentId (string, optional). Returns array of { Key, Status, Model, AgeText, tokens }.",
|
||||
["app.agents"] =
|
||||
"List agents from the connected gateway. Returns the raw agents JSON array.",
|
||||
["app.nodes"] =
|
||||
"List connected nodes and their capabilities. Returns array of { DisplayName, NodeId, IsOnline, Platform, CapabilityCount }.",
|
||||
["app.config.get"] =
|
||||
"Read gateway configuration value at a dot-path. Args: path (string, optional). Returns the config subtree or full config.",
|
||||
["app.settings.get"] =
|
||||
"Read a local app setting by name. Args: name (string, required). Returns the setting value.",
|
||||
["app.settings.set"] =
|
||||
"Set a local app setting (name and value). Args: name (string, required), value (string, required). Returns { name, value }.",
|
||||
["app.menu"] =
|
||||
"Get tray menu state (status, session count, node count). Returns array of menu items.",
|
||||
["app.search"] =
|
||||
"Search the command palette and return matching commands. Args: query (string, required). Returns array of { Title, Subtitle, Icon }.",
|
||||
"Speak text aloud on the Windows node. Args: text (string, required), provider ('windows'|'elevenlabs', optional), voiceId (string, optional), model (string, optional), interrupt (bool, default false). Returns { spoken, provider, contentType, durationMs }.",
|
||||
};
|
||||
|
||||
private async Task<object> HandleToolsCallAsync(JsonElement parameters, CancellationToken cancellationToken)
|
||||
|
||||
@ -5,8 +5,6 @@ namespace OpenClaw.Shared;
|
||||
/// </summary>
|
||||
public static class MenuSizingHelper
|
||||
{
|
||||
private const double ScaleTolerance = 0.001;
|
||||
|
||||
public static int ConvertPixelsToViewUnits(int pixels, uint dpi)
|
||||
{
|
||||
if (pixels <= 0) return 0;
|
||||
@ -15,19 +13,6 @@ public static class MenuSizingHelper
|
||||
return Math.Max(1, (int)Math.Floor(pixels * 96.0 / dpi));
|
||||
}
|
||||
|
||||
public static bool HasDpiOrScaleChanged(uint previousDpi, double previousRasterizationScale, uint currentDpi, double currentRasterizationScale)
|
||||
{
|
||||
previousDpi = NormalizeDpi(previousDpi);
|
||||
currentDpi = NormalizeDpi(currentDpi);
|
||||
|
||||
if (previousDpi != currentDpi)
|
||||
return true;
|
||||
|
||||
var previousScale = NormalizeScale(previousRasterizationScale);
|
||||
var currentScale = NormalizeScale(currentRasterizationScale);
|
||||
return Math.Abs(previousScale - currentScale) > ScaleTolerance;
|
||||
}
|
||||
|
||||
public static int CalculateWindowHeight(int contentHeight, int workAreaHeight, int minimumHeight = 100)
|
||||
{
|
||||
if (contentHeight < 0) contentHeight = 0;
|
||||
@ -40,9 +25,4 @@ public static class MenuSizingHelper
|
||||
var desiredHeight = Math.Max(contentHeight, minimumVisibleHeight);
|
||||
return Math.Min(desiredHeight, workAreaHeight);
|
||||
}
|
||||
|
||||
private static uint NormalizeDpi(uint dpi) => dpi == 0 ? 96u : dpi;
|
||||
|
||||
private static double NormalizeScale(double scale) =>
|
||||
double.IsFinite(scale) && scale > 0 ? scale : 1.0;
|
||||
}
|
||||
|
||||
@ -762,7 +762,7 @@ public static class PermissionDiagnostics
|
||||
{
|
||||
Name = "Microphone",
|
||||
Status = "review",
|
||||
Detail = "Required for camera clips with audio and for stt.transcribe speech-to-text capture.",
|
||||
Detail = "Required only for camera clips with audio or future voice features.",
|
||||
SettingsUri = "ms-settings:privacy-microphone"
|
||||
},
|
||||
new()
|
||||
@ -1019,7 +1019,7 @@ public static class CommandCenterCommandGroups
|
||||
public static readonly FrozenSet<string> SafeCompanionCommandSet =
|
||||
SafeCompanionCommands.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static readonly string[] CommonDangerousCommands =
|
||||
public static readonly string[] DangerousCommands =
|
||||
[
|
||||
"camera.snap",
|
||||
"camera.clip",
|
||||
@ -1027,14 +1027,6 @@ public static class CommandCenterCommandGroups
|
||||
"tts.speak"
|
||||
];
|
||||
|
||||
public static readonly string[] DangerousCommands =
|
||||
[
|
||||
.. CommonDangerousCommands,
|
||||
"stt.transcribe",
|
||||
"stt.listen",
|
||||
"stt.status"
|
||||
];
|
||||
|
||||
public static readonly FrozenSet<string> DangerousCommandSet =
|
||||
DangerousCommands.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@ -1243,7 +1235,7 @@ public static class CommandCenterDiagnostics
|
||||
Severity = GatewayDiagnosticSeverity.Info,
|
||||
Category = "allowlist",
|
||||
Title = "Privacy-sensitive commands are currently blocked",
|
||||
Detail = $"{blocked} {(node.MissingDangerousAllowlistCommands.Count == 1 ? "is" : "are")} declared but filtered by gateway policy. Leave blocked unless you explicitly want camera, microphone, or screen recording access for this node.",
|
||||
Detail = $"{blocked} {(node.MissingDangerousAllowlistCommands.Count == 1 ? "is" : "are")} declared but filtered by gateway policy. Leave blocked unless you explicitly want camera or screen recording access for this node.",
|
||||
RepairAction = "Copy opt-in guidance",
|
||||
CopyText = BuildDangerousCommandOptInGuidance(node.MissingDangerousAllowlistCommands)
|
||||
});
|
||||
@ -1513,323 +1505,3 @@ internal static class ModelFormatting
|
||||
}
|
||||
}
|
||||
|
||||
// ── Agent Events ──
|
||||
|
||||
/// <summary>Raw agent event from gateway broadcast.</summary>
|
||||
public class AgentEventInfo
|
||||
{
|
||||
public string RunId { get; set; } = "";
|
||||
public int Seq { get; set; }
|
||||
public string Stream { get; set; } = "";
|
||||
public double Ts { get; set; }
|
||||
public JsonElement Data { get; set; }
|
||||
public string? SessionKey { get; set; }
|
||||
public string? Summary { get; set; }
|
||||
|
||||
public DateTime Timestamp => DateTimeOffset.FromUnixTimeMilliseconds((long)Ts).LocalDateTime;
|
||||
|
||||
public string FormattedTime => Timestamp.ToString("HH:mm:ss.fff");
|
||||
|
||||
/// <summary>Resolved event kind — for "item" stream events, uses data.kind instead.</summary>
|
||||
public string ResolvedStream
|
||||
{
|
||||
get
|
||||
{
|
||||
var s = Stream.ToLowerInvariant();
|
||||
if (s == "item" && Data.ValueKind == JsonValueKind.Object &&
|
||||
Data.TryGetProperty("kind", out var k))
|
||||
{
|
||||
return k.GetString()?.ToLowerInvariant() ?? s;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
public string StreamUpper => ResolvedStream.ToUpperInvariant();
|
||||
|
||||
/// <summary>Color hex for stream badge (used by UI to create brush).</summary>
|
||||
public string BadgeColorHex => ResolvedStream switch
|
||||
{
|
||||
"tool" => "#FFB45D3A", // Burnt sienna
|
||||
"assistant" => "#FF28A050", // Green
|
||||
"error" => "#FFC83232", // Red
|
||||
"lifecycle" => "#FF3C78C8", // Blue
|
||||
"plan" => "#FF8C50C8", // Purple
|
||||
"approval" => "#FFC8A01E", // Amber
|
||||
"thinking" => "#FF648CB4", // Steel
|
||||
"patch" => "#FF50A0A0", // Teal
|
||||
_ => "#FF646464" // Gray
|
||||
};
|
||||
|
||||
/// <summary>Human-readable summary extracted from event data.</summary>
|
||||
public string SummaryLine
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!string.IsNullOrEmpty(Summary)) return Summary;
|
||||
try
|
||||
{
|
||||
var s = ResolvedStream;
|
||||
if (s == "tool" && Data.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var name = Data.TryGetProperty("name", out var n) ? n.GetString() : null;
|
||||
var title = Data.TryGetProperty("title", out var ti) ? ti.GetString() : null;
|
||||
var phase = Data.TryGetProperty("phase", out var p) ? p.GetString() : null;
|
||||
var status = Data.TryGetProperty("status", out var st) ? st.GetString() : null;
|
||||
// Prefer title (richer) over just name
|
||||
if (title != null)
|
||||
return phase != null ? $"🔧 {title} ({phase})" : $"🔧 {title}";
|
||||
if (name != null)
|
||||
return phase != null ? $"🔧 {name} ({phase})" : $"🔧 {name}";
|
||||
}
|
||||
if (s == "assistant" && Data.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var text = Data.TryGetProperty("text", out var t) ? t.GetString() : null;
|
||||
if (text != null) return text.Length > 300 ? text[..300] + "…" : text;
|
||||
}
|
||||
if (s == "error" && Data.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var msg = Data.TryGetProperty("message", out var m) ? m.GetString()
|
||||
: Data.TryGetProperty("error", out var e) ? e.GetString() : null;
|
||||
if (msg != null) return $"❌ {msg}";
|
||||
}
|
||||
if (s == "lifecycle" && Data.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var state = Data.TryGetProperty("state", out var st) ? st.GetString()
|
||||
: Data.TryGetProperty("livenessState", out var ls) ? ls.GetString() : null;
|
||||
var phase = Data.TryGetProperty("phase", out var ph) ? ph.GetString() : null;
|
||||
if (state != null)
|
||||
return phase != null ? $"⚡ {state} ({phase})" : $"⚡ {state}";
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasSummary => !string.IsNullOrEmpty(SummaryLine);
|
||||
|
||||
/// <summary>Full assistant message text (no truncation), for expanded view.</summary>
|
||||
public string? FullAssistantText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (ResolvedStream != "assistant" || Data.ValueKind != JsonValueKind.Object) return null;
|
||||
try { return Data.TryGetProperty("text", out var t) ? t.GetString() : null; }
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Whether this event is an assistant stream (expanded view shows full text instead of JSON).</summary>
|
||||
public bool IsAssistantStream => ResolvedStream == "assistant";
|
||||
|
||||
/// <summary>Whether to show the raw DataJson section. Hidden for streams where SummaryLine is sufficient.</summary>
|
||||
public bool ShowDataJson
|
||||
{
|
||||
get
|
||||
{
|
||||
var s = ResolvedStream;
|
||||
if (s is "assistant" or "error" or "lifecycle") return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// UI-only state for expand/collapse (not serialized)
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public bool IsExpanded { get; set; }
|
||||
|
||||
private string? _cachedDataJson;
|
||||
|
||||
public string DataJson
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_cachedDataJson != null) return _cachedDataJson;
|
||||
try
|
||||
{
|
||||
_cachedDataJson = JsonSerializer.Serialize(Data, new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
catch
|
||||
{
|
||||
_cachedDataJson = Data.ToString() ?? "{}";
|
||||
}
|
||||
return _cachedDataJson;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Node/Device Pairing ──
|
||||
|
||||
public class PairingRequest
|
||||
{
|
||||
public string RequestId { get; set; } = "";
|
||||
public string NodeId { get; set; } = "";
|
||||
public string? DisplayName { get; set; }
|
||||
public string? Platform { get; set; }
|
||||
public string? Version { get; set; }
|
||||
public string? RemoteIp { get; set; }
|
||||
public bool IsRepair { get; set; }
|
||||
public double Ts { get; set; }
|
||||
|
||||
public DateTime Timestamp => DateTimeOffset.FromUnixTimeMilliseconds((long)Ts).LocalDateTime;
|
||||
|
||||
public string Description
|
||||
{
|
||||
get
|
||||
{
|
||||
var lines = new List<string>();
|
||||
lines.Add($"Node: {DisplayName ?? NodeId}");
|
||||
if (!string.IsNullOrEmpty(Platform)) lines.Add($"Platform: {Platform}");
|
||||
if (!string.IsNullOrEmpty(Version)) lines.Add($"Version: {Version}");
|
||||
if (!string.IsNullOrEmpty(RemoteIp)) lines.Add($"IP: {RemoteIp}");
|
||||
if (IsRepair) lines.Add("Repair: yes");
|
||||
return string.Join("\n", lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class DevicePairingRequest
|
||||
{
|
||||
public string RequestId { get; set; } = "";
|
||||
public string DeviceId { get; set; } = "";
|
||||
public string? PublicKey { get; set; }
|
||||
public string? DisplayName { get; set; }
|
||||
public string? Platform { get; set; }
|
||||
public string? ClientId { get; set; }
|
||||
public string? ClientMode { get; set; }
|
||||
public string? Role { get; set; }
|
||||
public string[]? Scopes { get; set; }
|
||||
public string? RemoteIp { get; set; }
|
||||
public bool IsRepair { get; set; }
|
||||
public double Ts { get; set; }
|
||||
|
||||
public DateTime Timestamp => DateTimeOffset.FromUnixTimeMilliseconds((long)Ts).LocalDateTime;
|
||||
|
||||
public string Description
|
||||
{
|
||||
get
|
||||
{
|
||||
var lines = new List<string>();
|
||||
lines.Add($"Device: {DisplayName ?? DeviceId}");
|
||||
if (!string.IsNullOrEmpty(Platform)) lines.Add($"Platform: {Platform}");
|
||||
if (!string.IsNullOrEmpty(Role)) lines.Add($"Role: {Role}");
|
||||
if (Scopes is { Length: > 0 }) lines.Add($"Scopes: {string.Join(", ", Scopes)}");
|
||||
if (!string.IsNullOrEmpty(RemoteIp)) lines.Add($"IP: {RemoteIp}");
|
||||
if (IsRepair) lines.Add("Repair: yes");
|
||||
return string.Join("\n", lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class PairingListInfo
|
||||
{
|
||||
public List<PairingRequest> Pending { get; set; } = new();
|
||||
}
|
||||
|
||||
public class DevicePairingListInfo
|
||||
{
|
||||
public List<DevicePairingRequest> Pending { get; set; } = new();
|
||||
}
|
||||
|
||||
// ── Models List ──
|
||||
|
||||
public class ModelInfo
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string? Name { get; set; }
|
||||
public string? Provider { get; set; }
|
||||
public int? ContextWindow { get; set; }
|
||||
public bool IsConfigured { get; set; }
|
||||
|
||||
public string DisplayName => Name ?? Id;
|
||||
}
|
||||
|
||||
public class ModelsListInfo
|
||||
{
|
||||
public List<ModelInfo> Models { get; set; } = new();
|
||||
}
|
||||
|
||||
// ── Agent Info ──
|
||||
|
||||
public class AgentInfo
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string? Name { get; set; }
|
||||
public string? Emoji { get; set; }
|
||||
public string? Workspace { get; set; }
|
||||
public string? ModelPrimary { get; set; }
|
||||
public string DisplayName => Name ?? Id;
|
||||
}
|
||||
|
||||
// ── Presence (connected clients/instances) ──
|
||||
|
||||
public class PresenceEntry
|
||||
{
|
||||
public string? Host { get; set; }
|
||||
public string? Ip { get; set; }
|
||||
public string? Version { get; set; }
|
||||
public string? Platform { get; set; }
|
||||
public string? DeviceFamily { get; set; }
|
||||
public string? ModelIdentifier { get; set; }
|
||||
public string? Mode { get; set; }
|
||||
public int? LastInputSeconds { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
public string[]? Tags { get; set; }
|
||||
public string? Text { get; set; }
|
||||
public long Ts { get; set; }
|
||||
public string? DeviceId { get; set; }
|
||||
public string[]? Roles { get; set; }
|
||||
public string[]? Scopes { get; set; }
|
||||
public string? InstanceId { get; set; }
|
||||
|
||||
public string DisplayName => Host ?? DeviceId ?? Ip ?? "Unknown";
|
||||
public DateTime Timestamp => DateTimeOffset.FromUnixTimeSeconds(Ts).LocalDateTime;
|
||||
public string PlatformLabel => Platform ?? "unknown";
|
||||
public string ModeLabel => Mode ?? "unknown";
|
||||
|
||||
public string LastSeenText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (LastInputSeconds is not { } secs) return "";
|
||||
if (secs < 60) return $"{secs}s ago";
|
||||
if (secs < 3600) return $"{secs / 60}m ago";
|
||||
return $"{secs / 3600}h ago";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gateway Discovery ──
|
||||
|
||||
public class DiscoveredGateway
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string DisplayName { get; set; } = "";
|
||||
public string? Host { get; set; }
|
||||
public int Port { get; set; }
|
||||
public string? LanHost { get; set; }
|
||||
public string? TailnetDns { get; set; }
|
||||
public bool TlsEnabled { get; set; }
|
||||
public string? TlsFingerprint { get; set; }
|
||||
|
||||
public string ConnectionUrl
|
||||
{
|
||||
get
|
||||
{
|
||||
var scheme = TlsEnabled ? "wss" : "ws";
|
||||
var host = Host ?? LanHost ?? "localhost";
|
||||
return $"{scheme}://{host}:{Port}";
|
||||
}
|
||||
}
|
||||
|
||||
public string HttpUrl
|
||||
{
|
||||
get
|
||||
{
|
||||
var scheme = TlsEnabled ? "https" : "http";
|
||||
var host = Host ?? LanHost ?? "localhost";
|
||||
return $"{scheme}://{host}:{Port}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -15,13 +15,6 @@
|
||||
<PackageReference Include="NSec.Cryptography" Version="25.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Audio / Speech-to-Text (platform-agnostic components) -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Whisper.net" Version="1.9.0" />
|
||||
<PackageReference Include="Whisper.net.Runtime" Version="1.9.0" />
|
||||
<PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.25.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -16,14 +16,8 @@ public class SettingsData
|
||||
public string? SshTunnelHost { get; set; }
|
||||
public int SshTunnelRemotePort { get; set; } = 18789;
|
||||
public int SshTunnelLocalPort { get; set; } = 18789;
|
||||
public bool AutoStart { get; set; } = true;
|
||||
public bool AutoStart { get; set; }
|
||||
public bool GlobalHotkeyEnabled { get; set; } = true;
|
||||
/// <summary>
|
||||
/// One-shot gate: set to true after the post-onboarding "first-run" bootstrap
|
||||
/// kickoff message has been injected into the chat exactly once. Subsequent
|
||||
/// chat-window launches skip injection.
|
||||
/// </summary>
|
||||
public bool HasInjectedFirstRunBootstrap { get; set; }
|
||||
public bool ShowNotifications { get; set; } = true;
|
||||
public string? NotificationSound { get; set; }
|
||||
public bool NotifyHealth { get; set; } = true;
|
||||
@ -38,37 +32,13 @@ public class SettingsData
|
||||
public bool NodeCanvasEnabled { get; set; } = true;
|
||||
public bool NodeScreenEnabled { get; set; } = true;
|
||||
public bool NodeCameraEnabled { get; set; } = true;
|
||||
public bool ScreenRecordingConsentGiven { get; set; } = false;
|
||||
public bool CameraRecordingConsentGiven { get; set; } = false;
|
||||
public bool NodeLocationEnabled { get; set; } = true;
|
||||
public bool NodeBrowserProxyEnabled { get; set; } = true;
|
||||
public bool NodeSttEnabled { get; set; } = false;
|
||||
/// <summary>STT language: "auto" for Whisper auto-detect, or a BCP-47 tag like "en-US".</summary>
|
||||
public string SttLanguage { get; set; } = "auto";
|
||||
/// <summary>Whisper model name: "tiny", "base", or "small".</summary>
|
||||
public string SttModelName { get; set; } = "base";
|
||||
/// <summary>Seconds of silence before auto-submit in voice chat mode.</summary>
|
||||
public float SttSilenceTimeout { get; set; } = 2.5f;
|
||||
/// <summary>Enable TTS playback of responses during voice sessions.</summary>
|
||||
public bool VoiceTtsEnabled { get; set; } = true;
|
||||
/// <summary>Play audio feedback chimes on listen start/stop.</summary>
|
||||
public bool VoiceAudioFeedback { get; set; } = true;
|
||||
public bool NodeTtsEnabled { get; set; } = false;
|
||||
public string TtsProvider { get; set; } = OpenClaw.Shared.Capabilities.TtsCapability.PiperProvider;
|
||||
/// <summary>Persisted: whether the Hub's NavigationView pane is expanded
|
||||
/// (true) or collapsed/compact (false). Default true.</summary>
|
||||
public bool HubNavPaneOpen { get; set; } = true;
|
||||
/// <summary>Optional Windows TTS voice id (or display name). Empty = system default.</summary>
|
||||
public string? TtsWindowsVoiceId { get; set; }
|
||||
/// <summary>
|
||||
/// ElevenLabs API key storage slot. When persisted by the Windows tray's
|
||||
/// SettingsManager this is an opaque dpapi:-prefixed blob, not plaintext.
|
||||
/// </summary>
|
||||
public string TtsProvider { get; set; } = "windows";
|
||||
public string? TtsElevenLabsApiKey { get; set; }
|
||||
public string? TtsElevenLabsModel { get; set; }
|
||||
public string? TtsElevenLabsVoiceId { get; set; }
|
||||
/// <summary>Piper voice identifier, e.g. "en_US-amy-low". Voice file is downloaded on first use.</summary>
|
||||
public string TtsPiperVoiceId { get; set; } = "en_US-amy-low";
|
||||
/// <summary>Run the local MCP HTTP server. Independent of EnableNodeMode.</summary>
|
||||
public bool EnableMcpServer { get; set; } = false;
|
||||
/// <summary>
|
||||
@ -83,15 +53,12 @@ public class SettingsData
|
||||
/// </summary>
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public bool? McpOnlyMode { get; set; }
|
||||
public string? PreferredGatewayId { get; set; }
|
||||
public bool HasSeenActivityStreamTip { get; set; } = false;
|
||||
public string? SkippedUpdateTag { get; set; }
|
||||
public bool NotifyChatResponses { get; set; } = true;
|
||||
public bool PreferStructuredCategories { get; set; } = true;
|
||||
public List<UserNotificationRule>? UserRules { get; set; }
|
||||
|
||||
// ── (Voice / STT settings consolidated into the block above.) ──
|
||||
|
||||
private static readonly JsonSerializerOptions s_options = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
|
||||
@ -8,16 +8,6 @@ public static class SshTunnelCommandLine
|
||||
private static readonly Regex s_validSshUser = new(@"^[a-zA-Z0-9._-]+$", RegexOptions.Compiled);
|
||||
private static readonly Regex s_validSshHost = new(@"^[a-zA-Z0-9._-]+$", RegexOptions.Compiled);
|
||||
|
||||
// Fixed SSH options shared by every tunnel invocation.
|
||||
// Centralised here so the connection policy is visible and easy to review or adjust.
|
||||
private const string BaseOptions =
|
||||
"-o BatchMode=yes " +
|
||||
"-o ExitOnForwardFailure=yes " +
|
||||
"-o ServerAliveInterval=15 " +
|
||||
"-o ServerAliveCountMax=3 " +
|
||||
"-o TCPKeepAlive=yes " +
|
||||
"-N ";
|
||||
|
||||
public static string BuildArguments(string user, string host, int remotePort, int localPort)
|
||||
=> BuildArguments(user, host, remotePort, localPort, includeBrowserProxyForward: false);
|
||||
|
||||
@ -43,7 +33,13 @@ public static class SshTunnelCommandLine
|
||||
ValidateBrowserProxyPort(localPort, nameof(localPort));
|
||||
}
|
||||
|
||||
var sb = new StringBuilder(BaseOptions);
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("-o BatchMode=yes ");
|
||||
sb.Append("-o ExitOnForwardFailure=yes ");
|
||||
sb.Append("-o ServerAliveInterval=15 ");
|
||||
sb.Append("-o ServerAliveCountMax=3 ");
|
||||
sb.Append("-o TCPKeepAlive=yes ");
|
||||
sb.Append("-N ");
|
||||
AppendLocalForward(sb, localPort, remotePort);
|
||||
if (includeBrowserProxyForward)
|
||||
AppendLocalForward(sb, localPort + 2, remotePort + 2);
|
||||
|
||||
@ -12,10 +12,6 @@ public static class TokenSanitizer
|
||||
@"""(?<key>[^""]*(?:token|secret|bearer|authorization)[^""]*)""\s*:\s*""(?<value>[^""]+)""",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
|
||||
|
||||
private static readonly Regex BareGatewayHexTokenPattern = new(
|
||||
@"(?<![0-9A-Fa-f])[0-9a-f]{64}(?![0-9A-Fa-f])",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex LongBase64UrlPattern = new(
|
||||
@"(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
@ -29,7 +25,6 @@ public static class TokenSanitizer
|
||||
sanitized = JsonSecretFieldPattern.Replace(
|
||||
sanitized,
|
||||
match => $"\"{match.Groups["key"].Value}\":\"[REDACTED]\"");
|
||||
sanitized = BareGatewayHexTokenPattern.Replace(sanitized, "[REDACTED_TOKEN]");
|
||||
return LongBase64UrlPattern.Replace(sanitized, "[REDACTED_TOKEN]");
|
||||
}
|
||||
}
|
||||
|
||||
@ -251,10 +251,6 @@ public abstract class WebSocketClientBase : IDisposable
|
||||
while (!_disposed && !_cts.Token.IsCancellationRequested && ShouldAutoReconnect())
|
||||
{
|
||||
var delay = BackoffMs[Math.Min(_reconnectAttempts, BackoffMs.Length - 1)];
|
||||
// Add 0-25% jitter to prevent thundering herd when multiple clients
|
||||
// (operator + node) reconnect on the same schedule
|
||||
var jitter = Random.Shared.Next(0, delay / 4);
|
||||
delay += jitter;
|
||||
_reconnectAttempts++;
|
||||
_logger.Warn($"{ClientRole} reconnecting in {delay}ms (attempt {_reconnectAttempts})");
|
||||
RaiseStatusChanged(ConnectionStatus.Connecting);
|
||||
|
||||
@ -30,14 +30,6 @@ public class WindowsNodeClient : WebSocketClientBase
|
||||
private bool _isPaired;
|
||||
// Bridges the gap between an approval event and the next hello-ok when the gateway omits auth.deviceToken.
|
||||
private bool _pairingApprovedAwaitingReconnect;
|
||||
// Persists across disconnect/error so ShouldAutoReconnect can block reconnect
|
||||
// even after OnDisconnected clears _isPendingApproval.
|
||||
private volatile bool _pairingBlocked;
|
||||
private volatile bool _rateLimited;
|
||||
// Bug 3: source-side idempotency for PairingStatusChanged. HandleHelloOk runs on every
|
||||
// WS reconnect and re-fires PairingStatus.Paired even when nothing changed, causing a
|
||||
// toast storm in the tray UI. Track the last emitted status and only fire on transitions.
|
||||
private PairingStatus? _lastEmittedPairingStatus;
|
||||
private readonly string _gatewayToken;
|
||||
private readonly string? _bootstrapToken;
|
||||
|
||||
@ -65,7 +57,7 @@ public class WindowsNodeClient : WebSocketClientBase
|
||||
public bool IsPendingApproval => _isPendingApproval;
|
||||
|
||||
/// <summary>True if device is paired via a stored token or an explicit gateway approval event.</summary>
|
||||
public bool IsPaired => _isPaired || !string.IsNullOrEmpty(_deviceIdentity.NodeDeviceToken);
|
||||
public bool IsPaired => _isPaired || !string.IsNullOrEmpty(_deviceIdentity.DeviceToken);
|
||||
|
||||
/// <summary>Device ID for display/approval (first 16 chars of full ID)</summary>
|
||||
public string ShortDeviceId => _deviceIdentity.DeviceId.Length > 16
|
||||
@ -82,7 +74,7 @@ public class WindowsNodeClient : WebSocketClientBase
|
||||
protected override string ClientRole => "node";
|
||||
|
||||
public WindowsNodeClient(string gatewayUrl, string token, string dataPath, IOpenClawLogger? logger = null, string? bootstrapToken = null)
|
||||
: base(gatewayUrl, ResolveRequiredCredential(token, bootstrapToken, dataPath, logger), logger)
|
||||
: base(gatewayUrl, ResolveRequiredCredential(token, bootstrapToken, dataPath), logger)
|
||||
{
|
||||
_gatewayToken = NormalizeOptionalCredential(token);
|
||||
_bootstrapToken = NormalizeOptionalCredential(bootstrapToken);
|
||||
@ -106,14 +98,8 @@ public class WindowsNodeClient : WebSocketClientBase
|
||||
return string.IsNullOrWhiteSpace(credential) ? string.Empty : credential;
|
||||
}
|
||||
|
||||
private static string ResolveRequiredCredential(string? token, string? bootstrapToken, string dataPath, IOpenClawLogger? logger)
|
||||
private static string ResolveRequiredCredential(string? token, string? bootstrapToken, string dataPath)
|
||||
{
|
||||
var storedNodeToken = TryLoadStoredNodeToken(dataPath, logger);
|
||||
if (!string.IsNullOrEmpty(storedNodeToken))
|
||||
{
|
||||
return storedNodeToken;
|
||||
}
|
||||
|
||||
var gatewayToken = NormalizeOptionalCredential(token);
|
||||
if (!string.IsNullOrEmpty(gatewayToken))
|
||||
{
|
||||
@ -126,27 +112,14 @@ public class WindowsNodeClient : WebSocketClientBase
|
||||
return bootstrap;
|
||||
}
|
||||
|
||||
var storedDeviceToken = DeviceIdentity.TryReadStoredDeviceToken(dataPath);
|
||||
if (!string.IsNullOrEmpty(storedDeviceToken))
|
||||
{
|
||||
return storedDeviceToken;
|
||||
}
|
||||
|
||||
throw new ArgumentException("Token or bootstrap token is required.", nameof(token));
|
||||
}
|
||||
|
||||
public static bool HasStoredNodeDeviceToken(string dataPath, IOpenClawLogger? logger = null)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(TryLoadStoredNodeToken(dataPath, logger));
|
||||
}
|
||||
|
||||
private static string? TryLoadStoredNodeToken(string dataPath, IOpenClawLogger? logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
var identity = new DeviceIdentity(dataPath, logger);
|
||||
identity.Initialize();
|
||||
return string.IsNullOrWhiteSpace(identity.NodeDeviceToken) ? null : identity.NodeDeviceToken;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a capability handler
|
||||
@ -213,7 +186,7 @@ public class WindowsNodeClient : WebSocketClientBase
|
||||
try
|
||||
{
|
||||
// Log raw messages at debug level (visible in dbgview, not in log file noise)
|
||||
_logger.Debug($"[NODE RX] {TokenSanitizer.Sanitize(json)}");
|
||||
_logger.Debug($"[NODE RX] {json}");
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
@ -304,12 +277,11 @@ public class WindowsNodeClient : WebSocketClientBase
|
||||
|
||||
_isPendingApproval = true;
|
||||
_isPaired = false;
|
||||
_pairingBlocked = true;
|
||||
_pairingApprovedAwaitingReconnect = false;
|
||||
|
||||
_logger.Info($"[NODE] Pairing requested for this device via {eventType}");
|
||||
_logger.Info($"To approve, run: openclaw devices approve {_deviceIdentity.DeviceId}");
|
||||
EmitPairingStatusOnTransition(new PairingStatusEventArgs(
|
||||
PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs(
|
||||
PairingStatus.Pending,
|
||||
_deviceIdentity.DeviceId,
|
||||
$"Run: openclaw devices approve {ShortDeviceId}..."));
|
||||
@ -338,10 +310,9 @@ public class WindowsNodeClient : WebSocketClientBase
|
||||
{
|
||||
_isPendingApproval = false;
|
||||
_isPaired = true;
|
||||
_pairingBlocked = false; // Allow reconnect after approval
|
||||
_pairingApprovedAwaitingReconnect = true;
|
||||
|
||||
EmitPairingStatusOnTransition(new PairingStatusEventArgs(
|
||||
PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs(
|
||||
PairingStatus.Paired,
|
||||
_deviceIdentity.DeviceId,
|
||||
"Pairing approved; reconnecting to refresh node state."));
|
||||
@ -357,7 +328,7 @@ public class WindowsNodeClient : WebSocketClientBase
|
||||
_isPaired = false;
|
||||
_pairingApprovedAwaitingReconnect = false;
|
||||
|
||||
EmitPairingStatusOnTransition(new PairingStatusEventArgs(
|
||||
PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs(
|
||||
PairingStatus.Rejected,
|
||||
_deviceIdentity.DeviceId,
|
||||
null));
|
||||
@ -528,7 +499,7 @@ public class WindowsNodeClient : WebSocketClientBase
|
||||
|
||||
private async Task SendNodeConnectAsync(string? nonce, long ts)
|
||||
{
|
||||
var isPaired = !string.IsNullOrEmpty(_deviceIdentity.NodeDeviceToken);
|
||||
var isPaired = !string.IsNullOrEmpty(_deviceIdentity.DeviceToken);
|
||||
var usingBootstrap = !isPaired && !string.IsNullOrEmpty(_bootstrapToken);
|
||||
|
||||
_logger.Info($"Connecting with Ed25519 device identity (paired: {isPaired}, bootstrap: {usingBootstrap})");
|
||||
@ -598,9 +569,9 @@ public class WindowsNodeClient : WebSocketClientBase
|
||||
|
||||
private (Dictionary<string, string> Auth, string TokenForSignature) BuildConnectAuth()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_deviceIdentity.NodeDeviceToken))
|
||||
if (!string.IsNullOrEmpty(_deviceIdentity.DeviceToken))
|
||||
{
|
||||
return (new Dictionary<string, string> { ["deviceToken"] = _deviceIdentity.NodeDeviceToken }, _deviceIdentity.NodeDeviceToken);
|
||||
return (new Dictionary<string, string> { ["token"] = _deviceIdentity.DeviceToken }, _deviceIdentity.DeviceToken);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_bootstrapToken))
|
||||
@ -632,7 +603,6 @@ public class WindowsNodeClient : WebSocketClientBase
|
||||
PublishGatewaySelf(GatewaySelfInfo.FromHelloOk(payload));
|
||||
var reconnectingAfterApproval = _pairingApprovedAwaitingReconnect;
|
||||
_isConnected = true;
|
||||
_rateLimited = false; // Clear transient rate-limit on successful connect
|
||||
ResetReconnectAttempts();
|
||||
|
||||
// Extract node ID if returned
|
||||
@ -657,8 +627,8 @@ public class WindowsNodeClient : WebSocketClientBase
|
||||
_isPaired = true;
|
||||
_pairingApprovedAwaitingReconnect = false;
|
||||
_logger.Info("Received device token - we are now paired!");
|
||||
_deviceIdentity.StoreDeviceTokenForRole("node", deviceToken, TryGetAuthScopes(authPayload));
|
||||
EmitPairingStatusOnTransition(new PairingStatusEventArgs(
|
||||
_deviceIdentity.StoreDeviceToken(deviceToken);
|
||||
PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs(
|
||||
PairingStatus.Paired,
|
||||
_deviceIdentity.DeviceId,
|
||||
wasWaiting ? "Pairing approved!" : null));
|
||||
@ -671,7 +641,7 @@ public class WindowsNodeClient : WebSocketClientBase
|
||||
// Skip this block if we already fired PairingStatusChanged above via gotNewToken.
|
||||
if (!gotNewToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_deviceIdentity.NodeDeviceToken))
|
||||
if (string.IsNullOrEmpty(_deviceIdentity.DeviceToken))
|
||||
{
|
||||
if (reconnectingAfterApproval)
|
||||
{
|
||||
@ -684,10 +654,9 @@ public class WindowsNodeClient : WebSocketClientBase
|
||||
{
|
||||
_isPendingApproval = true;
|
||||
_isPaired = false;
|
||||
_pairingBlocked = true;
|
||||
_logger.Info("Not yet paired - check 'openclaw devices list' for pending approval");
|
||||
_logger.Info($"To approve, run: openclaw devices approve {_deviceIdentity.DeviceId}");
|
||||
EmitPairingStatusOnTransition(new PairingStatusEventArgs(
|
||||
PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs(
|
||||
PairingStatus.Pending,
|
||||
_deviceIdentity.DeviceId,
|
||||
$"Run: openclaw devices approve {ShortDeviceId}..."));
|
||||
@ -699,7 +668,7 @@ public class WindowsNodeClient : WebSocketClientBase
|
||||
_isPaired = true;
|
||||
_pairingApprovedAwaitingReconnect = false;
|
||||
_logger.Info("Already paired with stored device token");
|
||||
EmitPairingStatusOnTransition(new PairingStatusEventArgs(
|
||||
PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs(
|
||||
PairingStatus.Paired,
|
||||
_deviceIdentity.DeviceId));
|
||||
}
|
||||
@ -709,22 +678,6 @@ public class WindowsNodeClient : WebSocketClientBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bug 3: source-side suppression of duplicate PairingStatusChanged events from
|
||||
/// HandleHelloOk on WS reconnects. Only fire when the status differs from the last
|
||||
/// emitted status (or when nothing has been emitted yet).
|
||||
/// </summary>
|
||||
private void EmitPairingStatusOnTransition(PairingStatusEventArgs args)
|
||||
{
|
||||
if (_lastEmittedPairingStatus == args.Status)
|
||||
{
|
||||
_logger.Info($"[NODE] Suppressing duplicate pairing status event: {args.Status} for {args.DeviceId}");
|
||||
return;
|
||||
}
|
||||
_lastEmittedPairingStatus = args.Status;
|
||||
PairingStatusChanged?.Invoke(this, args);
|
||||
}
|
||||
|
||||
private void HandleRequestError(JsonElement root)
|
||||
{
|
||||
var error = "Unknown error";
|
||||
@ -764,7 +717,6 @@ public class WindowsNodeClient : WebSocketClientBase
|
||||
|
||||
_isPendingApproval = true;
|
||||
_isPaired = false;
|
||||
_pairingBlocked = true;
|
||||
_pairingApprovedAwaitingReconnect = false;
|
||||
|
||||
var detail = !string.IsNullOrWhiteSpace(pairingRequestId)
|
||||
@ -772,26 +724,14 @@ public class WindowsNodeClient : WebSocketClientBase
|
||||
: $"Run: openclaw devices approve {ShortDeviceId}...";
|
||||
_logger.Info($"[NODE] Pairing required for this device; reason={pairingReason ?? "unknown"}, requestId={pairingRequestId ?? "none"}");
|
||||
_logger.Info($"To approve, run: openclaw devices approve {_deviceIdentity.DeviceId}");
|
||||
EmitPairingStatusOnTransition(new PairingStatusEventArgs(
|
||||
PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs(
|
||||
PairingStatus.Pending,
|
||||
_deviceIdentity.DeviceId,
|
||||
detail));
|
||||
return;
|
||||
}
|
||||
|
||||
// Rate-limit / terminal auth errors — stop reconnecting
|
||||
if (error.Contains("too many failed", StringComparison.OrdinalIgnoreCase) ||
|
||||
error.Contains("rate limit", StringComparison.OrdinalIgnoreCase) ||
|
||||
error.Contains("origin not allowed", StringComparison.OrdinalIgnoreCase) ||
|
||||
error.Contains("token mismatch", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_rateLimited = true;
|
||||
_logger.Warn($"[NODE] Terminal auth error; stopping reconnect. Error: {TokenSanitizer.Sanitize(error)}");
|
||||
RaiseStatusChanged(ConnectionStatus.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Error($"Node registration failed: {TokenSanitizer.Sanitize(error)} (code: {errorCode})");
|
||||
_logger.Error($"Node registration failed: {error} (code: {errorCode})");
|
||||
RaiseStatusChanged(ConnectionStatus.Error);
|
||||
}
|
||||
|
||||
@ -839,27 +779,6 @@ public class WindowsNodeClient : WebSocketClientBase
|
||||
value = prop.GetString();
|
||||
return !string.IsNullOrWhiteSpace(value);
|
||||
}
|
||||
|
||||
private static string[]? TryGetAuthScopes(JsonElement authPayload)
|
||||
{
|
||||
if (!authPayload.TryGetProperty("scopes", out var scopes) || scopes.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var values = new List<string>();
|
||||
foreach (var item in scopes.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var value = item.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
values.Add(value);
|
||||
}
|
||||
}
|
||||
|
||||
return values.Count == 0 ? null : values.Distinct(StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
private async Task HandleRequestAsync(JsonElement root)
|
||||
{
|
||||
@ -1011,8 +930,16 @@ public class WindowsNodeClient : WebSocketClientBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a node.event request with JSON payload.
|
||||
/// Returns false when not connected or when the transport send fails.
|
||||
/// Send a generic node-event to the gateway. Mirrors the Android
|
||||
/// <c>GatewaySession.sendNodeEvent</c> wire shape: a JSON-RPC request with
|
||||
/// method <c>node.event</c> and params <c>{ event, payloadJSON }</c>,
|
||||
/// where <c>payloadJSON</c> is the inner payload as a *string*, not a
|
||||
/// nested object. The gateway's node-event dispatcher
|
||||
/// (<c>server-node-events.ts</c>) then re-parses it.
|
||||
///
|
||||
/// Returns false when not connected so callers can surface a status to the
|
||||
/// renderer (e.g. clear a button-loading spinner with an error). Throws on
|
||||
/// argument problems but swallows transport-layer errors as false.
|
||||
/// </summary>
|
||||
public async Task<bool> SendNodeEventAsync(string eventName, System.Text.Json.Nodes.JsonObject payload)
|
||||
{
|
||||
@ -1020,6 +947,9 @@ public class WindowsNodeClient : WebSocketClientBase
|
||||
if (payload is null) throw new ArgumentNullException(nameof(payload));
|
||||
if (!_isConnected) return false;
|
||||
|
||||
// payloadJSON is a STRING containing JSON, matching the Android wire
|
||||
// shape and the gateway's parser at server-node-events.ts:380 which
|
||||
// does JSON.parse(evt.payloadJSON).
|
||||
var msg = new
|
||||
{
|
||||
type = "req",
|
||||
@ -1067,20 +997,6 @@ public class WindowsNodeClient : WebSocketClientBase
|
||||
GatewaySelfUpdated?.Invoke(this, info);
|
||||
}
|
||||
|
||||
protected override bool ShouldAutoReconnect()
|
||||
{
|
||||
// Don't reconnect while awaiting pairing approval — each reconnect
|
||||
// generates a new pairing request on the gateway, causing a storm.
|
||||
// _pairingBlocked survives OnDisconnected (which clears _isPendingApproval).
|
||||
if (_pairingBlocked)
|
||||
return false;
|
||||
|
||||
if (_rateLimited)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected override void OnDisconnected()
|
||||
{
|
||||
_isConnected = false;
|
||||
|
||||
@ -14,9 +14,6 @@
|
||||
<SolidColorBrush x:Key="LobsterAccentBrush" Color="#E74C3C" />
|
||||
<SolidColorBrush x:Key="LobsterAccentBrushHover" Color="#C0392B" />
|
||||
|
||||
<!-- Hub NavigationView accent -->
|
||||
<SolidColorBrush x:Key="NavigationViewSelectionIndicatorForeground" Color="#E74C3C"/>
|
||||
|
||||
<!-- Custom Button Style -->
|
||||
<Style x:Key="AccentButtonStyle" TargetType="Button">
|
||||
<Setter Property="Background" Value="{StaticResource LobsterAccentBrush}" />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<UserControl
|
||||
x:Class="OpenClawTray.Controls.SchemaConfigEditor"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel x:Name="FieldsPanel" Spacing="4" Padding="0"/>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
@ -1,481 +0,0 @@
|
||||
using Microsoft.UI;
|
||||
using Microsoft.UI.Text;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace OpenClawTray.Controls;
|
||||
|
||||
public sealed partial class SchemaConfigEditor : UserControl
|
||||
{
|
||||
private JsonElement _schema;
|
||||
private JsonElement _config;
|
||||
private readonly Dictionary<string, object?> _changes = new();
|
||||
|
||||
private static readonly Regex CamelCaseSplitPattern = new(
|
||||
"([a-z])([A-Z])",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private static readonly SolidColorBrush SecondaryBrush =
|
||||
new(ColorHelper.FromArgb(255, 140, 150, 170));
|
||||
|
||||
public event EventHandler<Dictionary<string, object?>>? ConfigChanged;
|
||||
|
||||
public SchemaConfigEditor()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public void LoadSchema(JsonElement schema, JsonElement config)
|
||||
{
|
||||
_schema = schema;
|
||||
_config = config;
|
||||
_changes.Clear();
|
||||
FieldsPanel.Children.Clear();
|
||||
|
||||
try
|
||||
{
|
||||
RenderSchemaNode("", schema, config, FieldsPanel, 0);
|
||||
}
|
||||
catch { }
|
||||
|
||||
// If schema rendering produced nothing, fall back to rendering config as editable fields
|
||||
if (FieldsPanel.Children.Count == 0 && config.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
RenderConfigDirectly("", config, FieldsPanel, 0);
|
||||
}
|
||||
}
|
||||
|
||||
public Dictionary<string, object?> GetChanges() => new(_changes);
|
||||
|
||||
/// <summary>
|
||||
/// JSON Schema's "type" keyword may be either a string ("object") or an
|
||||
/// array of strings (["string","null"]). Returns the first non-null type
|
||||
/// when an array is encountered, or null if "type" is missing/unsupported.
|
||||
/// </summary>
|
||||
private static string? ExtractSchemaType(JsonElement schemaNode)
|
||||
{
|
||||
if (!schemaNode.TryGetProperty("type", out var typeEl)) return null;
|
||||
if (typeEl.ValueKind == JsonValueKind.String) return typeEl.GetString();
|
||||
if (typeEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in typeEl.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind != JsonValueKind.String) continue;
|
||||
var s = item.GetString();
|
||||
if (!string.IsNullOrEmpty(s) && s != "null") return s;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? SafeGetString(JsonElement parent, string propName)
|
||||
{
|
||||
if (!parent.TryGetProperty(propName, out var el)) return null;
|
||||
return el.ValueKind == JsonValueKind.String ? el.GetString() : null;
|
||||
}
|
||||
|
||||
private void RenderSchemaNode(string path, JsonElement schema, JsonElement config,
|
||||
StackPanel parent, int depth)
|
||||
{
|
||||
if (ExtractSchemaType(schema) == "object"
|
||||
&& schema.TryGetProperty("properties", out var props))
|
||||
{
|
||||
foreach (var prop in props.EnumerateObject())
|
||||
{
|
||||
var childPath = string.IsNullOrEmpty(path) ? prop.Name : $"{path}.{prop.Name}";
|
||||
var childConfig = config.ValueKind == JsonValueKind.Object
|
||||
&& config.TryGetProperty(prop.Name, out var cv)
|
||||
? cv
|
||||
: default;
|
||||
var childSchema = prop.Value;
|
||||
|
||||
var childType = ExtractSchemaType(childSchema);
|
||||
|
||||
if (childType == "object" && childSchema.TryGetProperty("properties", out _))
|
||||
{
|
||||
RenderObjectSection(childPath, prop.Name, childSchema, childConfig, parent, depth);
|
||||
}
|
||||
else
|
||||
{
|
||||
RenderField(childPath, prop.Name, childSchema, childConfig, parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RenderObjectSection(string path, string name, JsonElement schema,
|
||||
JsonElement config, StackPanel parent, int depth)
|
||||
{
|
||||
var title = GetLabel(path, name);
|
||||
var description = SafeGetString(schema, "description");
|
||||
|
||||
var expander = new Expander
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
HorizontalContentAlignment = HorizontalAlignment.Stretch,
|
||||
IsExpanded = true,
|
||||
Margin = new Thickness(0, 2, 0, 2)
|
||||
};
|
||||
|
||||
var headerPanel = new StackPanel { Spacing = 2 };
|
||||
headerPanel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = title,
|
||||
FontWeight = FontWeights.SemiBold
|
||||
});
|
||||
if (!string.IsNullOrEmpty(description))
|
||||
{
|
||||
headerPanel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = description,
|
||||
FontSize = 11,
|
||||
Foreground = SecondaryBrush,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
});
|
||||
}
|
||||
expander.Header = headerPanel;
|
||||
|
||||
var childPanel = new StackPanel { Spacing = 4, Padding = new Thickness(0, 4, 0, 4) };
|
||||
RenderSchemaNode(path, schema, config, childPanel, depth + 1);
|
||||
expander.Content = childPanel;
|
||||
|
||||
parent.Children.Add(expander);
|
||||
}
|
||||
|
||||
private void RenderField(string path, string name, JsonElement schema,
|
||||
JsonElement config, StackPanel parent)
|
||||
{
|
||||
var label = GetLabel(path, name);
|
||||
var description = SafeGetString(schema, "description");
|
||||
var type = ExtractSchemaType(schema) ?? "string";
|
||||
var isSensitive = IsSensitive(path);
|
||||
|
||||
// Resolve default value if config is missing
|
||||
var effectiveConfig = config;
|
||||
if (effectiveConfig.ValueKind == JsonValueKind.Undefined
|
||||
&& schema.TryGetProperty("default", out var defaultVal))
|
||||
{
|
||||
effectiveConfig = defaultVal;
|
||||
}
|
||||
|
||||
UIElement control;
|
||||
|
||||
if (schema.TryGetProperty("enum", out var enumEl)
|
||||
&& enumEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
control = RenderEnumField(path, label, description, enumEl, effectiveConfig);
|
||||
}
|
||||
else if (type == "boolean")
|
||||
{
|
||||
control = RenderBoolField(path, label, description, effectiveConfig);
|
||||
}
|
||||
else if (type == "integer" || type == "number")
|
||||
{
|
||||
control = RenderNumberField(path, label, description, type!, schema, effectiveConfig);
|
||||
}
|
||||
else if (type == "array" && schema.TryGetProperty("items", out var itemsSchema))
|
||||
{
|
||||
control = RenderArrayField(path, label, description, itemsSchema, effectiveConfig);
|
||||
}
|
||||
else // string (default)
|
||||
{
|
||||
control = isSensitive
|
||||
? RenderSensitiveField(path, label, description, effectiveConfig)
|
||||
: RenderStringField(path, label, description, effectiveConfig);
|
||||
}
|
||||
|
||||
parent.Children.Add(control);
|
||||
}
|
||||
|
||||
private UIElement RenderEnumField(string path, string label, string? description,
|
||||
JsonElement enumEl, JsonElement config)
|
||||
{
|
||||
var combo = new ComboBox { Header = label, MinWidth = 200 };
|
||||
if (!string.IsNullOrEmpty(description))
|
||||
ToolTipService.SetToolTip(combo, description);
|
||||
|
||||
var currentVal = config.ValueKind == JsonValueKind.String ? config.GetString() : null;
|
||||
foreach (var item in enumEl.EnumerateArray())
|
||||
{
|
||||
var val = item.GetString() ?? "";
|
||||
combo.Items.Add(val);
|
||||
if (val == currentVal) combo.SelectedItem = val;
|
||||
}
|
||||
|
||||
combo.SelectionChanged += (s, e) =>
|
||||
{
|
||||
_changes[path] = combo.SelectedItem as string;
|
||||
ConfigChanged?.Invoke(this, _changes);
|
||||
};
|
||||
return combo;
|
||||
}
|
||||
|
||||
private UIElement RenderBoolField(string path, string label, string? description,
|
||||
JsonElement config)
|
||||
{
|
||||
var toggle = new ToggleSwitch { Header = label };
|
||||
if (!string.IsNullOrEmpty(description))
|
||||
ToolTipService.SetToolTip(toggle, description);
|
||||
toggle.IsOn = config.ValueKind == JsonValueKind.True;
|
||||
toggle.Toggled += (s, e) =>
|
||||
{
|
||||
_changes[path] = toggle.IsOn;
|
||||
ConfigChanged?.Invoke(this, _changes);
|
||||
};
|
||||
return toggle;
|
||||
}
|
||||
|
||||
private UIElement RenderNumberField(string path, string label, string? description,
|
||||
string type, JsonElement schema, JsonElement config)
|
||||
{
|
||||
var numBox = new NumberBox
|
||||
{
|
||||
Header = label,
|
||||
SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Compact,
|
||||
MinWidth = 200
|
||||
};
|
||||
if (!string.IsNullOrEmpty(description))
|
||||
ToolTipService.SetToolTip(numBox, description);
|
||||
if (config.ValueKind == JsonValueKind.Number)
|
||||
numBox.Value = config.GetDouble();
|
||||
if (schema.TryGetProperty("minimum", out var min))
|
||||
numBox.Minimum = min.GetDouble();
|
||||
if (schema.TryGetProperty("maximum", out var max))
|
||||
numBox.Maximum = max.GetDouble();
|
||||
|
||||
numBox.ValueChanged += (s, e) =>
|
||||
{
|
||||
_changes[path] = type == "integer" ? (object)(int)numBox.Value : numBox.Value;
|
||||
ConfigChanged?.Invoke(this, _changes);
|
||||
};
|
||||
return numBox;
|
||||
}
|
||||
|
||||
private UIElement RenderStringField(string path, string label, string? description,
|
||||
JsonElement config)
|
||||
{
|
||||
var textBox = new TextBox { Header = label, MinWidth = 300 };
|
||||
if (!string.IsNullOrEmpty(description))
|
||||
ToolTipService.SetToolTip(textBox, description);
|
||||
if (config.ValueKind == JsonValueKind.String)
|
||||
textBox.Text = config.GetString() ?? "";
|
||||
else if (config.ValueKind != JsonValueKind.Undefined
|
||||
&& config.ValueKind != JsonValueKind.Null)
|
||||
textBox.Text = config.ToString();
|
||||
|
||||
textBox.TextChanged += (s, e) =>
|
||||
{
|
||||
_changes[path] = textBox.Text;
|
||||
ConfigChanged?.Invoke(this, _changes);
|
||||
};
|
||||
return textBox;
|
||||
}
|
||||
|
||||
private UIElement RenderSensitiveField(string path, string label, string? description,
|
||||
JsonElement config)
|
||||
{
|
||||
var pwBox = new PasswordBox { Header = label, Width = 350 };
|
||||
if (!string.IsNullOrEmpty(description))
|
||||
ToolTipService.SetToolTip(pwBox, description);
|
||||
if (config.ValueKind == JsonValueKind.String)
|
||||
pwBox.Password = config.GetString() ?? "";
|
||||
|
||||
pwBox.PasswordChanged += (s, e) =>
|
||||
{
|
||||
_changes[path] = pwBox.Password;
|
||||
ConfigChanged?.Invoke(this, _changes);
|
||||
};
|
||||
return pwBox;
|
||||
}
|
||||
|
||||
private UIElement RenderArrayField(string path, string label, string? description,
|
||||
JsonElement itemsSchema, JsonElement config)
|
||||
{
|
||||
var panel = new StackPanel { Spacing = 4 };
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = label,
|
||||
FontWeight = FontWeights.SemiBold
|
||||
});
|
||||
if (!string.IsNullOrEmpty(description))
|
||||
{
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = description,
|
||||
FontSize = 11,
|
||||
Foreground = SecondaryBrush,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
});
|
||||
}
|
||||
|
||||
var itemsPanel = new StackPanel { Spacing = 2 };
|
||||
|
||||
if (config.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in config.EnumerateArray())
|
||||
{
|
||||
AddArrayItem(itemsPanel, path, item.GetString() ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
panel.Children.Add(itemsPanel);
|
||||
|
||||
var addBtn = new Button
|
||||
{
|
||||
Content = "+ Add",
|
||||
Margin = new Thickness(0, 4, 0, 0)
|
||||
};
|
||||
addBtn.Click += (s, e) =>
|
||||
{
|
||||
AddArrayItem(itemsPanel, path, "");
|
||||
UpdateArrayChanges(itemsPanel, path);
|
||||
};
|
||||
panel.Children.Add(addBtn);
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
private void AddArrayItem(StackPanel itemsPanel, string path, string value)
|
||||
{
|
||||
var row = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 4 };
|
||||
var textBox = new TextBox { Text = value, MinWidth = 250 };
|
||||
textBox.TextChanged += (s, e) => UpdateArrayChanges(itemsPanel, path);
|
||||
|
||||
var removeBtn = new Button
|
||||
{
|
||||
Content = "\u2715",
|
||||
Width = 28,
|
||||
Height = 28,
|
||||
Padding = new Thickness(0)
|
||||
};
|
||||
removeBtn.Click += (s, e) =>
|
||||
{
|
||||
itemsPanel.Children.Remove(row);
|
||||
UpdateArrayChanges(itemsPanel, path);
|
||||
};
|
||||
|
||||
row.Children.Add(textBox);
|
||||
row.Children.Add(removeBtn);
|
||||
itemsPanel.Children.Add(row);
|
||||
}
|
||||
|
||||
private void UpdateArrayChanges(StackPanel itemsPanel, string path)
|
||||
{
|
||||
var values = new List<string>();
|
||||
foreach (var child in itemsPanel.Children)
|
||||
{
|
||||
if (child is StackPanel row && row.Children.Count > 0
|
||||
&& row.Children[0] is TextBox tb)
|
||||
{
|
||||
values.Add(tb.Text);
|
||||
}
|
||||
}
|
||||
_changes[path] = values.ToArray();
|
||||
ConfigChanged?.Invoke(this, _changes);
|
||||
}
|
||||
|
||||
private static string GetLabel(string path, string name)
|
||||
{
|
||||
var result = CamelCaseSplitPattern.Replace(name, "$1 $2");
|
||||
result = result.Replace("_", " ").Replace(".", " \u203A ");
|
||||
// Title-case the first character
|
||||
if (result.Length > 0)
|
||||
result = char.ToUpperInvariant(result[0]) + result[1..];
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool IsSensitive(string path)
|
||||
{
|
||||
var lower = path.ToLowerInvariant();
|
||||
return lower.Contains("token") || lower.Contains("secret")
|
||||
|| lower.Contains("password") || lower.Contains("apikey")
|
||||
|| lower.Contains("api_key");
|
||||
}
|
||||
|
||||
/// <summary>Fallback: render config values directly as editable fields when no schema available.</summary>
|
||||
private void RenderConfigDirectly(string path, JsonElement config, StackPanel parent, int depth)
|
||||
{
|
||||
if (config.ValueKind != JsonValueKind.Object) return;
|
||||
|
||||
foreach (var prop in config.EnumerateObject())
|
||||
{
|
||||
var childPath = string.IsNullOrEmpty(path) ? prop.Name : $"{path}.{prop.Name}";
|
||||
var value = prop.Value;
|
||||
|
||||
switch (value.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
var expander = new Expander
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
HorizontalContentAlignment = HorizontalAlignment.Stretch,
|
||||
IsExpanded = true,
|
||||
Margin = new Thickness(0, 2, 0, 2)
|
||||
};
|
||||
expander.Header = new TextBlock { Text = GetLabel(childPath, prop.Name), FontWeight = FontWeights.SemiBold };
|
||||
var childPanel = new StackPanel { Spacing = 4, Padding = new Thickness(0, 4, 0, 4) };
|
||||
RenderConfigDirectly(childPath, value, childPanel, depth + 1);
|
||||
expander.Content = childPanel;
|
||||
parent.Children.Add(expander);
|
||||
break;
|
||||
|
||||
case JsonValueKind.True:
|
||||
case JsonValueKind.False:
|
||||
var toggle = new ToggleSwitch { Header = GetLabel(childPath, prop.Name), IsOn = value.ValueKind == JsonValueKind.True };
|
||||
toggle.Toggled += (s, e) => { _changes[childPath] = toggle.IsOn; ConfigChanged?.Invoke(this, _changes); };
|
||||
parent.Children.Add(toggle);
|
||||
break;
|
||||
|
||||
case JsonValueKind.Number:
|
||||
var numBox = new NumberBox
|
||||
{
|
||||
Header = GetLabel(childPath, prop.Name),
|
||||
Value = value.GetDouble(),
|
||||
SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Compact,
|
||||
MinWidth = 200
|
||||
};
|
||||
numBox.ValueChanged += (s, e) => { _changes[childPath] = numBox.Value; ConfigChanged?.Invoke(this, _changes); };
|
||||
parent.Children.Add(numBox);
|
||||
break;
|
||||
|
||||
case JsonValueKind.String:
|
||||
if (IsSensitive(childPath))
|
||||
{
|
||||
var pwBox = new PasswordBox { Header = GetLabel(childPath, prop.Name), Width = 350 };
|
||||
pwBox.Password = value.GetString() ?? "";
|
||||
pwBox.PasswordChanged += (s, e) => { _changes[childPath] = pwBox.Password; ConfigChanged?.Invoke(this, _changes); };
|
||||
parent.Children.Add(pwBox);
|
||||
}
|
||||
else
|
||||
{
|
||||
var textBox = new TextBox { Header = GetLabel(childPath, prop.Name), Text = value.GetString() ?? "", MinWidth = 300 };
|
||||
textBox.TextChanged += (s, e) => { _changes[childPath] = textBox.Text; ConfigChanged?.Invoke(this, _changes); };
|
||||
parent.Children.Add(textBox);
|
||||
}
|
||||
break;
|
||||
|
||||
case JsonValueKind.Array:
|
||||
var arrayLabel = new TextBlock { Text = GetLabel(childPath, prop.Name), FontWeight = FontWeights.SemiBold, Margin = new Thickness(0, 8, 0, 4) };
|
||||
parent.Children.Add(arrayLabel);
|
||||
var arrayText = new TextBox
|
||||
{
|
||||
Text = value.ToString(),
|
||||
IsReadOnly = true,
|
||||
AcceptsReturn = true,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
MaxHeight = 100,
|
||||
FontFamily = new FontFamily("Consolas"),
|
||||
FontSize = 11
|
||||
};
|
||||
parent.Children.Add(arrayText);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,259 +0,0 @@
|
||||
using OpenClaw.Shared;
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace OpenClawTray.Dialogs;
|
||||
|
||||
// Bug #3 (manual test 2026-05-05): QuickSendDialog used to capture the App's
|
||||
// gateway client at constructor time into a readonly field. After autopair (or
|
||||
// any other path that swapped App._gatewayClient — SSH tunnel restart, manual
|
||||
// ConnectionPage re-pair, onboarding completion), the dialog kept sending into
|
||||
// the stale instance which still reported NOT_PAIRED, triggering the
|
||||
// "copy pair command to clipboard" remediation toast against a perfectly
|
||||
// paired live client.
|
||||
//
|
||||
// This file extracts the per-Send logic into a pure, UI-free coordinator that:
|
||||
// 1. Resolves the live gateway client from a Func<> provider on every Send.
|
||||
// 2. Defines explicit behavior for null / disposed / swap-window cases.
|
||||
// 3. Returns a discriminated outcome the dialog renders.
|
||||
//
|
||||
// RubberDucky closure conditions #1 (scope), #2 (lifetime contract) and #3
|
||||
// (genuine-unpaired regression test) are all satisfied by tests over this
|
||||
// coordinator (see tests/OpenClaw.Tray.Tests/QuickSendCoordinatorTests.cs).
|
||||
|
||||
/// <summary>
|
||||
/// Minimal gateway surface QuickSend needs. Wrapping the real
|
||||
/// <see cref="OpenClawGatewayClient"/> behind this interface keeps
|
||||
/// <see cref="QuickSendCoordinator"/> testable without spinning up a real
|
||||
/// WebSocket client.
|
||||
/// </summary>
|
||||
public interface IQuickSendGateway
|
||||
{
|
||||
bool IsConnectedToGateway { get; }
|
||||
Task ConnectAsync();
|
||||
Task SendChatMessageAsync(string message);
|
||||
string BuildPairingApprovalFixCommands();
|
||||
string BuildMissingScopeFixCommands(string missingScope);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adapter that exposes the live <see cref="OpenClawGatewayClient"/> through
|
||||
/// <see cref="IQuickSendGateway"/> for the production wiring.
|
||||
/// </summary>
|
||||
public sealed class OpenClawGatewayClientAdapter : IQuickSendGateway
|
||||
{
|
||||
private readonly OpenClawGatewayClient _client;
|
||||
|
||||
public OpenClawGatewayClientAdapter(OpenClawGatewayClient client)
|
||||
{
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
|
||||
public bool IsConnectedToGateway => _client.IsConnectedToGateway;
|
||||
public Task ConnectAsync() => _client.ConnectAsync();
|
||||
public Task SendChatMessageAsync(string message) => _client.SendChatMessageAsync(message);
|
||||
public string BuildPairingApprovalFixCommands() => _client.BuildPairingApprovalFixCommands();
|
||||
public string BuildMissingScopeFixCommands(string missingScope) => _client.BuildMissingScopeFixCommands(missingScope);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discriminated outcome of a single Send attempt. The dialog renders the
|
||||
/// outcome; the coordinator never touches UI.
|
||||
/// </summary>
|
||||
public abstract record QuickSendOutcome
|
||||
{
|
||||
/// <summary>Message accepted by the gateway.</summary>
|
||||
public sealed record Sent : QuickSendOutcome;
|
||||
|
||||
/// <summary>
|
||||
/// Gateway client provider returned null (or a previously-disposed
|
||||
/// instance was detected) — the App is mid-swap (init, restart, autopair
|
||||
/// reinit). DO NOT show the clipboard-pairing remediation; show a
|
||||
/// "still initializing" message and let the user retry.
|
||||
/// </summary>
|
||||
public sealed record GatewayInitializing(string Message) : QuickSendOutcome;
|
||||
|
||||
/// <summary>
|
||||
/// Live current client genuinely reports NOT_PAIRED. Clipboard remediation
|
||||
/// MUST still fire — this is the path Mike explicitly does not want
|
||||
/// suppressed.
|
||||
/// </summary>
|
||||
public sealed record PairingRequired(string Commands) : QuickSendOutcome;
|
||||
|
||||
/// <summary>Live current client is missing a required operator scope.</summary>
|
||||
public sealed record MissingScope(string Scope, string Commands) : QuickSendOutcome;
|
||||
|
||||
/// <summary>Any other failure (timeout, transport, dispose race, etc.).</summary>
|
||||
public sealed record Failed(string ErrorMessage) : QuickSendOutcome;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure (no UI, no static state) per-Send orchestrator. The dialog passes a
|
||||
/// <see cref="Func{T}"/> that reads <c>App._gatewayClient</c> on every Send
|
||||
/// so a swap underneath the dialog is observed before remediation decisions
|
||||
/// are made.
|
||||
/// </summary>
|
||||
public sealed class QuickSendCoordinator
|
||||
{
|
||||
/// <summary>
|
||||
/// Provider/lifetime contract — see Bug #3 plan §3 and RubberDucky
|
||||
/// closure condition #2:
|
||||
///
|
||||
/// (a) Provider returns null => GatewayInitializing (no clipboard toast).
|
||||
/// Reason: App is between Dispose() and the next assignment of
|
||||
/// _gatewayClient (SSH tunnel restart, onboarding swap), or the field
|
||||
/// has not yet been initialized.
|
||||
/// (b) Provider returns a previously-disposed instance => SendChatMessageAsync
|
||||
/// throws "Gateway connection is not open" or ObjectDisposedException;
|
||||
/// coordinator catches and returns Failed (NOT clipboard).
|
||||
/// (c) Provider returns a live client that genuinely reports NOT_PAIRED =>
|
||||
/// PairingRequired (clipboard toast STILL fires — built from the
|
||||
/// resolved current client, never a captured stale one).
|
||||
/// </summary>
|
||||
private readonly Func<IQuickSendGateway?> _provider;
|
||||
private readonly int _connectTimeoutMs;
|
||||
private readonly int _providerRetryDelayMs;
|
||||
private readonly Func<int, Task> _delayAsync;
|
||||
|
||||
public QuickSendCoordinator(
|
||||
Func<IQuickSendGateway?> provider,
|
||||
int connectTimeoutMs = 3000,
|
||||
int providerRetryDelayMs = 100,
|
||||
Func<int, Task>? delayAsync = null)
|
||||
{
|
||||
_provider = provider ?? throw new ArgumentNullException(nameof(provider));
|
||||
_connectTimeoutMs = connectTimeoutMs;
|
||||
_providerRetryDelayMs = providerRetryDelayMs;
|
||||
_delayAsync = delayAsync ?? Task.Delay;
|
||||
}
|
||||
|
||||
public async Task<QuickSendOutcome> SendAsync(string message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
return new QuickSendOutcome.Failed("Message is empty.");
|
||||
}
|
||||
|
||||
// Resolve live client. If the App is mid-swap (e.g., between Dispose
|
||||
// and the next InitializeGatewayClient assignment), the provider
|
||||
// returns null briefly. Retry once after a short delay to absorb the
|
||||
// window without surfacing a spurious "initializing" message.
|
||||
var client = ResolveClient();
|
||||
if (client == null)
|
||||
{
|
||||
await _delayAsync(_providerRetryDelayMs).ConfigureAwait(false);
|
||||
client = ResolveClient();
|
||||
}
|
||||
|
||||
if (client == null)
|
||||
{
|
||||
return new QuickSendOutcome.GatewayInitializing(
|
||||
"Gateway is still initializing. Please try again in a moment.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!await EnsureConnectedAsync(client, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return new QuickSendOutcome.Failed("Gateway connection is not open");
|
||||
}
|
||||
|
||||
await client.SendChatMessageAsync(message).ConfigureAwait(false);
|
||||
return new QuickSendOutcome.Sent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ClassifyFailure(client, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private IQuickSendGateway? ResolveClient()
|
||||
{
|
||||
try
|
||||
{
|
||||
return _provider();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Provider is `() => _gatewayClient` — the field read itself
|
||||
// can't throw, but defensive belt-and-braces against future
|
||||
// provider implementations.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> EnsureConnectedAsync(IQuickSendGateway client, CancellationToken cancellationToken)
|
||||
{
|
||||
if (client.IsConnectedToGateway) return true;
|
||||
|
||||
try
|
||||
{
|
||||
await client.ConnectAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Connect errors surface via the subsequent send.
|
||||
}
|
||||
|
||||
var deadline = Environment.TickCount64 + _connectTimeoutMs;
|
||||
while (Environment.TickCount64 < deadline)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) return false;
|
||||
if (client.IsConnectedToGateway) return true;
|
||||
await _delayAsync(120).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return client.IsConnectedToGateway;
|
||||
}
|
||||
|
||||
private static QuickSendOutcome ClassifyFailure(IQuickSendGateway client, Exception ex)
|
||||
{
|
||||
// ObjectDisposedException happens when the resolved client was
|
||||
// disposed mid-send (case (b) of the lifetime contract). Surface as
|
||||
// a clean Failed — never as the clipboard pairing remediation.
|
||||
if (ex is ObjectDisposedException)
|
||||
{
|
||||
return new QuickSendOutcome.Failed(
|
||||
"Gateway client was reset mid-send. Please try again.");
|
||||
}
|
||||
|
||||
var msg = ex.Message;
|
||||
if (IsPairingRequired(msg))
|
||||
{
|
||||
// Built from the live current client (resolved in this call), not
|
||||
// any captured stale snapshot — closes Bug #3 root cause.
|
||||
var commands = client.BuildPairingApprovalFixCommands();
|
||||
return new QuickSendOutcome.PairingRequired(commands);
|
||||
}
|
||||
|
||||
if (TryExtractMissingScope(msg, out var scope))
|
||||
{
|
||||
var commands = client.BuildMissingScopeFixCommands(scope);
|
||||
return new QuickSendOutcome.MissingScope(scope, commands);
|
||||
}
|
||||
|
||||
return new QuickSendOutcome.Failed(msg);
|
||||
}
|
||||
|
||||
internal static bool IsPairingRequired(string? message)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message)) return false;
|
||||
return message.Contains("pairing required", StringComparison.OrdinalIgnoreCase)
|
||||
|| message.Contains("not paired", StringComparison.OrdinalIgnoreCase)
|
||||
|| message.Contains("NOT_PAIRED", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
internal static bool TryExtractMissingScope(string? message, out string scope)
|
||||
{
|
||||
scope = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(message)) return false;
|
||||
|
||||
var match = Regex.Match(message, @"missing\s+scope\s*:\s*([A-Za-z0-9._-]+)", RegexOptions.IgnoreCase);
|
||||
if (!match.Success) return false;
|
||||
|
||||
scope = match.Groups[1].Value;
|
||||
return !string.IsNullOrWhiteSpace(scope);
|
||||
}
|
||||
}
|
||||
@ -9,6 +9,7 @@ using OpenClawTray.Services;
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using System.Text.RegularExpressions;
|
||||
using WinUIEx;
|
||||
|
||||
namespace OpenClawTray.Dialogs;
|
||||
@ -18,21 +19,12 @@ namespace OpenClawTray.Dialogs;
|
||||
/// </summary>
|
||||
public sealed class QuickSendDialog : WindowEx
|
||||
{
|
||||
// Bug #3 (manual test 2026-05-05): resolve the live App._gatewayClient
|
||||
// on every Send via this provider instead of capturing a single instance
|
||||
// at construction time. This survives autopair / SSH-tunnel-restart /
|
||||
// manual-pair / onboarding-completion swaps under the dialog.
|
||||
private readonly Func<OpenClawGatewayClient?> _clientProvider;
|
||||
private readonly QuickSendCoordinator _coordinator;
|
||||
private readonly OpenClawGatewayClient _client;
|
||||
private readonly TextBox _messageTextBox;
|
||||
private readonly TextBox _errorDetailsTextBox;
|
||||
private readonly Button _sendButton;
|
||||
private bool _isSending;
|
||||
private bool _isClosed;
|
||||
private bool _focusRetryRunning;
|
||||
|
||||
private const string TitleIcon = "🦞";
|
||||
private const double WindowControlsReservedWidth = 140;
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
|
||||
@ -50,26 +42,18 @@ public sealed class QuickSendDialog : WindowEx
|
||||
uint uFlags);
|
||||
|
||||
private static readonly IntPtr HWND_TOPMOST = new(-1);
|
||||
private const int TitleBarHeight = 48;
|
||||
private const int SW_SHOWNORMAL = 1;
|
||||
private const uint SWP_NOMOVE = 0x0002;
|
||||
private const uint SWP_NOSIZE = 0x0001;
|
||||
private const uint SWP_SHOWWINDOW = 0x0040;
|
||||
|
||||
public QuickSendDialog(Func<OpenClawGatewayClient?> clientProvider, string? prefillMessage = null)
|
||||
public QuickSendDialog(OpenClawGatewayClient client, string? prefillMessage = null)
|
||||
{
|
||||
_clientProvider = clientProvider ?? throw new ArgumentNullException(nameof(clientProvider));
|
||||
_coordinator = new QuickSendCoordinator(() =>
|
||||
{
|
||||
var live = _clientProvider();
|
||||
return live == null ? null : new OpenClawGatewayClientAdapter(live);
|
||||
});
|
||||
|
||||
_client = client;
|
||||
|
||||
// Window setup
|
||||
Title = LocalizationHelper.GetString("WindowTitle_QuickSend");
|
||||
ExtendsContentIntoTitleBar = true;
|
||||
this.SetWindowSize(420, 260 + TitleBarHeight);
|
||||
this.SetWindowSize(420, 260);
|
||||
this.CenterOnScreen();
|
||||
this.SetIcon(IconHelper.GetStatusIconPath(ConnectionStatus.Connected));
|
||||
|
||||
@ -78,9 +62,9 @@ public sealed class QuickSendDialog : WindowEx
|
||||
BackdropHelper.TrySetAcrylicBackdrop((Microsoft.UI.Xaml.Window)this);
|
||||
|
||||
// Hotkey-launched windows can fail to foreground on Windows 10 due to
|
||||
// foreground activation restrictions. Keep the existing topmost promotion.
|
||||
// foreground activation restrictions. Ensure the window is topmost.
|
||||
this.IsAlwaysOnTop = true;
|
||||
|
||||
|
||||
// Build UI programmatically (simple dialog)
|
||||
var root = new Grid
|
||||
{
|
||||
@ -146,57 +130,20 @@ public sealed class QuickSendDialog : WindowEx
|
||||
Grid.SetRow(buttonPanel, 3);
|
||||
root.Children.Add(buttonPanel);
|
||||
|
||||
var body = new Border
|
||||
Content = new Border
|
||||
{
|
||||
Padding = new Thickness(24),
|
||||
Child = root
|
||||
};
|
||||
|
||||
var outerGrid = new Grid();
|
||||
outerGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(TitleBarHeight) });
|
||||
outerGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
|
||||
|
||||
var titleBar = new Grid { Padding = new Thickness(16, 0, WindowControlsReservedWidth, 0) };
|
||||
var titleStack = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
titleStack.Children.Add(new TextBlock
|
||||
{
|
||||
Text = TitleIcon,
|
||||
FontSize = 20,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 10, 0)
|
||||
});
|
||||
titleStack.Children.Add(new TextBlock
|
||||
{
|
||||
Text = LocalizationHelper.GetString("WindowTitle_QuickSend"),
|
||||
FontSize = 13,
|
||||
Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"],
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
});
|
||||
titleBar.Children.Add(titleStack);
|
||||
Grid.SetRow(titleBar, 0);
|
||||
outerGrid.Children.Add(titleBar);
|
||||
|
||||
Grid.SetRow(body, 1);
|
||||
outerGrid.Children.Add(body);
|
||||
|
||||
Content = outerGrid;
|
||||
SetTitleBar(titleBar);
|
||||
|
||||
// Focus the text box when shown without closing on transient deactivation.
|
||||
// Focus the text box when shown
|
||||
Activated += (s, e) =>
|
||||
{
|
||||
if (e.WindowActivationState != WindowActivationState.Deactivated)
|
||||
{
|
||||
TryBringToFront();
|
||||
RequestInputFocus();
|
||||
}
|
||||
TryBringToFront();
|
||||
RequestInputFocus();
|
||||
};
|
||||
|
||||
Closed += (s, e) =>
|
||||
{
|
||||
_isClosed = true;
|
||||
Logger.Info("[QuickSend] Dialog closed");
|
||||
};
|
||||
Closed += (s, e) => Logger.Info("[QuickSend] Dialog closed");
|
||||
|
||||
Logger.Info($"[QuickSend] Dialog opened (prefill={!string.IsNullOrEmpty(prefillMessage)})");
|
||||
}
|
||||
@ -205,9 +152,6 @@ public sealed class QuickSendDialog : WindowEx
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_isClosed)
|
||||
return;
|
||||
|
||||
var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
|
||||
if (hwnd == IntPtr.Zero) return;
|
||||
|
||||
@ -247,77 +191,64 @@ public sealed class QuickSendDialog : WindowEx
|
||||
|
||||
_errorDetailsTextBox.Visibility = Visibility.Collapsed;
|
||||
_errorDetailsTextBox.Text = string.Empty;
|
||||
this.SetWindowSize(420, 260 + TitleBarHeight);
|
||||
this.SetWindowSize(420, 260);
|
||||
|
||||
_isSending = true;
|
||||
_sendButton.IsEnabled = false;
|
||||
_messageTextBox.IsEnabled = false;
|
||||
ShowDetails(LocalizationHelper.GetString("QuickSend_Sending"));
|
||||
|
||||
QuickSendOutcome outcome;
|
||||
try
|
||||
{
|
||||
outcome = await _coordinator.SendAsync(message);
|
||||
if (!await EnsureGatewayConnectedAsync())
|
||||
{
|
||||
throw new InvalidOperationException("Gateway connection is not open");
|
||||
}
|
||||
|
||||
await _client.SendChatMessageAsync(message);
|
||||
Logger.Info($"[QuickSend] Message sent ({message.Length} chars)");
|
||||
new ToastContentBuilder()
|
||||
.AddText(LocalizationHelper.GetString("QuickSend_ToastTitle"))
|
||||
.AddText(LocalizationHelper.GetString("QuickSend_ToastBody"))
|
||||
.Show();
|
||||
Close();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Coordinator catches/classifies all expected failures; this is
|
||||
// a defensive guard against unexpected programmer errors.
|
||||
Logger.Error($"Quick send coordinator threw: {ex.Message}");
|
||||
outcome = new QuickSendOutcome.Failed(ex.Message);
|
||||
}
|
||||
Logger.Error($"Quick send failed: {ex.Message}");
|
||||
if (IsPairingRequired(ex.Message))
|
||||
{
|
||||
var commands = _client.BuildPairingApprovalFixCommands();
|
||||
CopyTextToClipboard(commands);
|
||||
|
||||
switch (outcome)
|
||||
{
|
||||
case QuickSendOutcome.Sent:
|
||||
Logger.Info($"[QuickSend] Message sent ({message.Length} chars)");
|
||||
new ToastContentBuilder()
|
||||
.AddText(LocalizationHelper.GetString("QuickSend_ToastTitle"))
|
||||
.AddText(LocalizationHelper.GetString("QuickSend_ToastBody"))
|
||||
.Show();
|
||||
Close();
|
||||
return;
|
||||
|
||||
case QuickSendOutcome.GatewayInitializing init:
|
||||
// Bug #3: provider returned null (App is mid-swap). Do NOT
|
||||
// copy any pair-command remediation to clipboard — show a
|
||||
// simple "try again" message instead.
|
||||
Logger.Warn($"[QuickSend] {init.Message}");
|
||||
ShowErrorDetails(init.Message);
|
||||
break;
|
||||
|
||||
case QuickSendOutcome.PairingRequired pr:
|
||||
// Genuine NOT_PAIRED on the live current client — clipboard
|
||||
// remediation MUST still fire (Mike explicitly does not want
|
||||
// this case suppressed; RubberDucky closure condition #3).
|
||||
CopyTextToClipboard(pr.Commands);
|
||||
ShowErrorDetails($"Pairing approval required\n\n{pr.Commands}");
|
||||
ShowErrorDetails($"Pairing approval required\n\n{commands}");
|
||||
new ToastContentBuilder()
|
||||
.AddText("Quick Send device approval required")
|
||||
.AddText("Gateway reported pairing required. Approval guidance copied to clipboard.")
|
||||
.Show();
|
||||
Logger.Warn($"[QuickSend] Pairing required. Commands copied to clipboard.\n{pr.Commands}");
|
||||
break;
|
||||
Logger.Warn($"[QuickSend] Pairing required. Commands copied to clipboard.\n{commands}");
|
||||
}
|
||||
else if (TryExtractMissingScope(ex.Message, out var missingScope))
|
||||
{
|
||||
var commands = _client.BuildMissingScopeFixCommands(missingScope);
|
||||
CopyTextToClipboard(commands);
|
||||
|
||||
case QuickSendOutcome.MissingScope ms:
|
||||
CopyTextToClipboard(ms.Commands);
|
||||
ShowErrorDetails($"Missing scope: {ms.Scope}\n\n{ms.Commands}");
|
||||
ShowErrorDetails($"Missing scope: {missingScope}\n\n{commands}");
|
||||
new ToastContentBuilder()
|
||||
.AddText("Quick Send permission required")
|
||||
.AddText($"Missing scope '{ms.Scope}'. Identity + remediation guidance copied to clipboard.")
|
||||
.AddText($"Missing scope '{missingScope}'. Identity + remediation guidance copied to clipboard.")
|
||||
.Show();
|
||||
Logger.Warn($"[QuickSend] Missing scope '{ms.Scope}'. Commands copied to clipboard.\n{ms.Commands}");
|
||||
break;
|
||||
Logger.Warn($"[QuickSend] Missing scope '{missingScope}'. Commands copied to clipboard.\n{commands}");
|
||||
}
|
||||
else
|
||||
{
|
||||
ShowErrorDetails(ex.Message);
|
||||
}
|
||||
|
||||
case QuickSendOutcome.Failed f:
|
||||
Logger.Error($"Quick send failed: {f.ErrorMessage}");
|
||||
ShowErrorDetails(f.ErrorMessage);
|
||||
break;
|
||||
_sendButton.IsEnabled = true;
|
||||
_messageTextBox.IsEnabled = true;
|
||||
_isSending = false;
|
||||
}
|
||||
|
||||
_sendButton.IsEnabled = true;
|
||||
_messageTextBox.IsEnabled = true;
|
||||
_isSending = false;
|
||||
}
|
||||
|
||||
private void ShowErrorDetails(string details)
|
||||
@ -326,7 +257,7 @@ public sealed class QuickSendDialog : WindowEx
|
||||
_errorDetailsTextBox.MinHeight = 140;
|
||||
_errorDetailsTextBox.Text = details;
|
||||
_errorDetailsTextBox.Visibility = Visibility.Visible;
|
||||
this.SetWindowSize(520, 400 + TitleBarHeight);
|
||||
this.SetWindowSize(520, 400);
|
||||
|
||||
// Move focus to the details box so users can immediately select/copy text.
|
||||
_errorDetailsTextBox.Focus(FocusState.Programmatic);
|
||||
@ -338,7 +269,37 @@ public sealed class QuickSendDialog : WindowEx
|
||||
_errorDetailsTextBox.MinHeight = 80;
|
||||
_errorDetailsTextBox.Text = details;
|
||||
_errorDetailsTextBox.Visibility = Visibility.Visible;
|
||||
this.SetWindowSize(500, 320 + TitleBarHeight);
|
||||
this.SetWindowSize(500, 320);
|
||||
}
|
||||
|
||||
private static bool TryExtractMissingScope(string? message, out string scope)
|
||||
{
|
||||
scope = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var match = Regex.Match(message, @"missing\s+scope\s*:\s*([A-Za-z0-9._-]+)", RegexOptions.IgnoreCase);
|
||||
if (!match.Success)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
scope = match.Groups[1].Value;
|
||||
return !string.IsNullOrWhiteSpace(scope);
|
||||
}
|
||||
|
||||
private static bool IsPairingRequired(string? message)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return message.Contains("pairing required", StringComparison.OrdinalIgnoreCase)
|
||||
|| message.Contains("not paired", StringComparison.OrdinalIgnoreCase)
|
||||
|| message.Contains("NOT_PAIRED", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static void CopyTextToClipboard(string text)
|
||||
@ -350,41 +311,54 @@ public sealed class QuickSendDialog : WindowEx
|
||||
|
||||
private void QueueFocusMessageInput()
|
||||
{
|
||||
if (_isClosed)
|
||||
return;
|
||||
|
||||
DispatcherQueue?.TryEnqueue(FocusMessageInput);
|
||||
}
|
||||
|
||||
private void RequestInputFocus()
|
||||
{
|
||||
QueueFocusMessageInput();
|
||||
if (!_focusRetryRunning)
|
||||
{
|
||||
_focusRetryRunning = true;
|
||||
_ = RetryFocusMessageInputAsync();
|
||||
}
|
||||
_ = RetryFocusMessageInputAsync();
|
||||
}
|
||||
|
||||
private async Task RetryFocusMessageInputAsync()
|
||||
{
|
||||
var delaysMs = new[] { 60, 160, 320 };
|
||||
foreach (var delay in delaysMs)
|
||||
{
|
||||
await Task.Delay(delay);
|
||||
TryBringToFront();
|
||||
QueueFocusMessageInput();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> EnsureGatewayConnectedAsync(int timeoutMs = 3000)
|
||||
{
|
||||
if (_client.IsConnectedToGateway)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var delaysMs = new[] { 60, 160, 320 };
|
||||
foreach (var delay in delaysMs)
|
||||
{
|
||||
await Task.Delay(delay);
|
||||
if (_isClosed)
|
||||
return;
|
||||
|
||||
TryBringToFront();
|
||||
QueueFocusMessageInput();
|
||||
}
|
||||
await _client.ConnectAsync();
|
||||
}
|
||||
finally
|
||||
catch
|
||||
{
|
||||
_focusRetryRunning = false;
|
||||
// Connect errors are handled by the send flow.
|
||||
}
|
||||
|
||||
var started = Environment.TickCount64;
|
||||
while (Environment.TickCount64 - started < timeoutMs)
|
||||
{
|
||||
if (_client.IsConnectedToGateway)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
await Task.Delay(120);
|
||||
}
|
||||
|
||||
return _client.IsConnectedToGateway;
|
||||
}
|
||||
|
||||
public void FocusMessageInput()
|
||||
|
||||
@ -1,195 +0,0 @@
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using OpenClaw.Shared.Capabilities;
|
||||
using OpenClawTray.Helpers;
|
||||
using OpenClawTray.Services;
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using WinUIEx;
|
||||
|
||||
namespace OpenClawTray.Dialogs;
|
||||
|
||||
/// <summary>
|
||||
/// Privacy consent dialog shown before the first screen or camera recording.
|
||||
/// Parameterized by recording type so each capability gets its own consent.
|
||||
/// </summary>
|
||||
public sealed class RecordingConsentDialog : WindowEx
|
||||
{
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
|
||||
|
||||
private static readonly IntPtr HWND_TOPMOST = new(-1);
|
||||
private static readonly IntPtr HWND_NOTOPMOST = new(-2);
|
||||
private const uint SWP_NOMOVE = 0x0002;
|
||||
private const uint SWP_NOSIZE = 0x0001;
|
||||
|
||||
private readonly TaskCompletionSource<bool> _tcs = new();
|
||||
private bool _consented;
|
||||
|
||||
public RecordingConsentDialog(RecordingType type)
|
||||
{
|
||||
var isScreen = type == RecordingType.Screen;
|
||||
var headingKey = isScreen ? "RecordingConsent_ScreenTitle" : "RecordingConsent_CameraTitle";
|
||||
var descriptionKey = isScreen ? "RecordingConsent_ScreenDescription" : "RecordingConsent_CameraDescription";
|
||||
var emoji = isScreen ? "🖥️" : "📷";
|
||||
|
||||
Title = LocalizationHelper.GetString("RecordingConsent_WindowTitle");
|
||||
this.SetWindowSize(460, 340);
|
||||
this.CenterOnScreen();
|
||||
this.SetIcon("Assets\\openclaw.ico");
|
||||
|
||||
SystemBackdrop = new MicaBackdrop();
|
||||
ExtendsContentIntoTitleBar = true;
|
||||
|
||||
// Custom title bar
|
||||
var titleBar = new Grid
|
||||
{
|
||||
Height = 48,
|
||||
Padding = new Thickness(16, 0, 140, 0)
|
||||
};
|
||||
titleBar.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||
titleBar.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||
|
||||
var titleIcon = new TextBlock
|
||||
{
|
||||
Text = "🦞",
|
||||
FontSize = 16,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 8, 0)
|
||||
};
|
||||
Grid.SetColumn(titleIcon, 0);
|
||||
titleBar.Children.Add(titleIcon);
|
||||
|
||||
var titleText = new TextBlock
|
||||
{
|
||||
Text = LocalizationHelper.GetString("RecordingConsent_WindowTitle"),
|
||||
FontSize = 13,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"]
|
||||
};
|
||||
Grid.SetColumn(titleText, 1);
|
||||
titleBar.Children.Add(titleText);
|
||||
|
||||
SetTitleBar(titleBar);
|
||||
|
||||
// Main layout
|
||||
var outerGrid = new Grid();
|
||||
outerGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(48) });
|
||||
outerGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
|
||||
|
||||
Grid.SetRow(titleBar, 0);
|
||||
outerGrid.Children.Add(titleBar);
|
||||
|
||||
var root = new Grid
|
||||
{
|
||||
Padding = new Thickness(32, 16, 32, 32),
|
||||
RowSpacing = 16
|
||||
};
|
||||
root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
|
||||
root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
|
||||
root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
|
||||
|
||||
// Header
|
||||
var header = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Spacing = 12
|
||||
};
|
||||
header.Children.Add(new TextBlock { Text = emoji, FontSize = 36 });
|
||||
header.Children.Add(new TextBlock
|
||||
{
|
||||
Text = LocalizationHelper.GetString(headingKey),
|
||||
Style = (Style)Application.Current.Resources["SubtitleTextBlockStyle"],
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
});
|
||||
Grid.SetRow(header, 0);
|
||||
root.Children.Add(header);
|
||||
|
||||
// Content
|
||||
var content = new StackPanel { Spacing = 12 };
|
||||
content.Children.Add(new TextBlock
|
||||
{
|
||||
Text = LocalizationHelper.GetString(descriptionKey),
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
});
|
||||
content.Children.Add(new TextBlock
|
||||
{
|
||||
Text = LocalizationHelper.GetString("RecordingConsent_Privacy"),
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"]
|
||||
});
|
||||
Grid.SetRow(content, 1);
|
||||
root.Children.Add(content);
|
||||
|
||||
// Buttons
|
||||
var buttonPanel = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
Spacing = 8
|
||||
};
|
||||
|
||||
var denyButton = new Button
|
||||
{
|
||||
Content = LocalizationHelper.GetString("RecordingConsent_Deny")
|
||||
};
|
||||
denyButton.Click += (s, e) =>
|
||||
{
|
||||
Logger.Info($"[RecordingConsent] User denied {type} recording consent");
|
||||
_consented = false;
|
||||
Close();
|
||||
};
|
||||
buttonPanel.Children.Add(denyButton);
|
||||
|
||||
var allowButton = new Button
|
||||
{
|
||||
Content = LocalizationHelper.GetString("RecordingConsent_Allow"),
|
||||
Style = (Style)Application.Current.Resources["AccentButtonStyle"]
|
||||
};
|
||||
allowButton.Click += (s, e) =>
|
||||
{
|
||||
Logger.Info($"[RecordingConsent] User allowed {type} recording consent");
|
||||
_consented = true;
|
||||
Close();
|
||||
};
|
||||
buttonPanel.Children.Add(allowButton);
|
||||
|
||||
Grid.SetRow(buttonPanel, 2);
|
||||
root.Children.Add(buttonPanel);
|
||||
|
||||
Grid.SetRow(root, 1);
|
||||
outerGrid.Children.Add(root);
|
||||
|
||||
Content = outerGrid;
|
||||
|
||||
Closed += (s, e) => _tcs.TrySetResult(_consented);
|
||||
|
||||
Logger.Info($"[RecordingConsent] {type} recording consent dialog shown");
|
||||
}
|
||||
|
||||
public new Task<bool> ShowAsync()
|
||||
{
|
||||
Activate();
|
||||
|
||||
// Force to foreground since this may be triggered from a background context
|
||||
try
|
||||
{
|
||||
var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
|
||||
if (hwnd != IntPtr.Zero)
|
||||
{
|
||||
// Briefly set topmost to guarantee visibility, then remove topmost flag
|
||||
SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE);
|
||||
SetWindowPos(hwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE);
|
||||
SetForegroundWindow(hwnd);
|
||||
}
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
|
||||
return _tcs.Task;
|
||||
}
|
||||
}
|
||||
@ -1,133 +0,0 @@
|
||||
using Microsoft.UI;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using WinUIEx;
|
||||
|
||||
namespace OpenClawTray.Dialogs;
|
||||
|
||||
/// <summary>
|
||||
/// Compact chromeless countdown overlay (3-2-1) shown before recording starts.
|
||||
/// Displays as a small floating dark pill with a white countdown number.
|
||||
/// </summary>
|
||||
public sealed class RecordingCountdownWindow : WindowEx
|
||||
{
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern int GetWindowLong(IntPtr hWnd, int nIndex);
|
||||
|
||||
private static readonly IntPtr HWND_TOPMOST = new(-1);
|
||||
private const uint SWP_NOMOVE = 0x0002;
|
||||
private const uint SWP_NOSIZE = 0x0001;
|
||||
private const int GWL_STYLE = -16;
|
||||
private const int GWL_EXSTYLE = -20;
|
||||
private const int WS_POPUP = unchecked((int)0x80000000);
|
||||
private const int WS_VISIBLE = 0x10000000;
|
||||
private const int WS_EX_TOOLWINDOW = 0x00000080;
|
||||
private const int WS_EX_NOACTIVATE = 0x08000000;
|
||||
private const uint SWP_FRAMECHANGED = 0x0020;
|
||||
|
||||
private readonly TaskCompletionSource _tcs = new();
|
||||
private readonly TextBlock _countdownText;
|
||||
private readonly DispatcherQueueTimer _timer;
|
||||
private int _remaining;
|
||||
|
||||
public RecordingCountdownWindow(int seconds = 3)
|
||||
{
|
||||
_remaining = seconds;
|
||||
|
||||
Title = "";
|
||||
this.SetWindowSize(120, 120);
|
||||
this.CenterOnScreen();
|
||||
ExtendsContentIntoTitleBar = true;
|
||||
IsMinimizable = false;
|
||||
IsMaximizable = false;
|
||||
IsResizable = false;
|
||||
|
||||
_countdownText = new TextBlock
|
||||
{
|
||||
Text = _remaining.ToString(),
|
||||
FontSize = 56,
|
||||
FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Foreground = new SolidColorBrush(Colors.White),
|
||||
// Nudge up slightly to compensate for font descender space
|
||||
Padding = new Thickness(0, 0, 0, 6)
|
||||
};
|
||||
|
||||
// Solid dark circle on a fully transparent window
|
||||
var pill = new Border
|
||||
{
|
||||
Background = new SolidColorBrush(global::Windows.UI.Color.FromArgb(230, 30, 30, 30)),
|
||||
CornerRadius = new CornerRadius(60),
|
||||
Width = 100,
|
||||
Height = 100,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Child = _countdownText
|
||||
};
|
||||
|
||||
Content = new Grid
|
||||
{
|
||||
Background = new SolidColorBrush(Colors.Transparent),
|
||||
Children = { pill }
|
||||
};
|
||||
|
||||
_timer = DispatcherQueue.CreateTimer();
|
||||
_timer.Interval = TimeSpan.FromSeconds(1);
|
||||
_timer.Tick += OnTick;
|
||||
}
|
||||
|
||||
private void OnTick(DispatcherQueueTimer sender, object args)
|
||||
{
|
||||
_remaining--;
|
||||
|
||||
if (_remaining <= 0)
|
||||
{
|
||||
_timer.Stop();
|
||||
Close();
|
||||
return;
|
||||
}
|
||||
|
||||
_countdownText.Text = _remaining.ToString();
|
||||
}
|
||||
|
||||
public Task ShowCountdownAsync()
|
||||
{
|
||||
Closed += (s, e) => _tcs.TrySetResult();
|
||||
|
||||
// Transparent window background so only the dark circle is visible
|
||||
SystemBackdrop = new TransparentTintBackdrop();
|
||||
|
||||
Activate();
|
||||
|
||||
// Strip window chrome and make topmost
|
||||
try
|
||||
{
|
||||
var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
|
||||
if (hwnd != IntPtr.Zero)
|
||||
{
|
||||
SetWindowLong(hwnd, GWL_STYLE, WS_POPUP | WS_VISIBLE);
|
||||
var exStyle = GetWindowLong(hwnd, GWL_EXSTYLE);
|
||||
SetWindowLong(hwnd, GWL_EXSTYLE, exStyle | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE);
|
||||
SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0,
|
||||
SWP_NOMOVE | SWP_NOSIZE | SWP_FRAMECHANGED);
|
||||
}
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
|
||||
_timer.Start();
|
||||
|
||||
return _tcs.Task;
|
||||
}
|
||||
}
|
||||
@ -20,7 +20,6 @@ public sealed class WelcomeDialog : WindowEx
|
||||
public WelcomeDialog()
|
||||
{
|
||||
Title = LocalizationHelper.GetString("WindowTitle_Welcome");
|
||||
ExtendsContentIntoTitleBar = true;
|
||||
this.SetWindowSize(480, 440);
|
||||
this.CenterOnScreen();
|
||||
this.SetIcon("Assets\\openclaw.ico");
|
||||
@ -124,37 +123,7 @@ public sealed class WelcomeDialog : WindowEx
|
||||
Grid.SetRow(buttonPanel, 2);
|
||||
root.Children.Add(buttonPanel);
|
||||
|
||||
// Wrap content with custom titlebar
|
||||
var outerGrid = new Grid();
|
||||
outerGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(48) });
|
||||
outerGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
|
||||
|
||||
var titleBar = new Grid { Padding = new Thickness(16, 0, 140, 0) };
|
||||
var titleIcon = new TextBlock
|
||||
{
|
||||
Text = "🦞",
|
||||
FontSize = 20,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 10, 0)
|
||||
};
|
||||
var titleTextBlock = new TextBlock
|
||||
{
|
||||
Text = LocalizationHelper.GetString("WindowTitle_Welcome"),
|
||||
FontSize = 13,
|
||||
Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"],
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
var titleStack = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
titleStack.Children.Add(titleIcon);
|
||||
titleStack.Children.Add(titleTextBlock);
|
||||
titleBar.Children.Add(titleStack);
|
||||
Grid.SetRow(titleBar, 0);
|
||||
outerGrid.Children.Add(titleBar);
|
||||
|
||||
Grid.SetRow(root, 1);
|
||||
outerGrid.Children.Add(root);
|
||||
Content = outerGrid;
|
||||
SetTitleBar(titleBar);
|
||||
Content = root;
|
||||
|
||||
Closed += (s, e) => _tcs.TrySetResult(_result);
|
||||
|
||||
|
||||
@ -1,500 +0,0 @@
|
||||
using OpenClaw.Shared;
|
||||
using OpenClawTray.Services;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace OpenClawTray.Helpers;
|
||||
|
||||
internal static class CommandCenterTextHelper
|
||||
{
|
||||
// Pre-compiled patterns used in RedactSupportPath / RedactSupportValue.
|
||||
// Compiled once at startup; reused on every diagnostic / support-text build.
|
||||
private static readonly Regex PathWindowsUserPattern = new(
|
||||
@"\b[A-Za-z]:\\Users\\[^\\]+",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled,
|
||||
TimeSpan.FromMilliseconds(100));
|
||||
|
||||
private static readonly Regex PathUnixUserPattern = new(
|
||||
@"/Users/[^/]+",
|
||||
RegexOptions.Compiled,
|
||||
TimeSpan.FromMilliseconds(100));
|
||||
|
||||
private static readonly Regex ValueUrlHostPattern = new(
|
||||
@"\b[a-z][a-z0-9+.-]*://(?:[^@\s/]+@)?([^:/\s]+)",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled,
|
||||
TimeSpan.FromMilliseconds(100));
|
||||
|
||||
private static readonly Regex ValueIpPattern = new(
|
||||
@"\b(?:\d{1,3}\.){3}\d{1,3}\b",
|
||||
RegexOptions.Compiled,
|
||||
TimeSpan.FromMilliseconds(100));
|
||||
|
||||
private static readonly Regex ValueEmailPattern = new(
|
||||
@"\b[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}\b",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled,
|
||||
TimeSpan.FromMilliseconds(100));
|
||||
|
||||
private static readonly Regex ValueUserAtHostPattern = new(
|
||||
@"\b(?<user>[A-Za-z0-9._-]+)@(?<host>[A-Za-z0-9._-]+)(?=[:\s]|$)",
|
||||
RegexOptions.Compiled,
|
||||
TimeSpan.FromMilliseconds(100));
|
||||
|
||||
private static readonly Regex ValueHostAfterToPattern = new(
|
||||
@"(?<=\bto\s)[A-Za-z0-9._-]+(?=:\d{1,5}\b)",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled,
|
||||
TimeSpan.FromMilliseconds(100));
|
||||
|
||||
private static readonly Regex ValueLeadingHostPattern = new(
|
||||
@"^\s*[A-Za-z0-9._-]+(?=:\d{1,5}\b)",
|
||||
RegexOptions.Compiled,
|
||||
TimeSpan.FromMilliseconds(100));
|
||||
|
||||
internal static string BuildSupportContext(GatewayCommandCenterState state)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("OpenClaw Windows Tray Support Context");
|
||||
builder.AppendLine($"Generated: {DateTimeOffset.Now:O}");
|
||||
builder.AppendLine($"Connection: {state.ConnectionStatus}");
|
||||
builder.AppendLine($"Topology: {state.Topology.DisplayName}");
|
||||
builder.AppendLine($"Transport: {state.Topology.Transport}");
|
||||
builder.AppendLine($"Gateway URL: {RedactSupportValue(state.Topology.GatewayUrl)}");
|
||||
builder.AppendLine($"Topology detail: {RedactSupportValue(state.Topology.Detail)}");
|
||||
builder.AppendLine($"Gateway runtime: {RedactSupportValue(state.Runtime.DisplayText)}");
|
||||
builder.AppendLine($"Update status: {RedactSupportValue(state.Update.DisplayText)}");
|
||||
if (state.Tunnel != null && state.Tunnel.Status != TunnelStatus.NotConfigured)
|
||||
{
|
||||
builder.AppendLine($"Tunnel: {state.Tunnel.Status}");
|
||||
builder.AppendLine($"Tunnel local endpoint: {RedactSupportValue(state.Tunnel.LocalEndpoint)}");
|
||||
builder.AppendLine($"Tunnel remote endpoint: {RedactSupportValue(state.Tunnel.RemoteEndpoint)}");
|
||||
if (!string.IsNullOrWhiteSpace(state.Tunnel.BrowserProxyLocalEndpoint) ||
|
||||
!string.IsNullOrWhiteSpace(state.Tunnel.BrowserProxyRemoteEndpoint))
|
||||
{
|
||||
builder.AppendLine($"Tunnel browser proxy local endpoint: {RedactSupportValue(state.Tunnel.BrowserProxyLocalEndpoint)}");
|
||||
builder.AppendLine($"Tunnel browser proxy remote endpoint: {RedactSupportValue(state.Tunnel.BrowserProxyRemoteEndpoint)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(state.Tunnel.LastError))
|
||||
builder.AppendLine($"Tunnel last error: {RedactSupportValue(state.Tunnel.LastError)}");
|
||||
}
|
||||
|
||||
builder.AppendLine($"Gateway version: {state.GatewaySelf?.ServerVersion ?? "unknown"}");
|
||||
builder.AppendLine($"Gateway uptime ms: {state.GatewaySelf?.UptimeMs?.ToString() ?? "unknown"}");
|
||||
builder.AppendLine($"Channels: {state.Channels.Count}");
|
||||
builder.AppendLine($"Sessions: {state.Sessions.Count}");
|
||||
builder.AppendLine($"Nodes: {state.Nodes.Count}");
|
||||
builder.AppendLine($"Warnings: {state.Warnings.Count}");
|
||||
foreach (var warning in state.Warnings.Take(10))
|
||||
{
|
||||
builder.AppendLine($"- {warning.Severity}: {warning.Title}");
|
||||
}
|
||||
builder.AppendLine($"Recent activity: {state.RecentActivity.Count}");
|
||||
foreach (var item in state.RecentActivity.Take(10))
|
||||
{
|
||||
builder.AppendLine($"- {item.Timestamp:O} [{item.Category}] {item.Title}");
|
||||
}
|
||||
builder.AppendLine($"Ports: {state.PortDiagnostics.Count}");
|
||||
foreach (var port in state.PortDiagnostics)
|
||||
{
|
||||
builder.AppendLine($"- {port.Purpose}: {port.Port} {port.StatusText} ({RedactSupportValue(port.Detail)})");
|
||||
}
|
||||
builder.AppendLine($"Log file: {RedactSupportPath(Logger.LogFilePath)}");
|
||||
builder.AppendLine($"Diagnostics JSONL: {RedactSupportPath(DiagnosticsJsonlService.FilePath)}");
|
||||
builder.AppendLine($"Settings folder: {RedactSupportPath(SettingsManager.SettingsDirectoryPath)}");
|
||||
builder.AppendLine("Excluded: tokens, bootstrap tokens, command arguments, screenshots, recordings, camera data, microphone data, base64 payloads, and message payloads.");
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
internal static string BuildDebugBundle(GatewayCommandCenterState state)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("OpenClaw Windows Tray Debug Bundle");
|
||||
builder.AppendLine($"Generated: {DateTimeOffset.Now:O}");
|
||||
builder.AppendLine();
|
||||
AppendSection(builder, "Support Context", BuildSupportContext(state));
|
||||
AppendSection(builder, "Port Diagnostics", BuildPortDiagnosticsSummary(state.PortDiagnostics));
|
||||
AppendSection(builder, "Capability Diagnostics", BuildCapabilityDiagnosticsSummary(state));
|
||||
AppendSection(builder, "Node Inventory", BuildNodeInventorySummary(state.Nodes));
|
||||
AppendSection(builder, "Channel Summary", BuildChannelSummaryText(state.Channels));
|
||||
AppendSection(builder, "Activity Summary", BuildActivitySummary(state.RecentActivity));
|
||||
AppendSection(builder, "Extensibility Summary", BuildExtensibilitySummary(state.Channels));
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
internal static string BuildBrowserSetupGuidance(GatewayCommandCenterState state)
|
||||
{
|
||||
var browserProxyPort = state.PortDiagnostics
|
||||
.FirstOrDefault(p => p.Purpose.Equals("Browser proxy host", StringComparison.OrdinalIgnoreCase))
|
||||
?.Port ?? 0;
|
||||
|
||||
return BuildBrowserSetupGuidance(browserProxyPort, state.Topology, state.Tunnel);
|
||||
}
|
||||
|
||||
internal static string BuildBrowserSetupGuidance(
|
||||
int browserProxyPort,
|
||||
GatewayTopologyInfo? topology,
|
||||
TunnelCommandCenterInfo? tunnel)
|
||||
{
|
||||
var portText = browserProxyPort is >= 1 and <= 65535
|
||||
? browserProxyPort.ToString(CultureInfo.InvariantCulture)
|
||||
: "<gateway-port+2>";
|
||||
var gatewayHost = string.IsNullOrWhiteSpace(topology?.Host) ? "<gateway-host>" : topology.Host;
|
||||
var gatewayPort = ResolveGatewayPort(topology?.GatewayUrl);
|
||||
var gatewayPortText = gatewayPort is >= 1 and <= 65535
|
||||
? gatewayPort.Value.ToString(CultureInfo.InvariantCulture)
|
||||
: "<gateway-port>";
|
||||
|
||||
var lines = new List<string>
|
||||
{
|
||||
"OpenClaw browser proxy setup",
|
||||
$"Expected local browser-control endpoint: http://127.0.0.1:{portText}/",
|
||||
"",
|
||||
"If the Gateway and browser are on this Windows machine:",
|
||||
"1. Ensure the upstream browser plugin is enabled in the Gateway config.",
|
||||
"2. Verify the browser control plane:",
|
||||
" openclaw browser --browser-profile openclaw doctor",
|
||||
" openclaw browser --browser-profile openclaw start",
|
||||
" openclaw browser --browser-profile openclaw tabs",
|
||||
"",
|
||||
"If the browser is on this Windows machine but the Gateway is remote:",
|
||||
"1. Run a browser-capable OpenClaw node host on this machine:",
|
||||
$" openclaw node run --host {gatewayHost} --port {gatewayPortText}",
|
||||
"2. Or install it as a user service:",
|
||||
$" openclaw node install --host {gatewayHost} --port {gatewayPortText}",
|
||||
" openclaw node start",
|
||||
"3. Keep nodeHost.browserProxy.enabled=true, and configure nodeHost.browserProxy.allowProfiles only if you want to restrict profile access.",
|
||||
"",
|
||||
"Gateway policy and auth checks:",
|
||||
"- The Gateway allowlist must permit browser.proxy for this node.",
|
||||
"- Browser-control auth must match the saved Gateway token/password in Settings.",
|
||||
"- Do not paste QR bootstrap tokens into the normal Gateway Token field."
|
||||
};
|
||||
|
||||
if (topology?.UsesSshTunnel == true)
|
||||
{
|
||||
lines.Add("");
|
||||
lines.Add("SSH tunnel mode:");
|
||||
lines.Add("- Prefer the tray-managed SSH tunnel with Browser proxy bridge enabled; it forwards local-port+2 to remote-port+2 automatically.");
|
||||
lines.Add($"- Manual forward shape: {BuildBrowserProxySshForwardHint(browserProxyPort, tunnel)}");
|
||||
}
|
||||
|
||||
return string.Join(Environment.NewLine, lines);
|
||||
}
|
||||
|
||||
internal static string BuildChannelSummaryText(IReadOnlyCollection<ChannelCommandCenterInfo> channels)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine($"Channels: {BuildChannelSummary(channels)}");
|
||||
foreach (var channel in channels.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
builder.AppendLine($"- {channel.Name}: {channel.Status ?? "unknown"} ({BuildChannelDetail(channel)})");
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
internal static string BuildExtensibilitySummary(IReadOnlyCollection<ChannelCommandCenterInfo> channels)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("OpenClaw extensibility surfaces");
|
||||
builder.AppendLine("Channels dashboard: channels");
|
||||
builder.AppendLine("Skills dashboard: skills");
|
||||
builder.AppendLine("Cron / schedules dashboard: cron");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("Channel health currently reported to Windows:");
|
||||
foreach (var channel in channels.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
builder.AppendLine($"- {channel.Name}: {channel.Status} ({BuildChannelDetail(channel)})");
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
internal static string BuildCapabilityDiagnosticsSummary(GatewayCommandCenterState state)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("OpenClaw capability diagnostics");
|
||||
builder.AppendLine($"Generated: {DateTimeOffset.Now:O}");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("Windows permission surfaces:");
|
||||
foreach (var permission in state.Permissions.OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
builder.AppendLine($"- {permission.Name}: {permission.Status} - {permission.Detail}");
|
||||
}
|
||||
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("Node command allowlist status:");
|
||||
if (state.Nodes.Count == 0)
|
||||
{
|
||||
builder.AppendLine("- No nodes reported by gateway.");
|
||||
}
|
||||
|
||||
foreach (var node in state.Nodes.OrderBy(n => n.DisplayName, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var displayName = string.IsNullOrWhiteSpace(node.DisplayName) ? node.NodeId : node.DisplayName;
|
||||
builder.AppendLine($"- {displayName} ({node.Platform ?? "unknown"}, {(node.IsOnline ? "online" : "offline")})");
|
||||
builder.AppendLine($" declared commands: {FormatCommandList(node.Commands)}");
|
||||
builder.AppendLine($" safe companion commands: {FormatCommandList(node.SafeDeclaredCommands)}");
|
||||
builder.AppendLine($" privacy-sensitive opt-ins: {FormatCommandList(node.DangerousDeclaredCommands)}");
|
||||
builder.AppendLine($" browser proxy commands: {FormatCommandList(node.BrowserDeclaredCommands)}");
|
||||
builder.AppendLine($" Windows-specific commands: {FormatCommandList(node.WindowsSpecificDeclaredCommands)}");
|
||||
builder.AppendLine($" filtered by gateway policy: {FormatCommandList(node.BlockedDeclaredCommands)}");
|
||||
builder.AppendLine($" disabled in Settings: {FormatCommandList(node.DisabledBySettingsCommands)}");
|
||||
builder.AppendLine($" missing safe allowlist: {FormatCommandList(node.MissingSafeAllowlistCommands)}");
|
||||
builder.AppendLine($" missing privacy-sensitive allowlist: {FormatCommandList(node.MissingDangerousAllowlistCommands)}");
|
||||
builder.AppendLine($" missing browser proxy allowlist: {FormatCommandList(node.MissingBrowserAllowlistCommands)}");
|
||||
builder.AppendLine($" missing Mac parity: {FormatCommandList(node.MissingMacParityCommands)}");
|
||||
}
|
||||
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("Rule: safe companion commands can be allowlisted for parity; privacy-sensitive commands such as camera.snap, camera.clip, and screen.record should stay explicit opt-ins.");
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
internal static string BuildPortDiagnosticsSummary(IReadOnlyCollection<PortDiagnosticInfo> ports)
|
||||
{
|
||||
if (ports.Count == 0)
|
||||
return "No local port diagnostics available for the current topology.";
|
||||
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("OpenClaw port diagnostics");
|
||||
builder.AppendLine($"Generated: {DateTimeOffset.Now:O}");
|
||||
foreach (var port in ports.OrderBy(p => p.Port).ThenBy(p => p.Purpose, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var owner = port.OwningProcessId is > 0
|
||||
? $" · owner {port.OwningProcessName ?? "unknown"} (PID {port.OwningProcessId})"
|
||||
: "";
|
||||
builder.AppendLine($"- {port.Purpose}: {port.Port} {port.StatusText}{owner} - {RedactSupportValue(port.Detail)}");
|
||||
if (port.OwningProcessId is > 0)
|
||||
{
|
||||
builder.AppendLine($" stop hint: Stop-Process -Id {port.OwningProcessId.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
internal static string BuildActivitySummary(IReadOnlyCollection<CommandCenterActivityInfo> activity)
|
||||
{
|
||||
if (activity.Count == 0)
|
||||
return "No recent OpenClaw tray activity.";
|
||||
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("Recent OpenClaw tray activity");
|
||||
foreach (var item in activity)
|
||||
{
|
||||
var details = BuildActivityDetail(item);
|
||||
builder.AppendLine($"{item.Timestamp:O} [{item.Category}] {item.Title} - {details}");
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
internal static string BuildNodeInventorySummary(IReadOnlyCollection<NodeCapabilityHealthInfo> nodes)
|
||||
{
|
||||
if (nodes.Count == 0)
|
||||
return "No nodes reported by gateway.";
|
||||
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("OpenClaw node inventory");
|
||||
builder.AppendLine($"Generated: {DateTimeOffset.Now:O}");
|
||||
builder.AppendLine();
|
||||
foreach (var node in nodes.OrderBy(n => n.DisplayName, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
builder.AppendLine(BuildNodeSummary(node).TrimEnd());
|
||||
builder.AppendLine($"Safe companion commands: {FormatCommandList(node.SafeDeclaredCommands)}");
|
||||
builder.AppendLine($"Privacy-sensitive commands: {FormatCommandList(node.DangerousDeclaredCommands)}");
|
||||
builder.AppendLine($"Browser proxy commands: {FormatCommandList(node.BrowserDeclaredCommands)}");
|
||||
builder.AppendLine($"Windows-specific commands: {FormatCommandList(node.WindowsSpecificDeclaredCommands)}");
|
||||
builder.AppendLine($"Filtered by gateway policy: {FormatCommandList(node.BlockedDeclaredCommands)}");
|
||||
builder.AppendLine($"Missing browser proxy allowlist: {FormatCommandList(node.MissingBrowserAllowlistCommands)}");
|
||||
builder.AppendLine($"Disabled in Settings: {FormatCommandList(node.DisabledBySettingsCommands)}");
|
||||
builder.AppendLine($"Missing Mac parity: {FormatCommandList(node.MissingMacParityCommands)}");
|
||||
builder.AppendLine();
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static void AppendSection(StringBuilder builder, string title, string content)
|
||||
{
|
||||
builder.AppendLine($"## {title}");
|
||||
builder.AppendLine(content.TrimEnd());
|
||||
builder.AppendLine();
|
||||
}
|
||||
|
||||
private static string BuildBrowserProxySshForwardHint(int browserProxyPort, TunnelCommandCenterInfo? tunnel)
|
||||
{
|
||||
if (browserProxyPort is < 1 or > 65535)
|
||||
return "ssh -N -L <local-browser-port>:127.0.0.1:<remote-browser-port> <user>@<host>";
|
||||
|
||||
var target = string.IsNullOrWhiteSpace(tunnel?.User) || string.IsNullOrWhiteSpace(tunnel.Host)
|
||||
? "<user>@<host>"
|
||||
: $"{tunnel.User}@{tunnel.Host}";
|
||||
var remoteBrowserPort = TryParseEndpointPort(tunnel?.BrowserProxyRemoteEndpoint) ?? browserProxyPort;
|
||||
return $"ssh -N -L {browserProxyPort}:127.0.0.1:{remoteBrowserPort} {target}";
|
||||
}
|
||||
|
||||
private static int? TryParseEndpointPort(string? endpoint)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(endpoint))
|
||||
return null;
|
||||
|
||||
if (Uri.TryCreate($"tcp://{endpoint}", UriKind.Absolute, out var uri) &&
|
||||
uri.Port is >= 1 and <= 65535)
|
||||
{
|
||||
return uri.Port;
|
||||
}
|
||||
|
||||
var portDelimiter = endpoint.LastIndexOf(':');
|
||||
return portDelimiter >= 0 &&
|
||||
int.TryParse(endpoint[(portDelimiter + 1)..], NumberStyles.None, CultureInfo.InvariantCulture, out var port) &&
|
||||
port is >= 1 and <= 65535
|
||||
? port
|
||||
: null;
|
||||
}
|
||||
|
||||
private static int? ResolveGatewayPort(string? gatewayUrl)
|
||||
{
|
||||
return Uri.TryCreate(gatewayUrl, UriKind.Absolute, out var uri) && uri.Port is >= 1 and <= 65535
|
||||
? uri.Port
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string RedactSupportPath(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
return "not configured";
|
||||
|
||||
var redacted = path;
|
||||
var knownFolders = new Dictionary<string, string>
|
||||
{
|
||||
[Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)] = "%USERPROFILE%",
|
||||
[Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData)] = "%APPDATA%",
|
||||
[Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)] = "%LOCALAPPDATA%",
|
||||
[Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)] = "%USERPROFILE%\\Documents"
|
||||
};
|
||||
|
||||
foreach (var (folder, replacement) in knownFolders
|
||||
.Where(pair => !string.IsNullOrWhiteSpace(pair.Key))
|
||||
.OrderByDescending(pair => pair.Key.Length))
|
||||
{
|
||||
if (redacted.StartsWith(folder, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
redacted = replacement + redacted[folder.Length..];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
redacted = PathWindowsUserPattern.Replace(redacted, "%USERPROFILE%");
|
||||
|
||||
redacted = PathUnixUserPattern.Replace(redacted, "$HOME");
|
||||
|
||||
return redacted;
|
||||
}
|
||||
|
||||
private static string RedactSupportValue(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return "unknown";
|
||||
|
||||
var redacted = ValueUrlHostPattern.Replace(
|
||||
value,
|
||||
match => match.Value.Replace(match.Groups[1].Value, "<host>"));
|
||||
|
||||
redacted = ValueIpPattern.Replace(redacted, "<ip>");
|
||||
|
||||
redacted = ValueEmailPattern.Replace(redacted, "<email>");
|
||||
|
||||
redacted = ValueUserAtHostPattern.Replace(redacted, "<user>@<host>");
|
||||
|
||||
redacted = ValueHostAfterToPattern.Replace(redacted, "<host>");
|
||||
|
||||
redacted = ValueLeadingHostPattern.Replace(redacted, "<host>");
|
||||
|
||||
return redacted;
|
||||
}
|
||||
|
||||
private static string BuildChannelDetail(ChannelCommandCenterInfo channel)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(channel.Type))
|
||||
parts.Add(channel.Type!);
|
||||
if (channel.IsLinked)
|
||||
parts.Add(string.IsNullOrWhiteSpace(channel.AuthAge) ? "linked" : $"linked · {channel.AuthAge}");
|
||||
if (!string.IsNullOrWhiteSpace(channel.Error))
|
||||
parts.Add(channel.Error!);
|
||||
if (channel.CanStart)
|
||||
parts.Add("start available");
|
||||
if (channel.CanStop)
|
||||
parts.Add("stop available");
|
||||
return parts.Count == 0 ? "no details" : string.Join(" · ", parts);
|
||||
}
|
||||
|
||||
private static string BuildChannelSummary(IReadOnlyCollection<ChannelCommandCenterInfo> channels)
|
||||
{
|
||||
if (channels.Count == 0)
|
||||
return "No channels reported by gateway health.";
|
||||
|
||||
var running = channels.Count(c => c.CanStop);
|
||||
var startable = channels.Count(c => c.CanStart);
|
||||
var errors = channels.Count(c => string.Equals(c.Status, "error", StringComparison.OrdinalIgnoreCase));
|
||||
return $"{running}/{channels.Count} running · {startable} startable · {errors} error";
|
||||
}
|
||||
|
||||
private static string FormatCommandList(IEnumerable<string> commands)
|
||||
{
|
||||
var ordered = commands
|
||||
.Where(command => !string.IsNullOrWhiteSpace(command))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Order(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
return ordered.Count == 0 ? "none" : string.Join(", ", ordered);
|
||||
}
|
||||
|
||||
private static string BuildActivityDetail(CommandCenterActivityInfo activity)
|
||||
{
|
||||
var details = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(activity.Details))
|
||||
details.Add(activity.Details);
|
||||
if (!string.IsNullOrWhiteSpace(activity.SessionKey))
|
||||
details.Add($"session: {activity.SessionKey}");
|
||||
if (!string.IsNullOrWhiteSpace(activity.NodeId))
|
||||
details.Add($"node: {ShortId(activity.NodeId)}");
|
||||
if (!string.IsNullOrWhiteSpace(activity.DashboardPath))
|
||||
details.Add($"dashboard: {activity.DashboardPath}");
|
||||
|
||||
return details.Count == 0 ? activity.Category : string.Join(" · ", details);
|
||||
}
|
||||
|
||||
private static string ShortId(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return "";
|
||||
return value.Length <= 12 ? value : value[..12] + "...";
|
||||
}
|
||||
|
||||
private static string BuildNodeSummary(NodeCapabilityHealthInfo node)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine(string.IsNullOrWhiteSpace(node.DisplayName) ? node.NodeId : node.DisplayName);
|
||||
builder.AppendLine($"Node ID: {node.NodeId}");
|
||||
builder.AppendLine($"Platform: {node.Platform ?? "unknown"}");
|
||||
builder.AppendLine($"Status: {(node.IsOnline ? "online" : "offline")}");
|
||||
builder.AppendLine($"Capabilities: {string.Join(", ", node.Capabilities.OrderBy(c => c, StringComparer.OrdinalIgnoreCase))}");
|
||||
builder.AppendLine($"Commands: {string.Join(", ", node.Commands.OrderBy(c => c, StringComparer.OrdinalIgnoreCase))}");
|
||||
if (node.DisabledBySettingsCommands.Count > 0)
|
||||
builder.AppendLine($"Disabled in Settings: {string.Join(", ", node.DisabledBySettingsCommands)}");
|
||||
if (node.Warnings.Count > 0)
|
||||
{
|
||||
builder.AppendLine("Warnings:");
|
||||
foreach (var warning in node.Warnings)
|
||||
{
|
||||
builder.AppendLine($"- {warning.Title}: {warning.Detail}");
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,21 @@ internal static class VisualTestCapture
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, int> s_captureIndexes = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static void CaptureOnLoaded(FrameworkElement root, string surfaceName)
|
||||
{
|
||||
var rootDir = GetVisualTestDirectory();
|
||||
if (rootDir is null)
|
||||
return;
|
||||
|
||||
var surfaceDir = Path.Combine(rootDir, SanitizePathSegment(surfaceName));
|
||||
root.Loaded += (_, _) =>
|
||||
{
|
||||
_ = CaptureAfterDelayAsync(root, surfaceDir, 300);
|
||||
_ = CaptureAfterDelayAsync(root, surfaceDir, 1500);
|
||||
_ = CaptureAfterDelayAsync(root, surfaceDir, 3500);
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task CaptureAsync(FrameworkElement root, string surfaceName)
|
||||
{
|
||||
var rootDir = GetVisualTestDirectory();
|
||||
@ -23,6 +38,12 @@ internal static class VisualTestCapture
|
||||
await CaptureToDirectoryAsync(root, Path.Combine(rootDir, SanitizePathSegment(surfaceName)));
|
||||
}
|
||||
|
||||
private static async Task CaptureAfterDelayAsync(FrameworkElement root, string surfaceDir, int delayMs)
|
||||
{
|
||||
await Task.Delay(delayMs);
|
||||
await CaptureToDirectoryAsync(root, surfaceDir);
|
||||
}
|
||||
|
||||
private static async Task CaptureToDirectoryAsync(FrameworkElement root, string surfaceDir)
|
||||
{
|
||||
try
|
||||
|
||||
@ -4,7 +4,6 @@ using OpenClawTray.FunctionalUI.Navigation;
|
||||
using OpenClawTray.Onboarding.Services;
|
||||
using OpenClawTray.Onboarding.Pages;
|
||||
using OpenClawTray.Onboarding.Widgets;
|
||||
using OpenClawTray.Services;
|
||||
using static OpenClawTray.FunctionalUI.Factories;
|
||||
using Microsoft.UI.Xaml;
|
||||
|
||||
@ -21,14 +20,14 @@ public sealed class OnboardingApp : Component<OnboardingState>
|
||||
public override Element Render()
|
||||
{
|
||||
// Seed navigation + page index from Props.CurrentRoute (used by visual tests via
|
||||
// OPENCLAW_ONBOARDING_START_ROUTE; defaults to SetupWarning on normal launches).
|
||||
// OPENCLAW_ONBOARDING_START_ROUTE; defaults to Welcome on normal launches).
|
||||
var pagesInit = Props.GetPageOrder();
|
||||
var initialIdx = Math.Max(0, Array.IndexOf(pagesInit, Props.CurrentRoute));
|
||||
var nav = UseNavigation(pagesInit[initialIdx]);
|
||||
var (pageIndex, setPageIndex) = UseState(initialIdx);
|
||||
var pages = Props.GetPageOrder();
|
||||
|
||||
// Clamp pageIndex if page order changed (e.g., node mode toggled, SetupPath changed).
|
||||
// Clamp pageIndex if page order changed (e.g., node mode toggled)
|
||||
if (pageIndex >= pages.Length)
|
||||
{
|
||||
setPageIndex(pages.Length - 1);
|
||||
@ -36,19 +35,12 @@ public sealed class OnboardingApp : Component<OnboardingState>
|
||||
|
||||
void GoNext()
|
||||
{
|
||||
// Re-derive pages on each call so SetupPath changes (Local vs Advanced) take effect.
|
||||
var current = Props.GetPageOrder();
|
||||
if (pageIndex < current.Length - 1)
|
||||
if (pageIndex < pages.Length - 1)
|
||||
{
|
||||
Logger.Info($"[OnboardingApp] Advancing pageIndex {pageIndex}\u2192{pageIndex + 1}, next route={current[pageIndex + 1]}");
|
||||
setPageIndex(pageIndex + 1);
|
||||
nav.Navigate(current[pageIndex + 1]);
|
||||
nav.Navigate(pages[pageIndex + 1]);
|
||||
Props.NotifyPageChanged();
|
||||
Props.NotifyRouteChanged(current[pageIndex + 1]);
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.Info($"[OnboardingApp] AdvanceRequested no-op: at last page (pageIndex={pageIndex}, total={current.Length})");
|
||||
Props.NotifyRouteChanged(pages[pageIndex + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -63,70 +55,7 @@ public sealed class OnboardingApp : Component<OnboardingState>
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to programmatic advance requests (SetupWarningPage buttons,
|
||||
// LocalSetupProgressPage auto-advance after success).
|
||||
UseEffect(() =>
|
||||
{
|
||||
EventHandler handler = (_, _) =>
|
||||
{
|
||||
var current = Props.GetPageOrder();
|
||||
Logger.Info($"[OnboardingApp] AdvanceRequested handler entered; current Props.CurrentRoute={Props.CurrentRoute}, computed pageIndex={pageIndex}, total pages={current.Length}");
|
||||
GoNext();
|
||||
};
|
||||
Props.AdvanceRequested += handler;
|
||||
return () => Props.AdvanceRequested -= handler;
|
||||
}, pageIndex);
|
||||
|
||||
// Re-render when a page pushes a new nav-bar Next button state
|
||||
// (LocalSetupProgressPage uses this to map engine status → button).
|
||||
var (navBarTick, setNavBarTick) = UseState(0);
|
||||
UseEffect(() =>
|
||||
{
|
||||
EventHandler handler = (_, _) => setNavBarTick(navBarTick + 1);
|
||||
Props.NavBarStateChanged += handler;
|
||||
return () => Props.NavBarStateChanged -= handler;
|
||||
}, navBarTick);
|
||||
|
||||
var isLastPage = pageIndex >= pages.Length - 1;
|
||||
var currentRoute = pages[pageIndex];
|
||||
// Compute Next button visibility/disabled per page contract.
|
||||
// - SetupWarning: visible, disabled until SetupPath chosen (legacy).
|
||||
// - LocalSetupProgress: defer to Props.NextButtonState (set by the page in
|
||||
// response to engine state changes; see Phase 5 Next/Back-button policy).
|
||||
// - All other routes: visible, enabled (legacy default).
|
||||
bool nextHidden = false;
|
||||
bool nextDisabled;
|
||||
if (currentRoute == OnboardingRoute.SetupWarning)
|
||||
{
|
||||
nextDisabled = Props.SetupPath == null;
|
||||
}
|
||||
else if (currentRoute == OnboardingRoute.LocalSetupProgress)
|
||||
{
|
||||
switch (Props.NextButtonState)
|
||||
{
|
||||
case OnboardingNextButtonState.Hidden:
|
||||
nextHidden = true;
|
||||
nextDisabled = true;
|
||||
break;
|
||||
case OnboardingNextButtonState.VisibleDisabled:
|
||||
nextDisabled = true;
|
||||
break;
|
||||
case OnboardingNextButtonState.VisibleEnabled:
|
||||
nextDisabled = false;
|
||||
break;
|
||||
case OnboardingNextButtonState.Default:
|
||||
default:
|
||||
// Conservative default before the page has pushed a state:
|
||||
// visible+disabled (treat as Running/Idle equivalent — never
|
||||
// let the user advance past a not-yet-complete local setup).
|
||||
nextDisabled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
nextDisabled = false;
|
||||
}
|
||||
|
||||
// VStack for functional UI content (icon + pages only).
|
||||
// The nav bar is rendered natively in OnboardingWindow for reliable bottom pinning.
|
||||
@ -138,14 +67,13 @@ public sealed class OnboardingApp : Component<OnboardingState>
|
||||
// Page content — fixed height prevents nav bar from jumping between pages
|
||||
(NavigationHost<OnboardingRoute>(nav, route => route switch
|
||||
{
|
||||
OnboardingRoute.SetupWarning => Component<SetupWarningPage, OnboardingState>(Props),
|
||||
OnboardingRoute.LocalSetupProgress => Component<LocalSetupProgressPage, OnboardingState>(Props),
|
||||
OnboardingRoute.Welcome => Component<WelcomePage>(),
|
||||
OnboardingRoute.Connection => Component<ConnectionPage, OnboardingState>(Props),
|
||||
OnboardingRoute.Ready => Component<ReadyPage, OnboardingState>(Props),
|
||||
OnboardingRoute.Wizard => Component<WizardPage, OnboardingState>(Props),
|
||||
OnboardingRoute.Permissions => Component<PermissionsPage, OnboardingState>(Props),
|
||||
OnboardingRoute.Chat => Component<ChatPage, OnboardingState>(Props),
|
||||
_ => TextBlock(Helpers.LocalizationHelper.GetString("Onboarding_UnknownPage")),
|
||||
_ => TextBlock("Unknown page"),
|
||||
}) with { Transition = NavigationTransition.SlideInOnly(
|
||||
direction: SlideDirection.FromRight,
|
||||
duration: TimeSpan.FromMilliseconds(400),
|
||||
@ -166,11 +94,9 @@ public sealed class OnboardingApp : Component<OnboardingState>
|
||||
: Helpers.LocalizationHelper.GetString("Onboarding_Next"),
|
||||
isLastPage ? Props.Complete : GoNext)
|
||||
.Width(100)
|
||||
.Disabled(nextDisabled)
|
||||
.Set(b =>
|
||||
{
|
||||
Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(b, "OnboardingNext");
|
||||
b.Visibility = nextHidden ? Visibility.Collapsed : Visibility.Visible;
|
||||
b.Resources["ButtonBackground"] = new Microsoft.UI.Xaml.Media.SolidColorBrush(
|
||||
Microsoft.UI.ColorHelper.FromArgb(255, 211, 47, 47)); // #D32F2F
|
||||
b.Resources["ButtonBackgroundPointerOver"] = new Microsoft.UI.Xaml.Media.SolidColorBrush(
|
||||
|
||||
@ -32,7 +32,6 @@ public sealed class OnboardingWindow : WindowEx
|
||||
private readonly FunctionalHostControl _host;
|
||||
private readonly string? _visualTestDir;
|
||||
private readonly DispatcherQueue _dispatcherQueue;
|
||||
private readonly string? _identityDataPath;
|
||||
private int _captureIndex;
|
||||
|
||||
// WebView2 overlay for Chat page
|
||||
@ -45,32 +44,17 @@ public sealed class OnboardingWindow : WindowEx
|
||||
private bool _chatWebViewInitialized;
|
||||
private readonly OnboardingState _state;
|
||||
private bool _stateDisposed;
|
||||
// Single-fire guard so the X button (Closed) and the Finish button (state.Complete →
|
||||
// OnOnboardingFinished → Close → Closed) don't both dispatch completion. Both paths
|
||||
// route through OnWizardComplete which no-ops after the first call.
|
||||
private bool _completionDispatched;
|
||||
|
||||
public OnboardingWindow(SettingsManager settings, string? identityDataPath = null)
|
||||
public OnboardingWindow(SettingsManager settings)
|
||||
{
|
||||
_settings = settings;
|
||||
_identityDataPath = identityDataPath;
|
||||
_dispatcherQueue = DispatcherQueue.GetForCurrentThread();
|
||||
_visualTestDir = Environment.GetEnvironmentVariable("OPENCLAW_VISUAL_TEST") == "1"
|
||||
? ValidateTestDir(Environment.GetEnvironmentVariable("OPENCLAW_VISUAL_TEST_DIR")
|
||||
?? Path.Combine(Path.GetTempPath(), "openclaw-visual-test"))
|
||||
: null;
|
||||
|
||||
// Optional override for visual tests: render the onboarding UI in a specific locale
|
||||
// (e.g. "fr-FR", "zh-CN") regardless of system language. Must be set BEFORE the first
|
||||
// LocalizationHelper.GetString call so the resource context picks it up.
|
||||
var testLocale = Environment.GetEnvironmentVariable("OPENCLAW_TEST_LOCALE");
|
||||
if (!string.IsNullOrWhiteSpace(testLocale))
|
||||
{
|
||||
LocalizationHelper.SetLanguageOverride(testLocale);
|
||||
}
|
||||
|
||||
Title = LocalizationHelper.GetString("Onboarding_Title");
|
||||
ExtendsContentIntoTitleBar = true;
|
||||
this.SetWindowSize(720, 900);
|
||||
this.CenterOnScreen();
|
||||
this.SetIcon("Assets\\openclaw.ico");
|
||||
@ -86,36 +70,14 @@ public sealed class OnboardingWindow : WindowEx
|
||||
_state.Finished += OnOnboardingFinished;
|
||||
_state.RouteChanged += OnRouteChanged;
|
||||
|
||||
// Construct the existing-config guard and apply returning-user defaults.
|
||||
// When existing config is detected, default SetupPath to Advanced so the
|
||||
// user lands on the SetupWarning page with Next enabled (→ Connection page)
|
||||
// rather than the local setup path. The warn-and-confirm gate on
|
||||
// SetupWarningPage protects the "Set up locally" button.
|
||||
if (identityDataPath != null)
|
||||
{
|
||||
_state.ExistingConfigGuard = new OnboardingExistingConfigGuard(settings, identityDataPath);
|
||||
if (_state.ExistingConfigGuard.HasExistingConfiguration())
|
||||
_state.SetupPath = SetupPath.Advanced;
|
||||
}
|
||||
|
||||
// Optional override for visual tests / engineering: jump straight to a route.
|
||||
// Accepts the OnboardingRoute enum name (e.g., "Connection").
|
||||
var startRoute = Environment.GetEnvironmentVariable("OPENCLAW_ONBOARDING_START_ROUTE");
|
||||
if (!string.IsNullOrWhiteSpace(startRoute) &&
|
||||
Enum.TryParse<OnboardingRoute>(startRoute, ignoreCase: true, out var parsed))
|
||||
{
|
||||
// Ensure SetupPath is consistent with the requested route so GetPageOrder
|
||||
// produces the expected step indicator. Defaults can be overridden below.
|
||||
if (parsed == OnboardingRoute.LocalSetupProgress) _state.SetupPath = SetupPath.Local;
|
||||
else if (parsed == OnboardingRoute.Connection) _state.SetupPath = SetupPath.Advanced;
|
||||
_state.CurrentRoute = parsed;
|
||||
}
|
||||
var startSetupPath = Environment.GetEnvironmentVariable("OPENCLAW_ONBOARDING_START_SETUP_PATH");
|
||||
if (!string.IsNullOrWhiteSpace(startSetupPath) &&
|
||||
Enum.TryParse<SetupPath>(startSetupPath, ignoreCase: true, out var parsedPath))
|
||||
{
|
||||
_state.SetupPath = parsedPath;
|
||||
}
|
||||
// Optional override for visual tests: pre-select a connection mode (Local/Wsl/Remote/Ssh/Later).
|
||||
var startMode = Environment.GetEnvironmentVariable("OPENCLAW_ONBOARDING_START_MODE");
|
||||
if (!string.IsNullOrWhiteSpace(startMode) &&
|
||||
@ -137,50 +99,19 @@ public sealed class OnboardingWindow : WindowEx
|
||||
_chatOverlay.Visibility = Visibility.Collapsed;
|
||||
_chatOverlay.VerticalAlignment = VerticalAlignment.Top;
|
||||
|
||||
// Root grid: titlebar row + content area
|
||||
// Root grid: functional UI host fills everything, overlay sits on top (except nav bar)
|
||||
_rootGrid = new Grid
|
||||
{
|
||||
Background = GetThemeBrush("SolidBackgroundFillColorBaseBrush")
|
||||
};
|
||||
_rootGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(48) });
|
||||
_rootGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
|
||||
|
||||
// Custom title bar — matches HubWindow treatment
|
||||
var titleBar = new Grid { Padding = new Thickness(16, 0, 140, 0) };
|
||||
var titleIcon = new TextBlock
|
||||
{
|
||||
Text = "🦞",
|
||||
FontSize = 20,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 10, 0)
|
||||
};
|
||||
var titleText = new TextBlock
|
||||
{
|
||||
Text = LocalizationHelper.GetString("Onboarding_Title"),
|
||||
FontSize = 13,
|
||||
Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"],
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
var titleStack = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
titleStack.Children.Add(titleIcon);
|
||||
titleStack.Children.Add(titleText);
|
||||
titleBar.Children.Add(titleStack);
|
||||
Grid.SetRow(titleBar, 0);
|
||||
_rootGrid.Children.Add(titleBar);
|
||||
SetTitleBar(titleBar);
|
||||
|
||||
// Content area
|
||||
var contentGrid = new Grid();
|
||||
contentGrid.Children.Add(_host);
|
||||
contentGrid.Children.Add(_chatOverlay);
|
||||
Grid.SetRow(contentGrid, 1);
|
||||
_rootGrid.Children.Add(contentGrid);
|
||||
_rootGrid.Children.Add(_host);
|
||||
_rootGrid.Children.Add(_chatOverlay);
|
||||
Content = _rootGrid;
|
||||
Closed += OnClosed;
|
||||
|
||||
// Size the overlay after layout — leave space for the nav bar (~84px)
|
||||
// contentGrid is already in row 1 (below titlebar), so no need to subtract titlebar height
|
||||
contentGrid.SizeChanged += (_, args) =>
|
||||
// Size the overlay after layout — leave space for the nav bar
|
||||
// Nav bar is ~60px + VStack bottom padding 20px = 80px minimum
|
||||
_rootGrid.SizeChanged += (_, args) =>
|
||||
{
|
||||
_chatOverlay.Height = Math.Max(0, args.NewSize.Height - 84);
|
||||
};
|
||||
@ -494,7 +425,7 @@ public sealed class OnboardingWindow : WindowEx
|
||||
{
|
||||
_chatRetryButton = new Button
|
||||
{
|
||||
Content = LocalizationHelper.GetString("Onboarding_Retry"),
|
||||
Content = "Retry",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 40, 0, 0)
|
||||
@ -523,19 +454,108 @@ public sealed class OnboardingWindow : WindowEx
|
||||
|
||||
/// <summary>
|
||||
/// Auto-sends the bootstrap kickoff message after the web chat loads.
|
||||
/// Delegates to <see cref="BootstrapMessageInjector"/> so the same gated
|
||||
/// kickoff fires from both the (legacy) onboarding chat overlay and from
|
||||
/// post-wizard HubWindow chat navigation — guarded by
|
||||
/// <see cref="SettingsManager.HasInjectedFirstRunBootstrap"/>.
|
||||
/// Waits for the WebSocket to connect, then injects the message via JS.
|
||||
/// Matches macOS's maybeKickoffOnboardingChat behavior.
|
||||
/// </summary>
|
||||
private async Task SendBootstrapMessageAsync()
|
||||
{
|
||||
if (_bootstrapSent || _chatWebView?.CoreWebView2 == null) return;
|
||||
_bootstrapSent = true;
|
||||
|
||||
await BootstrapMessageInjector.InjectAsync(
|
||||
script => _chatWebView.CoreWebView2.ExecuteScriptAsync(script).AsTask(),
|
||||
_settings);
|
||||
const string bootstrapMessage =
|
||||
"Hi! I just installed OpenClaw and you're my brand-new agent. " +
|
||||
"Please start the first-run ritual from BOOTSTRAP.md, ask one question at a time, " +
|
||||
"and before we talk about WhatsApp/Telegram, visit soul.md with me to craft SOUL.md: " +
|
||||
"ask what matters to me and how you should be. Then guide me through choosing " +
|
||||
"how we should talk (web-only, WhatsApp, or Telegram).";
|
||||
|
||||
try
|
||||
{
|
||||
// Wait for the web UI to initialize its WebSocket connection
|
||||
await Task.Delay(3000);
|
||||
|
||||
// Inject JS that finds the chat input and sends the bootstrap message.
|
||||
// The Lit-based UI uses shadow DOM, so we traverse through custom elements.
|
||||
// SECURITY: Use JsonSerializer to safely encode the message as a JS string literal,
|
||||
// preventing XSS via template expression injection (${...}), quotes, or backslashes.
|
||||
var safeMsg = System.Text.Json.JsonSerializer.Serialize(bootstrapMessage);
|
||||
var js = $$"""
|
||||
(function() {
|
||||
const msg = {{safeMsg}};
|
||||
|
||||
// Strategy 1: Find textarea/input in the page (may be in shadow DOM)
|
||||
function findInput(root) {
|
||||
const inputs = root.querySelectorAll('textarea, input[type="text"]');
|
||||
for (const input of inputs) {
|
||||
if (input.offsetParent !== null || input.offsetHeight > 0) return input;
|
||||
}
|
||||
// Search shadow DOMs
|
||||
const elements = root.querySelectorAll('*');
|
||||
for (const el of elements) {
|
||||
if (el.shadowRoot) {
|
||||
const found = findInput(el.shadowRoot);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findButton(root) {
|
||||
// Look for send buttons
|
||||
const buttons = root.querySelectorAll('button');
|
||||
for (const btn of buttons) {
|
||||
const text = (btn.textContent || '').toLowerCase();
|
||||
const label = (btn.getAttribute('aria-label') || '').toLowerCase();
|
||||
if (text.includes('send') || label.includes('send') ||
|
||||
btn.querySelector('svg') && btn.closest('form')) {
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
const elements = root.querySelectorAll('*');
|
||||
for (const el of elements) {
|
||||
if (el.shadowRoot) {
|
||||
const found = findButton(el.shadowRoot);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const input = findInput(document);
|
||||
if (input) {
|
||||
// Set value and dispatch events to trigger Lit's data binding
|
||||
input.value = msg;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
// Try to find and click the send button
|
||||
setTimeout(() => {
|
||||
const btn = findButton(document);
|
||||
if (btn) {
|
||||
btn.click();
|
||||
console.log('[OpenClaw] Bootstrap message sent via button click');
|
||||
} else {
|
||||
// Try Enter key as fallback
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true
|
||||
}));
|
||||
console.log('[OpenClaw] Bootstrap message sent via Enter key');
|
||||
}
|
||||
}, 200);
|
||||
} else {
|
||||
console.warn('[OpenClaw] Could not find chat input for bootstrap');
|
||||
}
|
||||
})();
|
||||
""";
|
||||
|
||||
await _chatWebView.CoreWebView2.ExecuteScriptAsync(js);
|
||||
Logger.Info("[OnboardingChat] Bootstrap message injection executed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"[OnboardingChat] Bootstrap injection failed: {ex.Message}");
|
||||
// Not fatal — user can type manually
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -584,17 +604,15 @@ public sealed class OnboardingWindow : WindowEx
|
||||
|
||||
private void OnOnboardingFinished(object? sender, EventArgs e)
|
||||
{
|
||||
OnWizardComplete();
|
||||
_settings.Save();
|
||||
Completed = true;
|
||||
_state.GatewayClient = null;
|
||||
OnboardingCompleted?.Invoke(this, EventArgs.Empty);
|
||||
Close();
|
||||
}
|
||||
|
||||
private void OnClosed(object sender, WindowEventArgs args)
|
||||
{
|
||||
// X button path: also runs OnWizardComplete (idempotent via _completionDispatched)
|
||||
// so a user who clicks the title-bar X on the Ready page still gets the chat-window
|
||||
// launch when a model has been configured, matching the Finish-button behavior.
|
||||
OnWizardComplete();
|
||||
|
||||
if (_stateDisposed) return;
|
||||
_stateDisposed = true;
|
||||
_state.Finished -= OnOnboardingFinished;
|
||||
@ -606,82 +624,6 @@ public sealed class OnboardingWindow : WindowEx
|
||||
_state.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unified completion handler invoked from both the Finish button (via
|
||||
/// <see cref="OnOnboardingFinished"/>) and the title-bar X button (via
|
||||
/// <see cref="OnClosed"/>). Idempotent — guarded by <see cref="_completionDispatched"/>.
|
||||
///
|
||||
/// If the user is closing from the Ready page and setup no longer requires
|
||||
/// credentials, launches the main tray hub window on the chat tab.
|
||||
/// This intentionally does not depend on WizardLifecycleState == "complete": the
|
||||
/// gateway wizard can stop on a later channel step even after credentials/model
|
||||
/// setup succeeded, but Finish on Ready still runs this handler.
|
||||
/// </summary>
|
||||
private void OnWizardComplete()
|
||||
{
|
||||
if (_completionDispatched) return;
|
||||
_completionDispatched = true;
|
||||
|
||||
var finishedFromReady = _state.CurrentRoute == OnboardingRoute.Ready;
|
||||
|
||||
_settings.Save();
|
||||
Completed = true;
|
||||
_state.GatewayClient = null;
|
||||
|
||||
// Materialize the persisted AutoStart preference into the OS-level Run-key.
|
||||
// ReadyPage applies the toggle on each change, but a user who never touches
|
||||
// it should still get the default (true) registered. Idempotent.
|
||||
try
|
||||
{
|
||||
AutoStartManager.SetAutoStart(_settings.AutoStart);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"[Onboarding] Failed to apply AutoStart={_settings.AutoStart}: {ex.Message}");
|
||||
}
|
||||
|
||||
OnboardingCompleted?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
var dataPath = _identityDataPath ?? SettingsManager.SettingsDirectoryPath;
|
||||
var setupStillRequired = StartupSetupState.RequiresSetup(_settings, dataPath);
|
||||
if (finishedFromReady && !setupStillRequired)
|
||||
{
|
||||
Logger.Info("[OnboardingWindow] OnWizardComplete launching HubWindow on chat tab");
|
||||
ShowHubChatAfterWizardClose();
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.Info($"[OnboardingWindow] OnWizardComplete skipping chat launch; route={_state.CurrentRoute}, setupStillRequired={setupStillRequired}");
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowHubChatAfterWizardClose()
|
||||
{
|
||||
void ShowHubChat()
|
||||
{
|
||||
try
|
||||
{
|
||||
var app = Microsoft.UI.Xaml.Application.Current as App;
|
||||
if (app == null)
|
||||
{
|
||||
Logger.Warn("[OnboardingWindow] ShowHub chat after Finish failed: App unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
app.ShowHub("chat");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"[OnboardingWindow] ShowHub chat after Finish failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (!_dispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, ShowHubChat))
|
||||
{
|
||||
ShowHubChat();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SECURITY: Validate visual test directory path to prevent directory traversal.
|
||||
/// Returns null if the path is suspicious.
|
||||
|
||||
@ -66,6 +66,34 @@ public sealed class ConnectionPage : Component<OnboardingState>
|
||||
return DefaultLocalUrl;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Probes common local gateway ports and returns the first reachable URL.
|
||||
/// Checks the default port (18789) first, then the dev port (19001).
|
||||
/// Uses a very short timeout for responsiveness.
|
||||
/// </summary>
|
||||
private static async Task<string> DetectLocalGatewayUrlAsync()
|
||||
{
|
||||
foreach (var candidate in new[] { DefaultLocalUrl, DevLocalUrl })
|
||||
{
|
||||
try
|
||||
{
|
||||
var uri = new Uri(candidate.Replace("ws://", "http://"));
|
||||
using var client = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromMilliseconds(800) };
|
||||
var response = await client.GetAsync($"{uri.GetLeftPart(UriPartial.Authority)}/health");
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
Logger.Info($"[Connection] Detected local gateway at {candidate}");
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Port not reachable, try next
|
||||
}
|
||||
}
|
||||
return DefaultLocalUrl; // Fallback to default
|
||||
}
|
||||
|
||||
private static string GetVisualTestPairingDeviceId() =>
|
||||
Environment.GetEnvironmentVariable("OPENCLAW_VISUAL_TEST_PAIRING") == "1"
|
||||
? VisualTestPairingDeviceId
|
||||
@ -136,14 +164,14 @@ public sealed class ConnectionPage : Component<OnboardingState>
|
||||
|
||||
void OnSetupCodeChanged(string code)
|
||||
{
|
||||
setSetupCode(code);
|
||||
if (string.IsNullOrWhiteSpace(code)) return;
|
||||
|
||||
var result = SetupCodeDecoder.Decode(code);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
// Not a valid setup code — user might be still typing.
|
||||
// Don't call setSetupCode here to avoid re-render that steals focus.
|
||||
// Not a valid setup code — user might be still typing
|
||||
if (code.Length > 2048)
|
||||
Logger.Warn("[Connection] Setup code rejected: exceeds 2048 character limit");
|
||||
else
|
||||
@ -151,8 +179,6 @@ public sealed class ConnectionPage : Component<OnboardingState>
|
||||
return;
|
||||
}
|
||||
|
||||
// Valid setup code decoded — now update state (will re-render)
|
||||
setSetupCode(code);
|
||||
if (result.Url != null)
|
||||
{
|
||||
setUrl(result.Url);
|
||||
@ -161,8 +187,7 @@ public sealed class ConnectionPage : Component<OnboardingState>
|
||||
if (result.Token != null)
|
||||
{
|
||||
setToken(result.Token);
|
||||
// Bootstrap token goes to BootstrapToken only — it's single-use for pairing.
|
||||
// Don't save as Settings.Token (causes reconnect storms on restart).
|
||||
Props.Settings.Token = result.Token;
|
||||
Props.Settings.BootstrapToken = result.Token;
|
||||
}
|
||||
setStatusMsg($"✅ {LocalizationHelper.GetString("Onboarding_Connection_StatusDecoded")}");
|
||||
@ -208,13 +233,7 @@ public sealed class ConnectionPage : Component<OnboardingState>
|
||||
async void TestConnection()
|
||||
{
|
||||
Props.Settings.GatewayUrl = url;
|
||||
// Only save to Settings.Token if the user entered a manual token,
|
||||
// not a decoded bootstrap token (which belongs in BootstrapToken only).
|
||||
if (string.IsNullOrWhiteSpace(Props.Settings.BootstrapToken) ||
|
||||
!string.Equals(token, Props.Settings.BootstrapToken, StringComparison.Ordinal))
|
||||
{
|
||||
Props.Settings.Token = token;
|
||||
}
|
||||
Props.Settings.Token = token;
|
||||
|
||||
// When SSH mode, start the managed tunnel before health-checking the local URL.
|
||||
if (mode == ConnectionMode.Ssh)
|
||||
@ -482,14 +501,40 @@ public sealed class ConnectionPage : Component<OnboardingState>
|
||||
catch { /* clipboard unavailable — ignore */ }
|
||||
}
|
||||
|
||||
// Setup code row: TextField + Paste + QR buttons
|
||||
// Setup code row: TextField + Paste + QR buttons (Grid keeps the field expanding)
|
||||
cardChildren.Add(
|
||||
Grid(["1*", "Auto", "Auto"], ["Auto"],
|
||||
TextField(setupCode, OnSetupCodeChanged,
|
||||
placeholder: LocalizationHelper.GetString("Onboarding_Connection_SetupCodePlaceholder"),
|
||||
header: LocalizationHelper.GetString("Onboarding_Connection_SetupCode"))
|
||||
.Grid(row: 0, column: 0)
|
||||
.Set(tb => Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(tb, "OnboardingSetupCode")),
|
||||
.OnGotFocus((sender, _) =>
|
||||
{
|
||||
if (sender is Microsoft.UI.Xaml.Controls.TextBox tb && string.IsNullOrEmpty(tb.Text))
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = global::Windows.ApplicationModel.DataTransfer.Clipboard.GetContent();
|
||||
if (content.Contains(global::Windows.ApplicationModel.DataTransfer.StandardDataFormats.Text))
|
||||
{
|
||||
var task = content.GetTextAsync();
|
||||
task.Completed = (op, status) =>
|
||||
{
|
||||
if (status == global::Windows.Foundation.AsyncStatus.Completed)
|
||||
{
|
||||
var text = op.GetResults();
|
||||
tb.DispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
tb.Text = text;
|
||||
OnSetupCodeChanged(text);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
})
|
||||
.Grid(row: 0, column: 0),
|
||||
Button(LocalizationHelper.GetString("Onboarding_Connection_PasteSetup"), PasteSetupCode)
|
||||
.VAlign(VerticalAlignment.Bottom)
|
||||
.Margin(6, 0, 0, 0)
|
||||
@ -754,4 +799,30 @@ public sealed class ConnectionPage : Component<OnboardingState>
|
||||
.Padding(0, 12, 0, 12)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight logger that captures the first and last error/warning for UI display.
|
||||
/// Preserves the first error so reconnect noise doesn't overwrite the real cause.
|
||||
/// </summary>
|
||||
private sealed class ConnectionTestLogger : IOpenClawLogger
|
||||
{
|
||||
/// <summary>The first error captured — preserves the original cause.</summary>
|
||||
public string? FirstError { get; private set; }
|
||||
public string? LastError { get; private set; }
|
||||
public string? LastWarn { get; private set; }
|
||||
|
||||
public void Info(string message) { }
|
||||
public void Debug(string message) { }
|
||||
public void Warn(string message)
|
||||
{
|
||||
LastWarn = message;
|
||||
FirstError ??= message;
|
||||
LastError ??= message;
|
||||
}
|
||||
public void Error(string message, Exception? ex = null)
|
||||
{
|
||||
FirstError ??= message;
|
||||
LastError = message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,417 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.UI.Xaml;
|
||||
using OpenClawTray.FunctionalUI;
|
||||
using OpenClawTray.FunctionalUI.Core;
|
||||
using OpenClawTray.Helpers;
|
||||
using OpenClawTray.Onboarding.Services;
|
||||
using OpenClawTray.Services;
|
||||
using OpenClawTray.Services.LocalGatewaySetup;
|
||||
using static OpenClawTray.FunctionalUI.Factories;
|
||||
|
||||
namespace OpenClawTray.Onboarding.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// Page 1 of the Local fork (Phase 5).
|
||||
///
|
||||
/// Drives <see cref="LocalGatewaySetupEngine"/> via <see cref="App.CreateLocalGatewaySetupEngine"/>,
|
||||
/// surfaces a small whitelist of user-meaningful stages, and auto-advances after a
|
||||
/// 1-second pause once <see cref="LocalGatewaySetupStatus.Complete"/> is reached.
|
||||
/// On <see cref="LocalGatewaySetupStatus.FailedRetryable"/> a Try again button restarts
|
||||
/// the engine; on <see cref="LocalGatewaySetupStatus.FailedTerminal"/> we surface the
|
||||
/// message with an aka.ms/wsllogs hint and leave the user to back out.
|
||||
///
|
||||
/// Layout contract (Mattingly Phase 5):
|
||||
///
|
||||
/// Grid
|
||||
/// Rows: Auto (title), Auto (subtitle), 1* (scrollable stages), Auto (error/retry)
|
||||
/// Columns: 1*
|
||||
/// Row 0: TextBlock — 22pt bold, centered
|
||||
/// Row 1: TextBlock — 13pt, 0.65 opacity, wrapping, centered
|
||||
/// Row 2: ScrollView wrapping VStack of per-stage Grid rows
|
||||
/// Per stage: Grid columns Auto / 1* / Auto = icon | label | spinner-or-checkmark
|
||||
/// States: Pending (0.4 opacity) / Active (spinner) / Complete (✅) / Failed (❌, red)
|
||||
/// Row 3: Error/retry Grid (collapsed unless Failed*) — error TextBlock | Try again Button
|
||||
///
|
||||
/// Hidden phases that emit subtitle only (per Mike's decision): ElevationCheck,
|
||||
/// PairOperator, CheckWindowsNodeReadiness, PairWindowsTrayNode, VerifyEndToEnd.
|
||||
/// </summary>
|
||||
public sealed class LocalSetupProgressPage : Component<OnboardingState>
|
||||
{
|
||||
// Engine lives across page navigations so back/forward doesn't cancel an in-flight setup.
|
||||
private static LocalGatewaySetupEngine? s_engine;
|
||||
private static Task<LocalGatewaySetupState>? s_runTask;
|
||||
private static bool s_advanceFiredForCompletion;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable snapshot captured per <see cref="LocalGatewaySetupEngine.StateChanged"/>
|
||||
/// invocation. Records have value-equality, so storing a fresh snapshot in
|
||||
/// <c>UseState</c> on every event reliably triggers a re-render — unlike the
|
||||
/// previous code which stored the live <see cref="LocalGatewaySetupState"/>
|
||||
/// reference (the engine mutates the same instance in place; reference-equal
|
||||
/// previous/next values caused <c>UseState</c> to swallow every update past
|
||||
/// the first, leaving the page stuck on stage 1 forever — Bug 2 / e2e drive).
|
||||
/// </summary>
|
||||
private sealed record RenderSnapshot(
|
||||
LocalGatewaySetupPhase Phase,
|
||||
LocalGatewaySetupStatus Status,
|
||||
LocalGatewaySetupPhase LastRunningPhase,
|
||||
string? UserMessage,
|
||||
string? FailureCode);
|
||||
|
||||
private static RenderSnapshot Capture(LocalGatewaySetupState st)
|
||||
{
|
||||
var lastRunning = LocalGatewaySetupPhase.NotStarted;
|
||||
for (int i = st.History.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var rec = st.History[i];
|
||||
if (rec.Phase != LocalGatewaySetupPhase.Failed
|
||||
&& rec.Phase != LocalGatewaySetupPhase.Cancelled
|
||||
&& rec.Phase != LocalGatewaySetupPhase.NotStarted)
|
||||
{
|
||||
lastRunning = rec.Phase;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// While running, the last-running phase IS the current phase.
|
||||
if (st.Status == LocalGatewaySetupStatus.Running
|
||||
&& st.Phase != LocalGatewaySetupPhase.Failed
|
||||
&& st.Phase != LocalGatewaySetupPhase.Cancelled
|
||||
&& st.Phase != LocalGatewaySetupPhase.NotStarted)
|
||||
{
|
||||
lastRunning = st.Phase;
|
||||
}
|
||||
return new RenderSnapshot(st.Phase, st.Status, lastRunning, st.UserMessage, st.FailureCode);
|
||||
}
|
||||
|
||||
public override Element Render()
|
||||
{
|
||||
var (snapshot, setSnapshot) = UseState<RenderSnapshot?>(null);
|
||||
var (retryCount, setRetryCount) = UseState(0);
|
||||
var dispatcher = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread();
|
||||
var advanceRef = Props; // capture for closure
|
||||
|
||||
// Visual-test override: render a synthetic state so screenshot capture doesn't
|
||||
// kick off a real WSL install on the test machine.
|
||||
var visualState = TryReadVisualTestState();
|
||||
|
||||
UseEffect(() =>
|
||||
{
|
||||
if (visualState != null)
|
||||
{
|
||||
setSnapshot(Capture(visualState));
|
||||
return () => { };
|
||||
}
|
||||
|
||||
// Defense-in-depth: block local setup if existing config detected and
|
||||
// replacement was not explicitly confirmed via the SetupWarningPage
|
||||
// warn-and-confirm flow. Primary gate is SetupWarningPage; this catches
|
||||
// env-override (OPENCLAW_ONBOARDING_START_ROUTE=LocalSetupProgress) and
|
||||
// any future callers that bypass SetupWarningPage.
|
||||
if (!Props.ReplaceExistingConfigurationConfirmed
|
||||
&& Props.ExistingConfigGuard?.HasExistingConfiguration() == true)
|
||||
{
|
||||
var failState = LocalGatewaySetupState.Create(new LocalGatewaySetupOptions());
|
||||
failState.Block(
|
||||
"existing_config_gate",
|
||||
"Existing configuration detected. Use Advanced Setup to reconnect, or confirm replacement on the previous page.",
|
||||
retryable: false,
|
||||
detail: null);
|
||||
setSnapshot(Capture(failState));
|
||||
return () => { };
|
||||
}
|
||||
|
||||
if (s_engine == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var app = (App)Application.Current;
|
||||
s_engine = app.CreateLocalGatewaySetupEngine(Props.ReplaceExistingConfigurationConfirmed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var failState = LocalGatewaySetupState.Create(new LocalGatewaySetupOptions());
|
||||
failState.Block("engine_construct_failed", ex.Message, retryable: false, detail: ex.ToString());
|
||||
setSnapshot(Capture(failState));
|
||||
return () => { };
|
||||
}
|
||||
}
|
||||
|
||||
void Handler(LocalGatewaySetupState st)
|
||||
{
|
||||
// Capture an immutable RenderSnapshot OFF the dispatcher so the
|
||||
// values reflect the engine's state at the moment of the event,
|
||||
// not whatever the engine has further mutated to by the time the
|
||||
// dispatcher dequeues us.
|
||||
var snap = Capture(st);
|
||||
dispatcher?.TryEnqueue(() =>
|
||||
{
|
||||
setSnapshot(snap);
|
||||
|
||||
if (snap.Status == LocalGatewaySetupStatus.Complete && !s_advanceFiredForCompletion)
|
||||
{
|
||||
s_advanceFiredForCompletion = true;
|
||||
// Bug #1 (manual test 2026-05-05) sister fix: the next route in the
|
||||
// Local easy-setup flow is Wizard, which calls wizard.start RPC over
|
||||
// App.GatewayClient ?? Props.GatewayClient. App startup only initializes
|
||||
// the operator GatewayClient when EnableNodeMode==false (App.xaml.cs:385);
|
||||
// PairAsync flips it to true mid-onboarding, so without an explicit
|
||||
// re-init here the WizardPage will sit in "loading" for 30s then save
|
||||
// an "offline" state. Eagerly (re)initialize the gateway client now —
|
||||
// operator credentials saved by Phase 12 (_settings.Token) drive auth.
|
||||
try
|
||||
{
|
||||
var appForSeed = (App)Application.Current;
|
||||
if (appForSeed.GatewayClient == null || !appForSeed.GatewayClient.IsConnectedToGateway)
|
||||
appForSeed.ReinitializeGatewayClient();
|
||||
advanceRef.GatewayClient = appForSeed.GatewayClient;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"[LocalSetupProgress] Seeding GatewayClient before advance failed: {ex.Message}");
|
||||
}
|
||||
|
||||
// 1-second pause on success per Mike's decision. Tap-to-skip:
|
||||
// user can tap the (now visible+enabled) Next button to advance
|
||||
// immediately; gate this timer on still being on LocalSetupProgress
|
||||
// so an early tap doesn't over-advance a later page.
|
||||
const int delayMs = 1000;
|
||||
Logger.Info($"[LocalSetupProgress] Status=Complete observed; scheduling RequestAdvance after {delayMs}ms");
|
||||
Task.Delay(TimeSpan.FromMilliseconds(delayMs)).ContinueWith(_ =>
|
||||
{
|
||||
Logger.Info("[LocalSetupProgress] Delay elapsed; dispatching RequestAdvance");
|
||||
var enqueued = dispatcher.TryEnqueue(() =>
|
||||
{
|
||||
Logger.Info("[LocalSetupProgress] Dispatched lambda entered; checking guard");
|
||||
if (advanceRef.CurrentRoute == OnboardingRoute.LocalSetupProgress)
|
||||
{
|
||||
Logger.Info("[LocalSetupProgress] Guard passed");
|
||||
Logger.Info("[LocalSetupProgress] Calling state.RequestAdvance()");
|
||||
advanceRef.RequestAdvance();
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.Info($"[LocalSetupProgress] Guard skipped: CurrentRoute={advanceRef.CurrentRoute}");
|
||||
}
|
||||
});
|
||||
Logger.Info($"[LocalSetupProgress] TryEnqueue returned {enqueued}");
|
||||
},
|
||||
TaskScheduler.Default);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
s_engine.StateChanged += Handler;
|
||||
|
||||
if (s_runTask == null || s_runTask.IsCompleted || retryCount > 0)
|
||||
{
|
||||
s_advanceFiredForCompletion = false;
|
||||
s_runTask = s_engine.RunLocalOnlyAsync();
|
||||
}
|
||||
|
||||
return () =>
|
||||
{
|
||||
if (s_engine != null)
|
||||
s_engine.StateChanged -= Handler;
|
||||
};
|
||||
}, retryCount);
|
||||
|
||||
var phase = snapshot?.Phase ?? LocalGatewaySetupPhase.NotStarted;
|
||||
var status = snapshot?.Status ?? LocalGatewaySetupStatus.Pending;
|
||||
var lastRunningPhase = snapshot?.LastRunningPhase ?? LocalGatewaySetupPhase.NotStarted;
|
||||
var subtitle = !string.IsNullOrWhiteSpace(snapshot?.UserMessage)
|
||||
? snapshot!.UserMessage!
|
||||
: LocalizationHelper.GetString("Onboarding_LocalSetup_SubtitleIdle");
|
||||
|
||||
// Push the nav-bar Next button state for this snapshot. Mapping (Phase 5 final policy):
|
||||
// Idle/Pending (engine not started) → Hidden
|
||||
// Running / RequiresAdmin / RequiresRestart / Blocked → VisibleDisabled
|
||||
// Complete → VisibleEnabled (1s before auto-advance; tap to skip)
|
||||
// FailedRetryable / FailedTerminal → VisibleDisabled (in-page Try Again or Back-out)
|
||||
// Cancelled → VisibleDisabled
|
||||
// Back is always enabled by the OnboardingApp default (pageIndex > 0).
|
||||
Props.SetNextButtonState(LocalSetupProgressPolicy.MapStatusToNextButtonState(snapshot != null, status));
|
||||
|
||||
var stageRows = LocalSetupProgressStageMap.VisibleStages
|
||||
.Select(stage => RenderStage(LocalizationHelper.GetString(stage.LabelKey), stage.Phases, phase, status, lastRunningPhase))
|
||||
.ToArray<Element?>();
|
||||
|
||||
var isFailed = LocalSetupProgressStageMap.ShouldShowErrorRow(status);
|
||||
var canRetry = LocalSetupProgressStageMap.ShouldShowRetryButton(status);
|
||||
|
||||
Element errorRow;
|
||||
if (isFailed)
|
||||
{
|
||||
var msg = snapshot?.UserMessage ?? LocalizationHelper.GetString("Onboarding_LocalSetup_TerminalFailure");
|
||||
if (status == LocalGatewaySetupStatus.FailedTerminal)
|
||||
msg += "\n" + LocalizationHelper.GetString("Onboarding_LocalSetup_DiagnosticsHint");
|
||||
|
||||
var children = new System.Collections.Generic.List<Element?>
|
||||
{
|
||||
TextBlock(msg)
|
||||
.FontSize(12)
|
||||
.Opacity(0.85)
|
||||
.TextWrapping()
|
||||
.VAlign(VerticalAlignment.Center)
|
||||
.Grid(row: 0, column: 0)
|
||||
};
|
||||
if (canRetry)
|
||||
{
|
||||
children.Add(
|
||||
Button(LocalizationHelper.GetString("Onboarding_LocalSetup_Retry"), () => setRetryCount(retryCount + 1))
|
||||
.MinWidth(120)
|
||||
.HAlign(HorizontalAlignment.Right)
|
||||
.VAlign(VerticalAlignment.Center)
|
||||
.Set(b => Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(b, "OnboardingLocalSetupRetry"))
|
||||
.Grid(row: 0, column: 1)
|
||||
);
|
||||
}
|
||||
errorRow = Border(
|
||||
Grid(["1*", "Auto"], ["Auto"], children.ToArray())
|
||||
.Padding(12, 10, 12, 10)
|
||||
)
|
||||
.CornerRadius(8)
|
||||
.BackgroundResource("SystemFillColorCriticalBackgroundBrush")
|
||||
.Margin(0, 12, 0, 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
errorRow = TextBlock("").Height(0); // collapsed
|
||||
}
|
||||
|
||||
return Grid(
|
||||
columns: ["1*"],
|
||||
rows: ["Auto", "Auto", "1*", "Auto"],
|
||||
|
||||
TextBlock(LocalizationHelper.GetString("Onboarding_LocalSetup_Title"))
|
||||
.FontSize(22)
|
||||
.FontWeight(new global::Windows.UI.Text.FontWeight(700))
|
||||
.HAlign(HorizontalAlignment.Center)
|
||||
.TextWrapping()
|
||||
.Grid(row: 0, column: 0),
|
||||
|
||||
TextBlock(subtitle)
|
||||
.FontSize(13)
|
||||
.Opacity(0.65)
|
||||
.HAlign(HorizontalAlignment.Center)
|
||||
.TextWrapping()
|
||||
.Margin(0, 6, 0, 12)
|
||||
.Grid(row: 1, column: 0),
|
||||
|
||||
ScrollView(
|
||||
VStack(8, stageRows)
|
||||
.Padding(8, 4, 8, 4)
|
||||
)
|
||||
.Grid(row: 2, column: 0),
|
||||
|
||||
errorRow.Grid(row: 3, column: 0)
|
||||
)
|
||||
.HAlign(HorizontalAlignment.Stretch)
|
||||
.VAlign(VerticalAlignment.Stretch)
|
||||
.MaxWidth(520)
|
||||
.Padding(0, 8, 0, 0);
|
||||
}
|
||||
|
||||
private static Element RenderStage(string label, LocalGatewaySetupPhase[] stagePhases, LocalGatewaySetupPhase currentPhase, LocalGatewaySetupStatus currentStatus, LocalGatewaySetupPhase lastRunningPhase)
|
||||
{
|
||||
var stageState = LocalSetupProgressStageMap.ComputeStageState(stagePhases, currentPhase, currentStatus, lastRunningPhase);
|
||||
string icon;
|
||||
Element trailing;
|
||||
double opacity;
|
||||
switch (stageState)
|
||||
{
|
||||
case LocalSetupProgressStageMap.StageState.Complete:
|
||||
icon = "✅";
|
||||
trailing = TextBlock("").Width(20);
|
||||
opacity = 1.0;
|
||||
break;
|
||||
case LocalSetupProgressStageMap.StageState.Active:
|
||||
icon = "•";
|
||||
trailing = ProgressRing().Width(18).Height(18);
|
||||
opacity = 1.0;
|
||||
break;
|
||||
case LocalSetupProgressStageMap.StageState.Failed:
|
||||
icon = "❌";
|
||||
trailing = TextBlock("").Width(20);
|
||||
opacity = 1.0;
|
||||
break;
|
||||
case LocalSetupProgressStageMap.StageState.Pending:
|
||||
default:
|
||||
icon = "○";
|
||||
trailing = TextBlock("").Width(20);
|
||||
opacity = 0.4;
|
||||
break;
|
||||
}
|
||||
|
||||
var labelBlock = TextBlock(label)
|
||||
.FontSize(13)
|
||||
.VAlign(VerticalAlignment.Center)
|
||||
.Grid(row: 0, column: 1);
|
||||
|
||||
if (stageState == LocalSetupProgressStageMap.StageState.Failed)
|
||||
labelBlock = labelBlock.Set(t => t.Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.IndianRed));
|
||||
|
||||
return Grid(
|
||||
columns: ["Auto", "1*", "Auto"],
|
||||
rows: ["Auto"],
|
||||
|
||||
TextBlock(icon)
|
||||
.FontSize(14)
|
||||
.Margin(0, 0, 10, 0)
|
||||
.VAlign(VerticalAlignment.Center)
|
||||
.Grid(row: 0, column: 0),
|
||||
|
||||
labelBlock,
|
||||
|
||||
trailing.Grid(row: 0, column: 2)
|
||||
)
|
||||
.Opacity(opacity)
|
||||
.Padding(4, 4, 4, 4);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Visual-test hook: when OPENCLAW_VISUAL_TEST=1 and OPENCLAW_VISUAL_TEST_LOCAL_SETUP is set,
|
||||
/// render a synthetic state without starting the real WSL setup engine. Accepted values:
|
||||
/// "active:<phase>" (e.g. "active:CreateWslInstance"),
|
||||
/// "complete",
|
||||
/// "retryable:<message>",
|
||||
/// "terminal:<message>".
|
||||
/// </summary>
|
||||
private static LocalGatewaySetupState? TryReadVisualTestState()
|
||||
{
|
||||
if (Environment.GetEnvironmentVariable("OPENCLAW_VISUAL_TEST") != "1") return null;
|
||||
var raw = Environment.GetEnvironmentVariable("OPENCLAW_VISUAL_TEST_LOCAL_SETUP");
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
|
||||
var state = LocalGatewaySetupState.Create(new LocalGatewaySetupOptions());
|
||||
var parts = raw.Split(':', 2);
|
||||
var kind = parts[0].Trim().ToLowerInvariant();
|
||||
var arg = parts.Length > 1 ? parts[1] : "";
|
||||
|
||||
switch (kind)
|
||||
{
|
||||
case "active":
|
||||
if (Enum.TryParse<LocalGatewaySetupPhase>(arg, ignoreCase: true, out var p))
|
||||
{
|
||||
state.StartPhase(p, LocalizationHelper.GetString("Onboarding_LocalSetup_SubtitleIdle"));
|
||||
}
|
||||
break;
|
||||
case "complete":
|
||||
state.CompletePhase(LocalGatewaySetupPhase.Complete, LocalizationHelper.GetString("Onboarding_LocalSetup_SubtitleSuccess"));
|
||||
break;
|
||||
case "retryable":
|
||||
// Walk the engine partway so RenderSnapshot.LastRunningPhase pins
|
||||
// the failure marker on a stage instead of stage 0.
|
||||
state.StartPhase(LocalGatewaySetupPhase.MintBootstrapToken, "");
|
||||
state.Block("visual_test_retryable", string.IsNullOrWhiteSpace(arg) ? "Setup hit a snag." : arg, retryable: true);
|
||||
break;
|
||||
case "terminal":
|
||||
state.StartPhase(LocalGatewaySetupPhase.MintBootstrapToken, "");
|
||||
state.Block("visual_test_terminal", string.IsNullOrWhiteSpace(arg) ? "Setup cannot continue." : arg, retryable: false);
|
||||
break;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,6 @@ using OpenClawTray.FunctionalUI;
|
||||
using OpenClawTray.FunctionalUI.Core;
|
||||
using OpenClawTray.Helpers;
|
||||
using OpenClawTray.Onboarding.Services;
|
||||
using OpenClawTray.Services;
|
||||
using static OpenClawTray.FunctionalUI.Factories;
|
||||
using Microsoft.UI.Xaml;
|
||||
|
||||
@ -17,16 +16,7 @@ public sealed class ReadyPage : Component<OnboardingState>
|
||||
{
|
||||
public override Element Render()
|
||||
{
|
||||
// Safety-default the rendered switch to ON, then sync from persisted settings
|
||||
// on mount (SettingsManager defaults AutoStart=true for fresh users). The mount
|
||||
// sync also materializes the Run-key even if the user never touches the switch.
|
||||
var (launchAtLogin, setLaunchAtLogin) = UseState(true);
|
||||
UseEffect(() =>
|
||||
{
|
||||
var persisted = Props.Settings.AutoStart;
|
||||
setLaunchAtLogin(persisted);
|
||||
ApplyLaunchAtLogin(persisted);
|
||||
}, Props.Settings.AutoStart);
|
||||
var (launchAtLogin, setLaunchAtLogin) = UseState(false);
|
||||
|
||||
return ScrollView(
|
||||
VStack(12,
|
||||
@ -59,11 +49,7 @@ public sealed class ReadyPage : Component<OnboardingState>
|
||||
|
||||
// Launch at Login toggle
|
||||
HStack(8,
|
||||
ToggleSwitch(launchAtLogin, v =>
|
||||
{
|
||||
setLaunchAtLogin(v);
|
||||
ApplyLaunchAtLogin(v);
|
||||
}),
|
||||
ToggleSwitch(launchAtLogin, v => setLaunchAtLogin(v)),
|
||||
TextBlock(LocalizationHelper.GetString("Onboarding_Ready_LaunchAtLogin"))
|
||||
.FontSize(13)
|
||||
.VAlign(VerticalAlignment.Center)
|
||||
@ -75,34 +61,18 @@ public sealed class ReadyPage : Component<OnboardingState>
|
||||
).HorizontalScrollMode(Microsoft.UI.Xaml.Controls.ScrollMode.Disabled);
|
||||
}
|
||||
|
||||
private void ApplyLaunchAtLogin(bool enabled)
|
||||
{
|
||||
Props.Settings.AutoStart = enabled;
|
||||
// Persist immediately so a user who toggles and then closes the wizard via
|
||||
// the X button still gets their preference saved (OnboardingState.Complete()
|
||||
// also saves on Finish — this is belt-and-braces).
|
||||
Props.Settings.Save();
|
||||
|
||||
try
|
||||
{
|
||||
AutoStartManager.SetAutoStart(enabled);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Logger.Warn($"[ReadyPage] Failed to apply autostart={enabled}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private Element ModeInfoCard()
|
||||
{
|
||||
if (Props.Settings.EnableNodeMode)
|
||||
{
|
||||
return Border(
|
||||
VStack(8,
|
||||
TextBlock(LocalizationHelper.GetString("Onboarding_Ready_NodeModeActive"))
|
||||
TextBlock("🔌 Node Mode Active")
|
||||
.FontSize(14)
|
||||
.FontWeight(new global::Windows.UI.Text.FontWeight(600)),
|
||||
TextBlock(LocalizationHelper.GetString("Onboarding_Ready_NodeModeActiveDetail"))
|
||||
TextBlock("This PC will operate as a remote compute node. " +
|
||||
"The gateway can invoke screen capture, camera, and system " +
|
||||
"commands on this machine.")
|
||||
.FontSize(12)
|
||||
.Opacity(0.8)
|
||||
.TextWrapping()
|
||||
|
||||
@ -1,190 +0,0 @@
|
||||
using OpenClawTray.FunctionalUI;
|
||||
using OpenClawTray.FunctionalUI.Core;
|
||||
using OpenClawTray.Helpers;
|
||||
using OpenClawTray.Onboarding.Services;
|
||||
using static OpenClawTray.FunctionalUI.Factories;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
|
||||
namespace OpenClawTray.Onboarding.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// Page 0 of the forked Phase-5 onboarding flow.
|
||||
///
|
||||
/// Layout contract (Mattingly Phase 5 + PR #274 must-fix #6):
|
||||
///
|
||||
/// Grid
|
||||
/// Rows: Auto (title), 1* (body+spacer), Auto (primary or warning section), Auto (hyperlink)
|
||||
/// Columns: 1*
|
||||
/// HAlign Center / VAlign Center / MaxWidth 460
|
||||
/// Row 0: TextBlock title — bold 22pt, centered
|
||||
/// Row 1: TextBlock body — 14pt, 0.65 opacity, wrapping; security notice folded in
|
||||
/// Row 2: [no existing config] Button "Set up locally" — accent fill, MinWidth 200, Height 44, centered
|
||||
/// [existing config] VStack: ⚠️ heading + body + "Replace my setup" (accent) + "Keep my setup" (hyperlink)
|
||||
/// Row 3: Button "Advanced setup" styled as TextBlockButton (hyperlink), 8px top margin (always visible)
|
||||
///
|
||||
/// When existing config is detected (<see cref="OnboardingState.ExistingConfigGuard"/>
|
||||
/// returns HasExistingConfiguration=true), the warn-and-confirm section replaces row 2
|
||||
/// immediately on page load. The user must explicitly click "Replace my setup" before
|
||||
/// the local setup path can advance. "Advanced setup" is always available in row 3.
|
||||
/// </summary>
|
||||
public sealed class SetupWarningPage : Component<OnboardingState>
|
||||
{
|
||||
public override Element Render()
|
||||
{
|
||||
var guard = Props.ExistingConfigGuard;
|
||||
var hasExisting = guard?.HasExistingConfiguration() == true;
|
||||
|
||||
// Initialize warn-confirm state to true when existing config detected so the
|
||||
// warning is visible immediately on page load (Mike's directive: initial page
|
||||
// MUST show warning when existing gateway is paired).
|
||||
var (confirmingReplace, setConfirmingReplace) = UseState(hasExisting);
|
||||
|
||||
string titleText = LocalizationHelper.GetString("Onboarding_SetupWarning_Title");
|
||||
string bodyText = LocalizationHelper.GetString("Onboarding_SetupWarning_Body");
|
||||
|
||||
void ChooseLocal()
|
||||
{
|
||||
if (guard?.HasExistingConfiguration() == true)
|
||||
{
|
||||
// Show warn-and-confirm section in-place.
|
||||
setConfirmingReplace(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
Props.SetupPath = Onboarding.Services.SetupPath.Local;
|
||||
Props.Mode = ConnectionMode.Local;
|
||||
Props.RequestAdvance();
|
||||
}
|
||||
}
|
||||
|
||||
void ConfirmReplace()
|
||||
{
|
||||
Props.ReplaceExistingConfigurationConfirmed = true;
|
||||
Props.SetupPath = Onboarding.Services.SetupPath.Local;
|
||||
Props.Mode = ConnectionMode.Local;
|
||||
Props.RequestAdvance();
|
||||
}
|
||||
|
||||
void CancelReplace()
|
||||
{
|
||||
setConfirmingReplace(false);
|
||||
}
|
||||
|
||||
void ChooseAdvanced()
|
||||
{
|
||||
Props.SetupPath = Onboarding.Services.SetupPath.Advanced;
|
||||
Props.RequestAdvance();
|
||||
}
|
||||
|
||||
// Row 2: either the local setup button or the warn-and-confirm section.
|
||||
Element row2;
|
||||
if (confirmingReplace)
|
||||
{
|
||||
var summary = guard?.GetSummary();
|
||||
var replaceBody = LocalizationHelper.GetString("Onboarding_SetupWarning_ReplaceBody");
|
||||
|
||||
// Append dynamic lost-items detail (Mike Q2: list specifically what is lost).
|
||||
var lostItems = new System.Collections.Generic.List<string>();
|
||||
if (summary?.HasToken == true) lostItems.Add("gateway token");
|
||||
if (summary?.HasOperatorDeviceToken == true || summary?.HasNodeDeviceToken == true) lostItems.Add("device pairing");
|
||||
if (summary?.HasNonDefaultGatewayUrl == true) lostItems.Add("current gateway URL");
|
||||
if (summary?.HasBootstrapToken == true) lostItems.Add("bootstrap token");
|
||||
if (lostItems.Count > 0)
|
||||
replaceBody += $" This will overwrite: {string.Join(", ", lostItems)}.";
|
||||
|
||||
row2 = VStack(8,
|
||||
TextBlock(LocalizationHelper.GetString("Onboarding_SetupWarning_ReplaceHeading"))
|
||||
.FontSize(15)
|
||||
.FontWeight(new global::Windows.UI.Text.FontWeight(600))
|
||||
.HAlign(HorizontalAlignment.Center)
|
||||
.TextWrapping(),
|
||||
|
||||
TextBlock(replaceBody)
|
||||
.FontSize(13)
|
||||
.Opacity(0.75)
|
||||
.HAlign(HorizontalAlignment.Center)
|
||||
.TextWrapping()
|
||||
.Margin(0, 4, 0, 8),
|
||||
|
||||
Button(LocalizationHelper.GetString("Onboarding_SetupWarning_ReplaceConfirm"), ConfirmReplace)
|
||||
.MinWidth(200)
|
||||
.Height(44)
|
||||
.HAlign(HorizontalAlignment.Center)
|
||||
.Set(b =>
|
||||
{
|
||||
b.Style = (Style)Application.Current.Resources["AccentButtonStyle"];
|
||||
Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(b, "OnboardingReplaceConfirm");
|
||||
}),
|
||||
|
||||
Button(LocalizationHelper.GetString("Onboarding_SetupWarning_ReplaceCancel"), CancelReplace)
|
||||
.HAlign(HorizontalAlignment.Center)
|
||||
.Set(b =>
|
||||
{
|
||||
if (Application.Current.Resources.TryGetValue("TextBlockButtonStyle", out var hyperStyle) &&
|
||||
hyperStyle is Style s)
|
||||
{
|
||||
b.Style = s;
|
||||
}
|
||||
Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(b, "OnboardingReplaceCancel");
|
||||
})
|
||||
)
|
||||
.HAlign(HorizontalAlignment.Center)
|
||||
.Grid(row: 2, column: 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
row2 = Button(LocalizationHelper.GetString("Onboarding_SetupWarning_SetupLocally"), ChooseLocal)
|
||||
.MinWidth(200)
|
||||
.Height(44)
|
||||
.HAlign(HorizontalAlignment.Center)
|
||||
.Set(b =>
|
||||
{
|
||||
b.Style = (Style)Application.Current.Resources["AccentButtonStyle"];
|
||||
Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(b, "OnboardingSetupLocal");
|
||||
})
|
||||
.Grid(row: 2, column: 0);
|
||||
}
|
||||
|
||||
return Grid(
|
||||
columns: ["1*"],
|
||||
rows: ["Auto", "1*", "Auto", "Auto"],
|
||||
|
||||
TextBlock(titleText)
|
||||
.FontSize(22)
|
||||
.FontWeight(new global::Windows.UI.Text.FontWeight(700))
|
||||
.HAlign(HorizontalAlignment.Center)
|
||||
.TextWrapping()
|
||||
.Grid(row: 0, column: 0),
|
||||
|
||||
TextBlock(bodyText)
|
||||
.FontSize(14)
|
||||
.Opacity(0.65)
|
||||
.HAlign(HorizontalAlignment.Center)
|
||||
.VAlign(VerticalAlignment.Top)
|
||||
.TextWrapping()
|
||||
.Margin(0, 12, 0, 12)
|
||||
.Grid(row: 1, column: 0),
|
||||
|
||||
row2,
|
||||
|
||||
Button(LocalizationHelper.GetString("Onboarding_SetupWarning_Advanced"), ChooseAdvanced)
|
||||
.HAlign(HorizontalAlignment.Center)
|
||||
.Margin(0, 8, 0, 0)
|
||||
.Set(b =>
|
||||
{
|
||||
if (Application.Current.Resources.TryGetValue("TextBlockButtonStyle", out var hyperStyle) &&
|
||||
hyperStyle is Style s)
|
||||
{
|
||||
b.Style = s;
|
||||
}
|
||||
Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(b, "OnboardingSetupAdvanced");
|
||||
})
|
||||
.Grid(row: 3, column: 0)
|
||||
)
|
||||
.HAlign(HorizontalAlignment.Center)
|
||||
.VAlign(VerticalAlignment.Center)
|
||||
.MaxWidth(460)
|
||||
.Padding(0, 8, 0, 0);
|
||||
}
|
||||
}
|
||||
78
src/OpenClaw.Tray.WinUI/Onboarding/Pages/WelcomePage.cs
Normal file
78
src/OpenClaw.Tray.WinUI/Onboarding/Pages/WelcomePage.cs
Normal file
@ -0,0 +1,78 @@
|
||||
using OpenClawTray.FunctionalUI;
|
||||
using OpenClawTray.FunctionalUI.Core;
|
||||
using OpenClawTray.Helpers;
|
||||
using static OpenClawTray.FunctionalUI.Factories;
|
||||
using Microsoft.UI.Xaml;
|
||||
|
||||
namespace OpenClawTray.Onboarding.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// Page 0: Welcome & Security Notice.
|
||||
/// Matches macOS welcomePage() — title, subtitle, security warning card.
|
||||
/// </summary>
|
||||
public sealed class WelcomePage : Component
|
||||
{
|
||||
public override Element Render()
|
||||
{
|
||||
return VStack(10,
|
||||
TextBlock(LocalizationHelper.GetString("Onboarding_Welcome_Title"))
|
||||
.FontSize(22)
|
||||
.FontWeight(new global::Windows.UI.Text.FontWeight(700))
|
||||
.HAlign(HorizontalAlignment.Center)
|
||||
.TextWrapping(),
|
||||
|
||||
TextBlock(LocalizationHelper.GetString("Onboarding_Welcome_Subtitle"))
|
||||
.FontSize(14)
|
||||
.Opacity(0.6)
|
||||
.HAlign(HorizontalAlignment.Center)
|
||||
.TextWrapping(),
|
||||
|
||||
TextBlock(LocalizationHelper.GetString("Onboarding_Welcome_GetConnected"))
|
||||
.FontSize(13)
|
||||
.Opacity(0.5)
|
||||
.HAlign(HorizontalAlignment.Center)
|
||||
.TextWrapping()
|
||||
.Margin(0, 4, 0, 0),
|
||||
|
||||
// Combined security notice + trust card
|
||||
Border(
|
||||
VStack(8,
|
||||
HStack(6,
|
||||
TextBlock("⚠️").FontSize(14),
|
||||
TextBlock(LocalizationHelper.GetString("Onboarding_Welcome_SecurityTitle"))
|
||||
.FontSize(13)
|
||||
.FontWeight(new global::Windows.UI.Text.FontWeight(600))
|
||||
),
|
||||
TextBlock(LocalizationHelper.GetString("Onboarding_Welcome_SecurityBody"))
|
||||
.FontSize(12)
|
||||
.Opacity(0.85)
|
||||
.TextWrapping(),
|
||||
TextBlock(LocalizationHelper.GetString("Onboarding_Welcome_TrustTitle"))
|
||||
.FontSize(13)
|
||||
.FontWeight(new global::Windows.UI.Text.FontWeight(600))
|
||||
.Margin(0, 4, 0, 0),
|
||||
BulletItem("Onboarding_Welcome_Trust_Commands", "Run commands on your computer"),
|
||||
BulletItem("Onboarding_Welcome_Trust_Files", "Read and write files"),
|
||||
BulletItem("Onboarding_Welcome_Trust_Screen", "Capture screenshots")
|
||||
).Padding(14)
|
||||
)
|
||||
.CornerRadius(8)
|
||||
.BackgroundResource("SystemFillColorCautionBackgroundBrush")
|
||||
.Margin(0, 12, 0, 0)
|
||||
)
|
||||
.HAlign(HorizontalAlignment.Center)
|
||||
.VAlign(VerticalAlignment.Center)
|
||||
.MaxWidth(460)
|
||||
.Padding(0, 8, 0, 0);
|
||||
}
|
||||
|
||||
private static Element BulletItem(string key, string fallback)
|
||||
{
|
||||
var text = LocalizationHelper.GetString(key);
|
||||
if (text == key) text = fallback;
|
||||
return HStack(6,
|
||||
TextBlock("•").FontSize(12).Opacity(0.6),
|
||||
TextBlock(text).FontSize(12).Opacity(0.7)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -18,13 +18,6 @@ namespace OpenClawTray.Onboarding.Pages;
|
||||
/// </summary>
|
||||
public sealed class WizardPage : Component<OnboardingState>
|
||||
{
|
||||
private static readonly Regex UrlInMessagePattern = new(
|
||||
@"(https?://[^\s\)\"",]+)",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex DeviceCodePattern = new(
|
||||
@"(?:^|\s)(?:[Cc]ode|user_code|USER_CODE)\s*[:=]\s*([A-Z0-9]{2,8}(?:-[A-Z0-9]{2,8})+|[A-Z0-9]{4,12})\b",
|
||||
RegexOptions.Compiled);
|
||||
public override Element Render()
|
||||
{
|
||||
// Read persisted wizard state from shared OnboardingState
|
||||
@ -54,9 +47,9 @@ public sealed class WizardPage : Component<OnboardingState>
|
||||
// Guard against default/undefined JsonElement
|
||||
if (payload.ValueKind == JsonValueKind.Undefined || payload.ValueKind == JsonValueKind.Null)
|
||||
{
|
||||
setErrorMsg(LocalizationHelper.GetString("Onboarding_Wizard_ErrorEmptyGatewayResponse"));
|
||||
setErrorMsg("Empty response from gateway");
|
||||
setWizardState("error");
|
||||
SaveState("error", LocalizationHelper.GetString("Onboarding_Wizard_ErrorEmptyGatewayResponse"));
|
||||
SaveState("error", "Empty response from gateway");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -268,9 +261,9 @@ public sealed class WizardPage : Component<OnboardingState>
|
||||
|
||||
if (!client.IsConnectedToGateway)
|
||||
{
|
||||
setErrorMsg(LocalizationHelper.GetString("Onboarding_Wizard_ErrorGatewayDisconnectedDetail"));
|
||||
setErrorMsg("Lost connection to gateway. Click Next to skip the wizard, or wait for reconnection.");
|
||||
setWizardState("error");
|
||||
SaveState("error", LocalizationHelper.GetString("Onboarding_Wizard_ErrorGatewayDisconnected"));
|
||||
SaveState("error", "Gateway disconnected");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -289,7 +282,7 @@ public sealed class WizardPage : Component<OnboardingState>
|
||||
var answerValue = string.IsNullOrEmpty(stepInput) ? "true" : stepInput;
|
||||
|
||||
// Smart timeout: 5min for auth-related steps (device code polling), 30s for everything else
|
||||
var isAuthStep = !string.IsNullOrEmpty(stepMessage) &&
|
||||
var isAuthStep = !string.IsNullOrEmpty(stepMessage) &&
|
||||
(stepMessage.Contains("device", StringComparison.OrdinalIgnoreCase) ||
|
||||
stepMessage.Contains("authorize", StringComparison.OrdinalIgnoreCase) ||
|
||||
stepMessage.Contains("login", StringComparison.OrdinalIgnoreCase) ||
|
||||
@ -306,9 +299,9 @@ public sealed class WizardPage : Component<OnboardingState>
|
||||
// Validate response before applying
|
||||
if (response.ValueKind == JsonValueKind.Undefined || response.ValueKind == JsonValueKind.Null)
|
||||
{
|
||||
setErrorMsg(LocalizationHelper.GetString("Onboarding_Wizard_ErrorEmptyNextResponse"));
|
||||
setErrorMsg("Gateway returned empty response for wizard.next");
|
||||
setWizardState("error");
|
||||
SaveState("error", LocalizationHelper.GetString("Onboarding_Wizard_ErrorEmptyNextResponse"));
|
||||
SaveState("error", "Empty wizard.next response");
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -340,9 +333,9 @@ public sealed class WizardPage : Component<OnboardingState>
|
||||
|
||||
if (!client.IsConnectedToGateway)
|
||||
{
|
||||
setErrorMsg(LocalizationHelper.GetString("Onboarding_Wizard_ErrorGatewayDisconnectedDetail"));
|
||||
setErrorMsg("Lost connection to gateway. Click Next to skip the wizard, or wait for reconnection.");
|
||||
setWizardState("error");
|
||||
SaveState("error", LocalizationHelper.GetString("Onboarding_Wizard_ErrorGatewayDisconnected"));
|
||||
SaveState("error", "Gateway disconnected");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -471,21 +464,21 @@ public sealed class WizardPage : Component<OnboardingState>
|
||||
}
|
||||
else
|
||||
{
|
||||
inputArea = TextBlock(LocalizationHelper.GetString("Onboarding_Wizard_NoOptionsAvailable")).FontSize(12).Opacity(0.5);
|
||||
inputArea = TextBlock("No options available").FontSize(12).Opacity(0.5);
|
||||
showButtons = false; // Don't allow submit with no valid selection
|
||||
}
|
||||
}
|
||||
else if (stepType == "confirm")
|
||||
{
|
||||
buttonLabel1 = LocalizationHelper.GetString("Onboarding_Wizard_Yes");
|
||||
buttonLabel2 = LocalizationHelper.GetString("Onboarding_Wizard_NoSkip");
|
||||
buttonLabel1 = "Yes";
|
||||
buttonLabel2 = "No / Skip";
|
||||
}
|
||||
else if (stepType == "progress")
|
||||
{
|
||||
// Show spinner while gateway polls for auth completion
|
||||
inputArea = HStack(8,
|
||||
ProgressRing().Width(24).Height(24),
|
||||
TextBlock(LocalizationHelper.GetString("Onboarding_Wizard_Waiting")).FontSize(13).Opacity(0.7)
|
||||
TextBlock("Waiting...").FontSize(13).Opacity(0.7)
|
||||
.VAlign(VerticalAlignment.Center)
|
||||
);
|
||||
showButtons = false; // Gateway auto-advances on completion
|
||||
@ -495,23 +488,23 @@ public sealed class WizardPage : Component<OnboardingState>
|
||||
|
||||
case "complete":
|
||||
displayTitle = $"✅ {LocalizationHelper.GetString("Onboarding_Wizard_Complete")}";
|
||||
displayMessage = LocalizationHelper.GetString("Onboarding_Wizard_ClickNextToContinue");
|
||||
displayMessage = "Click Next to continue.";
|
||||
break;
|
||||
|
||||
case "error":
|
||||
displayTitle = $"❌ {LocalizationHelper.GetString("Onboarding_Wizard_ErrorTitle")}";
|
||||
displayTitle = "❌ Wizard error";
|
||||
displayMessage = errorMsg;
|
||||
showButtons = true;
|
||||
buttonLabel1 = LocalizationHelper.GetString("Onboarding_Retry");
|
||||
buttonLabel2 = LocalizationHelper.GetString("Onboarding_Wizard_SkipWizard");
|
||||
buttonLabel1 = "Retry";
|
||||
buttonLabel2 = "Skip Wizard";
|
||||
break;
|
||||
|
||||
case "loading":
|
||||
displayTitle = $"🔄 {LocalizationHelper.GetString("Onboarding_Connection_StatusAuthenticating")}";
|
||||
displayMessage = LocalizationHelper.GetString("Onboarding_Wizard_ConnectingToGateway");
|
||||
displayMessage = "Connecting to gateway...";
|
||||
inputArea = HStack(8,
|
||||
ProgressRing().Width(24).Height(24),
|
||||
TextBlock(LocalizationHelper.GetString("Onboarding_Wizard_ConnectionWaitDetail"))
|
||||
TextBlock("Please wait while the connection is established...")
|
||||
.FontSize(13).Opacity(0.7)
|
||||
.VAlign(VerticalAlignment.Center)
|
||||
);
|
||||
@ -519,7 +512,7 @@ public sealed class WizardPage : Component<OnboardingState>
|
||||
|
||||
default:
|
||||
displayTitle = $"🔌 {LocalizationHelper.GetString("Onboarding_Wizard_Offline")}";
|
||||
displayMessage = $"{LocalizationHelper.GetString("Onboarding_Wizard_OfflineMessage")}\n\n{LocalizationHelper.GetString("Onboarding_Wizard_ClickNextToContinue")}";
|
||||
displayMessage = $"{LocalizationHelper.GetString("Onboarding_Wizard_OfflineMessage")}\n\nClick Next to continue.";
|
||||
break;
|
||||
}
|
||||
|
||||
@ -530,7 +523,7 @@ public sealed class WizardPage : Component<OnboardingState>
|
||||
if (!string.IsNullOrEmpty(displayMessage))
|
||||
{
|
||||
// URL detection — find https:// URLs in the message
|
||||
var urlMatch = UrlInMessagePattern.Match(displayMessage);
|
||||
var urlMatch = Regex.Match(displayMessage, @"(https?://[^\s\)\"",]+)");
|
||||
if (urlMatch.Success)
|
||||
{
|
||||
var detectedUrl = urlMatch.Value;
|
||||
@ -552,7 +545,9 @@ public sealed class WizardPage : Component<OnboardingState>
|
||||
// Capture must contain a digit or hyphen (or be all uppercase) to avoid
|
||||
// matching common English words like "below" that follow "code".
|
||||
// Case-sensitive on the value to require the GitHub-style uppercase code.
|
||||
var codeMatch = DeviceCodePattern.Match(displayMessage);
|
||||
var codeMatch = Regex.Match(
|
||||
displayMessage,
|
||||
@"(?:^|\s)(?:[Cc]ode|user_code|USER_CODE)\s*[:=]\s*([A-Z0-9]{2,8}(?:-[A-Z0-9]{2,8})+|[A-Z0-9]{4,12})\b");
|
||||
if (codeMatch.Success)
|
||||
{
|
||||
var code = codeMatch.Groups[1].Value;
|
||||
@ -586,7 +581,7 @@ public sealed class WizardPage : Component<OnboardingState>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(displayMessage))
|
||||
{
|
||||
var urlMatch = UrlInMessagePattern.Match(displayMessage);
|
||||
var urlMatch = Regex.Match(displayMessage, @"(https?://[^\s\)\"",]+)");
|
||||
if (urlMatch.Success)
|
||||
{
|
||||
try
|
||||
@ -644,3 +639,4 @@ public sealed class WizardPage : Component<OnboardingState>
|
||||
.Padding(0, 8, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using OpenClaw.Shared;
|
||||
using System;
|
||||
|
||||
namespace OpenClawTray.Onboarding.Services;
|
||||
|
||||
@ -10,5 +10,18 @@ public static class LocalGatewayApprover
|
||||
/// <summary>
|
||||
/// Checks if the gateway URL points to localhost.
|
||||
/// </summary>
|
||||
public static bool IsLocalGateway(string gatewayUrl) => LocalGatewayUrlClassifier.IsLocalGatewayUrl(gatewayUrl);
|
||||
public static bool IsLocalGateway(string gatewayUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(gatewayUrl)) return false;
|
||||
try
|
||||
{
|
||||
var uri = new Uri(gatewayUrl);
|
||||
var host = uri.Host.ToLowerInvariant();
|
||||
return host is "localhost" or "127.0.0.1" or "::1" or "[::1]";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,50 +0,0 @@
|
||||
using OpenClawTray.Services.LocalGatewaySetup;
|
||||
|
||||
namespace OpenClawTray.Onboarding.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Pure mapping helpers for <c>LocalSetupProgressPage</c> nav-bar policy
|
||||
/// (Phase 5 final). Lives in the Services namespace (no WinUI / FunctionalUI
|
||||
/// dependencies) so unit tests in <c>OpenClaw.Tray.Tests</c> can import it
|
||||
/// directly via the project's selective <c><Compile Include></c> list.
|
||||
/// </summary>
|
||||
public static class LocalSetupProgressPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps a <see cref="LocalGatewaySetupState"/> snapshot to the nav-bar
|
||||
/// Next button state per the Phase 5 final Next/Back-button policy.
|
||||
///
|
||||
/// Mapping:
|
||||
/// null / Pending → Hidden (engine not started; Idle)
|
||||
/// Running → VisibleDisabled (engine progressing)
|
||||
/// Complete → VisibleEnabled (1s pre-auto-advance; tap to skip)
|
||||
/// FailedRetryable → VisibleDisabled (in-page Try Again is the action)
|
||||
/// FailedTerminal → VisibleDisabled (force Back-out; no advancing past broken gateway)
|
||||
/// RequiresAdmin / RequiresRestart / Blocked / Cancelled → VisibleDisabled
|
||||
///
|
||||
/// Back is always enabled by the OnboardingApp default (pageIndex > 0
|
||||
/// on LocalSetupProgress because SetupWarning is page 0).
|
||||
/// </summary>
|
||||
public static OnboardingNextButtonState MapStatusToNextButtonState(LocalGatewaySetupState? snapshot, LocalGatewaySetupStatus status)
|
||||
=> MapStatusToNextButtonState(snapshot != null, status);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot-free overload used by the page after Bug 2 (e2e drive 2026-05-04).
|
||||
/// The page now stores an immutable RenderSnapshot record (value equality)
|
||||
/// instead of holding the live <see cref="LocalGatewaySetupState"/> reference,
|
||||
/// so it passes <c>hasSnapshot</c> + <c>status</c> directly. The original
|
||||
/// reference-typed overload is preserved for back-compat with existing tests.
|
||||
/// </summary>
|
||||
public static OnboardingNextButtonState MapStatusToNextButtonState(bool hasSnapshot, LocalGatewaySetupStatus status)
|
||||
{
|
||||
if (!hasSnapshot)
|
||||
return OnboardingNextButtonState.Hidden;
|
||||
|
||||
return status switch
|
||||
{
|
||||
LocalGatewaySetupStatus.Pending => OnboardingNextButtonState.Hidden,
|
||||
LocalGatewaySetupStatus.Complete => OnboardingNextButtonState.VisibleEnabled,
|
||||
_ => OnboardingNextButtonState.VisibleDisabled,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,131 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenClawTray.Services.LocalGatewaySetup;
|
||||
|
||||
namespace OpenClawTray.Onboarding.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Pure helpers for <c>LocalSetupProgressPage</c>'s stage-list rendering
|
||||
/// (Phase 5). Lives in the Services namespace (no WinUI / FunctionalUI
|
||||
/// dependencies) so unit tests in <c>OpenClaw.Tray.Tests</c> can import
|
||||
/// it directly via the project's selective <c><Compile Include></c> list.
|
||||
///
|
||||
/// Exists to fix Bug 2 from the e2e drive (2026-05-04) — the page render
|
||||
/// previously inlined this logic AND took a reference-typed snapshot, which
|
||||
/// hid two distinct defects:
|
||||
/// 1. The engine raises <see cref="LocalGatewaySetupEngine.StateChanged"/>
|
||||
/// with the same mutating <see cref="LocalGatewaySetupState"/> instance,
|
||||
/// so reference-equality in <c>UseState</c> suppressed re-renders.
|
||||
/// 2. The stage-state computation depended on <see cref="LocalGatewaySetupPhase.Failed"/>'s
|
||||
/// ordinal, but on failure the engine pins <c>Phase = Failed</c> (the highest
|
||||
/// ordinal), losing the position of the last running phase. This helper
|
||||
/// threads <c>lastRunningPhase</c> explicitly so failure rendering is
|
||||
/// stable across the engine's full phase set.
|
||||
/// </summary>
|
||||
public static class LocalSetupProgressStageMap
|
||||
{
|
||||
public enum StageState
|
||||
{
|
||||
Pending,
|
||||
Active,
|
||||
Complete,
|
||||
Failed,
|
||||
}
|
||||
|
||||
public sealed record VisibleStage(string LabelKey, LocalGatewaySetupPhase[] Phases);
|
||||
|
||||
/// <summary>
|
||||
/// Whitelist of user-meaningful stages. Hidden phases (e.g. ElevationCheck,
|
||||
/// PairOperator, CheckWindowsNodeReadiness, PairWindowsTrayNode, VerifyEndToEnd)
|
||||
/// fold into a neighbouring visible stage or surface only as the subtitle line.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<VisibleStage> VisibleStages = new VisibleStage[]
|
||||
{
|
||||
new("Onboarding_LocalSetup_Phase_Preflight", new[] { LocalGatewaySetupPhase.Preflight, LocalGatewaySetupPhase.EnsureWslEnabled, LocalGatewaySetupPhase.ElevationCheck }),
|
||||
new("Onboarding_LocalSetup_Phase_CreateInstance", new[] { LocalGatewaySetupPhase.CreateWslInstance }),
|
||||
new("Onboarding_LocalSetup_Phase_Configure", new[] { LocalGatewaySetupPhase.ConfigureWslInstance }),
|
||||
new("Onboarding_LocalSetup_Phase_InstallCli", new[] { LocalGatewaySetupPhase.InstallOpenClawCli }),
|
||||
new("Onboarding_LocalSetup_Phase_PrepareConfig", new[] { LocalGatewaySetupPhase.PrepareGatewayConfig, LocalGatewaySetupPhase.InstallGatewayService }),
|
||||
new("Onboarding_LocalSetup_Phase_StartGateway", new[] { LocalGatewaySetupPhase.StartGateway, LocalGatewaySetupPhase.WaitForGateway }),
|
||||
new("Onboarding_LocalSetup_Phase_MintToken", new[] { LocalGatewaySetupPhase.MintBootstrapToken, LocalGatewaySetupPhase.PairOperator, LocalGatewaySetupPhase.CheckWindowsNodeReadiness, LocalGatewaySetupPhase.PairWindowsTrayNode, LocalGatewaySetupPhase.VerifyEndToEnd }),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Compute the visual state for a single visible stage given the current
|
||||
/// engine phase, status, and (when failed) the last running phase prior
|
||||
/// to failure (read from <see cref="LocalGatewaySetupState.History"/>).
|
||||
/// </summary>
|
||||
public static StageState ComputeStageState(
|
||||
LocalGatewaySetupPhase[] stagePhases,
|
||||
LocalGatewaySetupPhase currentPhase,
|
||||
LocalGatewaySetupStatus currentStatus,
|
||||
LocalGatewaySetupPhase lastRunningPhase)
|
||||
{
|
||||
if (currentStatus == LocalGatewaySetupStatus.Complete)
|
||||
return StageState.Complete;
|
||||
|
||||
var stageOrdinals = stagePhases.Select(p => (int)p).ToArray();
|
||||
var minOrdinalInStage = stageOrdinals.Min();
|
||||
var maxOrdinalInStage = stageOrdinals.Max();
|
||||
|
||||
if (currentStatus == LocalGatewaySetupStatus.FailedRetryable
|
||||
|| currentStatus == LocalGatewaySetupStatus.FailedTerminal
|
||||
|| currentPhase == LocalGatewaySetupPhase.Failed)
|
||||
{
|
||||
// Use the last running phase to pin the failure marker on the
|
||||
// stage where the engine actually broke.
|
||||
var lastOrdinal = (int)lastRunningPhase;
|
||||
if (lastOrdinal >= minOrdinalInStage && lastOrdinal <= maxOrdinalInStage)
|
||||
return StageState.Failed;
|
||||
if (lastOrdinal > maxOrdinalInStage)
|
||||
return StageState.Complete;
|
||||
return StageState.Pending;
|
||||
}
|
||||
|
||||
if (currentStatus == LocalGatewaySetupStatus.Cancelled)
|
||||
{
|
||||
var lastOrdinal = (int)lastRunningPhase;
|
||||
if (lastOrdinal > maxOrdinalInStage) return StageState.Complete;
|
||||
if (lastOrdinal >= minOrdinalInStage && lastOrdinal <= maxOrdinalInStage) return StageState.Pending;
|
||||
return StageState.Pending;
|
||||
}
|
||||
|
||||
var currentOrdinal = (int)currentPhase;
|
||||
if (currentOrdinal > maxOrdinalInStage)
|
||||
return StageState.Complete;
|
||||
if (currentOrdinal >= minOrdinalInStage && currentOrdinal <= maxOrdinalInStage)
|
||||
return StageState.Active;
|
||||
return StageState.Pending;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find the index of the visible stage that should be highlighted Active
|
||||
/// (or Failed) for the given engine phase. Returns -1 when no visible
|
||||
/// stage covers the phase (e.g. <see cref="LocalGatewaySetupPhase.NotStarted"/>
|
||||
/// or <see cref="LocalGatewaySetupPhase.Complete"/>).
|
||||
/// </summary>
|
||||
public static int IndexOfStageForPhase(LocalGatewaySetupPhase phase)
|
||||
{
|
||||
for (int i = 0; i < VisibleStages.Count; i++)
|
||||
{
|
||||
if (VisibleStages[i].Phases.Contains(phase))
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the page should render the inline error / retry row
|
||||
/// (FailedRetryable or FailedTerminal). All other statuses collapse it.
|
||||
/// </summary>
|
||||
public static bool ShouldShowErrorRow(LocalGatewaySetupStatus status)
|
||||
=> status == LocalGatewaySetupStatus.FailedRetryable
|
||||
|| status == LocalGatewaySetupStatus.FailedTerminal;
|
||||
|
||||
/// <summary>
|
||||
/// True when the inline error row should expose a Try Again button —
|
||||
/// only on FailedRetryable. FailedTerminal forces Back-out.
|
||||
/// </summary>
|
||||
public static bool ShouldShowRetryButton(LocalGatewaySetupStatus status)
|
||||
=> status == LocalGatewaySetupStatus.FailedRetryable;
|
||||
}
|
||||
@ -1,123 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using OpenClaw.Shared;
|
||||
using OpenClawTray.Services;
|
||||
using OpenClawTray.Services.LocalGatewaySetup;
|
||||
|
||||
namespace OpenClawTray.Onboarding.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Detects whether an existing OpenClaw configuration is present in tray settings,
|
||||
/// device identity, or setup-state storage.
|
||||
/// Used to gate the local easy-button setup flow so returning users receive an
|
||||
/// explicit warn-and-confirm dialog before potentially overwriting their credentials.
|
||||
/// </summary>
|
||||
public sealed class OnboardingExistingConfigGuard
|
||||
{
|
||||
private const string DefaultGatewayUrl = "ws://localhost:18789";
|
||||
private readonly SettingsManager _settings;
|
||||
private readonly string _identityDataPath;
|
||||
private readonly string _setupStatePath;
|
||||
|
||||
public OnboardingExistingConfigGuard(
|
||||
SettingsManager settings,
|
||||
string identityDataPath,
|
||||
string? setupStatePath = null)
|
||||
{
|
||||
_settings = settings;
|
||||
_identityDataPath = identityDataPath;
|
||||
_setupStatePath = setupStatePath ?? Path.Combine(
|
||||
Environment.GetEnvironmentVariable("OPENCLAW_TRAY_LOCALAPPDATA_DIR")
|
||||
?? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"OpenClawTray",
|
||||
"setup-state.json");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if any existing configuration is detected (sync, cheap).
|
||||
/// Checks in-memory settings, device-key-ed25519.json, and setup-state.json.
|
||||
/// Does NOT probe WSL distros (async-only path).
|
||||
/// </summary>
|
||||
public bool HasExistingConfiguration() => GetSummary().HasAny;
|
||||
|
||||
/// <summary>
|
||||
/// Returns a detailed breakdown of which configuration components exist.
|
||||
/// Sync — reads settings (in-memory), device-key files, and setup-state.json.
|
||||
/// </summary>
|
||||
public ExistingConfigurationSummary GetSummary()
|
||||
{
|
||||
return new ExistingConfigurationSummary(
|
||||
HasToken: !string.IsNullOrWhiteSpace(_settings.Token),
|
||||
HasBootstrapToken: !string.IsNullOrWhiteSpace(_settings.BootstrapToken),
|
||||
HasNonDefaultGatewayUrl: !string.IsNullOrWhiteSpace(_settings.GatewayUrl)
|
||||
&& !string.Equals(_settings.GatewayUrl, DefaultGatewayUrl, StringComparison.OrdinalIgnoreCase),
|
||||
HasOperatorDeviceToken: DeviceIdentity.HasStoredDeviceToken(_identityDataPath),
|
||||
HasNodeDeviceToken: DeviceIdentity.HasStoredDeviceTokenForRole(_identityDataPath, "node"),
|
||||
HasCompletedOrRunningSetupState: ReadSetupStateIsActive(_setupStatePath),
|
||||
HasWslDistro: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Async-enriched summary that also probes WSL for the OpenClawGateway distro.
|
||||
/// </summary>
|
||||
public async Task<ExistingConfigurationSummary> GetSummaryAsync(
|
||||
IWslCommandRunner? wsl = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sync = GetSummary();
|
||||
var hasDistro = false;
|
||||
if (wsl != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await wsl.RunAsync(["--list", "--verbose"], ct);
|
||||
hasDistro = result.StandardOutput.Contains("OpenClawGateway", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort — distro probe failure does not block the gate.
|
||||
}
|
||||
}
|
||||
return sync with { HasWslDistro = hasDistro };
|
||||
}
|
||||
|
||||
private static bool ReadSetupStateIsActive(string statePath)
|
||||
{
|
||||
if (!File.Exists(statePath))
|
||||
return false;
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(statePath);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.TryGetProperty("Phase", out var phaseEl))
|
||||
{
|
||||
var phaseName = phaseEl.GetString();
|
||||
// Active (returns true) if phase is NOT in the safe-to-restart set
|
||||
return phaseName is not (null or "NotStarted" or "Failed" or "Cancelled");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort — malformed state file does not block the gate.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Breakdown of which existing configuration components were found.
|
||||
/// </summary>
|
||||
public sealed record ExistingConfigurationSummary(
|
||||
bool HasToken,
|
||||
bool HasBootstrapToken,
|
||||
bool HasNonDefaultGatewayUrl,
|
||||
bool HasOperatorDeviceToken,
|
||||
bool HasNodeDeviceToken,
|
||||
bool HasCompletedOrRunningSetupState,
|
||||
bool HasWslDistro)
|
||||
{
|
||||
/// <summary>True if any configuration component exists.</summary>
|
||||
public bool HasAny =>
|
||||
HasToken || HasBootstrapToken || HasNonDefaultGatewayUrl
|
||||
|| HasOperatorDeviceToken || HasNodeDeviceToken
|
||||
|| HasCompletedOrRunningSetupState || HasWslDistro;
|
||||
}
|
||||
@ -16,7 +16,7 @@ public sealed class OnboardingState : IDisposable
|
||||
/// <summary>
|
||||
/// The currently displayed route. Updated by OnboardingApp on navigation.
|
||||
/// </summary>
|
||||
public OnboardingRoute CurrentRoute { get; set; } = OnboardingRoute.SetupWarning;
|
||||
public OnboardingRoute CurrentRoute { get; set; } = OnboardingRoute.Welcome;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the current route changes to or from the Chat page.
|
||||
@ -31,55 +31,6 @@ public sealed class OnboardingState : IDisposable
|
||||
/// </summary>
|
||||
public ConnectionMode Mode { get; set; } = ConnectionMode.Local;
|
||||
|
||||
/// <summary>
|
||||
/// Forked-onboarding setup path (Phase 5). Null until the user picks a path
|
||||
/// on <see cref="OnboardingRoute.SetupWarning"/>. While null, the nav-bar
|
||||
/// "Next" button is disabled on the SetupWarning page.
|
||||
/// </summary>
|
||||
public SetupPath? SetupPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Raised by pages that want to advance the OnboardingApp programmatically
|
||||
/// (e.g., the SetupWarning page's "Set up locally" / "Advanced setup" buttons,
|
||||
/// the LocalSetupProgress page on auto-advance after success).
|
||||
/// </summary>
|
||||
public event EventHandler? AdvanceRequested;
|
||||
|
||||
public void RequestAdvance()
|
||||
{
|
||||
var subs = AdvanceRequested?.GetInvocationList().Length ?? 0;
|
||||
OpenClawTray.Services.Logger.Info($"[OnboardingState] RequestAdvance invoked; subscriber count = {subs}");
|
||||
AdvanceRequested?.Invoke(this, EventArgs.Empty);
|
||||
OpenClawTray.Services.Logger.Info("[OnboardingState] AdvanceRequested invoked; returned");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-page nav-bar Next button state override. Pages that want fine-grained
|
||||
/// control over the nav-bar Next button (Hidden / Visible+Disabled /
|
||||
/// Visible+Enabled) push a value here and raise <see cref="NavBarStateChanged"/>;
|
||||
/// <see cref="OnboardingApp"/> consults this for routes that opt in (currently
|
||||
/// only <see cref="OnboardingRoute.LocalSetupProgress"/>) and falls back to its
|
||||
/// legacy logic everywhere else.
|
||||
/// </summary>
|
||||
public OnboardingNextButtonState NextButtonState { get; private set; } = OnboardingNextButtonState.Default;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when <see cref="NextButtonState"/> changes so <see cref="OnboardingApp"/>
|
||||
/// can re-render the nav bar.
|
||||
/// </summary>
|
||||
public event EventHandler? NavBarStateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Sets <see cref="NextButtonState"/> and raises <see cref="NavBarStateChanged"/>
|
||||
/// if the value actually changed.
|
||||
/// </summary>
|
||||
public void SetNextButtonState(OnboardingNextButtonState state)
|
||||
{
|
||||
if (NextButtonState == state) return;
|
||||
NextButtonState = state;
|
||||
NavBarStateChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the onboarding chat page should be shown.
|
||||
/// </summary>
|
||||
@ -111,69 +62,38 @@ public sealed class OnboardingState : IDisposable
|
||||
/// <summary>Wizard error message if in error state.</summary>
|
||||
public string? WizardError { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Guard that detects existing tray configuration.
|
||||
/// Set by <see cref="OnboardingWindow"/> after construction.
|
||||
/// Null when not available (startup auto-onboarding or env-override paths).
|
||||
/// </summary>
|
||||
public OnboardingExistingConfigGuard? ExistingConfigGuard { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Set to true by <see cref="SetupWarningPage"/> warn-and-confirm flow
|
||||
/// before advancing to the local setup path. Required by
|
||||
/// <see cref="LocalSetupProgressPage"/> defense-in-depth guard and the
|
||||
/// <see cref="LocalGatewaySetupEngineFactory"/> fail-closed check.
|
||||
/// </summary>
|
||||
public bool ReplaceExistingConfigurationConfirmed { get; set; }
|
||||
|
||||
public OnboardingState(SettingsManager settings)
|
||||
{
|
||||
Settings = settings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the page order for the forked Phase-5 onboarding flow.
|
||||
/// SetupWarning is page 0 in every flow; the user's choice on that page
|
||||
/// (<see cref="SetupPath"/>) determines whether page 1 is the local-setup
|
||||
/// progress page or the legacy advanced Connection page.
|
||||
/// Returns the page order based on the selected mode and chat preference,
|
||||
/// matching the macOS onboarding flow.
|
||||
/// </summary>
|
||||
public OnboardingRoute[] GetPageOrder()
|
||||
{
|
||||
// Treat null SetupPath as Local for page-count purposes; the nav-bar
|
||||
// Next button is disabled on SetupWarning until the user picks a path.
|
||||
var path = SetupPath ?? Onboarding.Services.SetupPath.Local;
|
||||
|
||||
// Node mode: skip Wizard and Chat — remote-node clients can't use operator RPCs.
|
||||
// Exception (Bug #1, manual test 2026-05-05): Local easy-setup pairs the tray
|
||||
// as BOTH operator (Phase 12) AND node (Phase 14) on the loopback gateway it
|
||||
// just stood up. Even though PairAsync flips EnableNodeMode=true mid-onboarding
|
||||
// (LocalGatewaySetup.cs:2147), the tray still has operator credentials and the
|
||||
// Wizard hop's wizard.start RPC works. Only skip Wizard for explicit Advanced
|
||||
// remote-node deployments.
|
||||
if (Settings.EnableNodeMode && path != Onboarding.Services.SetupPath.Local)
|
||||
// Node mode: skip Wizard and Chat — node clients can't use operator RPCs
|
||||
if (Settings.EnableNodeMode)
|
||||
{
|
||||
return [OnboardingRoute.SetupWarning, OnboardingRoute.Connection, OnboardingRoute.Permissions, OnboardingRoute.Ready];
|
||||
return Mode switch
|
||||
{
|
||||
ConnectionMode.Local or ConnectionMode.Wsl or ConnectionMode.Remote or ConnectionMode.Ssh =>
|
||||
[OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Permissions, OnboardingRoute.Ready],
|
||||
_ => // Later or unknown
|
||||
[OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Ready],
|
||||
};
|
||||
}
|
||||
|
||||
if (path == Onboarding.Services.SetupPath.Local)
|
||||
return (Mode, ShowChat) switch
|
||||
{
|
||||
// Local setup always runs the wizard locally after the gateway is up.
|
||||
// The WebView2 chat-preview step was removed per UX update (PR #274 follow-up):
|
||||
// post-Permissions we go straight to Ready, then optionally launch the Hub
|
||||
// chat tab from OnboardingWindow.OnWizardComplete based on whether the
|
||||
// wizard reached its "complete" lifecycle state (i.e. user picked a model).
|
||||
return [OnboardingRoute.SetupWarning, OnboardingRoute.LocalSetupProgress, OnboardingRoute.Wizard, OnboardingRoute.Permissions, OnboardingRoute.Ready];
|
||||
}
|
||||
|
||||
// Advanced path: keep the legacy ConnectionMode-aware ordering.
|
||||
// ShowChat (the in-wizard WebView2 chat preview) is intentionally not consulted
|
||||
// anymore — the preview step has been removed from every flow.
|
||||
return Mode switch
|
||||
{
|
||||
ConnectionMode.Local or ConnectionMode.Wsl or ConnectionMode.Ssh => [OnboardingRoute.SetupWarning, OnboardingRoute.Connection, OnboardingRoute.Wizard, OnboardingRoute.Permissions, OnboardingRoute.Ready],
|
||||
ConnectionMode.Remote => [OnboardingRoute.SetupWarning, OnboardingRoute.Connection, OnboardingRoute.Permissions, OnboardingRoute.Ready],
|
||||
ConnectionMode.Later => [OnboardingRoute.SetupWarning, OnboardingRoute.Connection, OnboardingRoute.Ready],
|
||||
_ => [OnboardingRoute.SetupWarning, OnboardingRoute.Connection, OnboardingRoute.Ready],
|
||||
// Local-style flows (Local, WSL, SSH tunnel) all run wizard locally
|
||||
(ConnectionMode.Local or ConnectionMode.Wsl or ConnectionMode.Ssh, true) => [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Wizard, OnboardingRoute.Permissions, OnboardingRoute.Chat, OnboardingRoute.Ready],
|
||||
(ConnectionMode.Local or ConnectionMode.Wsl or ConnectionMode.Ssh, false) => [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Wizard, OnboardingRoute.Permissions, OnboardingRoute.Ready],
|
||||
(ConnectionMode.Remote, true) => [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Permissions, OnboardingRoute.Chat, OnboardingRoute.Ready],
|
||||
(ConnectionMode.Remote, false) => [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Permissions, OnboardingRoute.Ready],
|
||||
(ConnectionMode.Later, _) => [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Ready],
|
||||
_ => [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Ready],
|
||||
};
|
||||
}
|
||||
|
||||
@ -210,41 +130,10 @@ public enum ConnectionMode
|
||||
|
||||
public enum OnboardingRoute
|
||||
{
|
||||
SetupWarning,
|
||||
LocalSetupProgress,
|
||||
Welcome,
|
||||
Connection,
|
||||
Wizard,
|
||||
Permissions,
|
||||
Chat,
|
||||
Ready,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forked-onboarding setup path picked on <see cref="OnboardingRoute.SetupWarning"/>.
|
||||
/// </summary>
|
||||
public enum SetupPath
|
||||
{
|
||||
/// <summary>User chose "Set up locally" — run the WSL gateway setup engine.</summary>
|
||||
Local,
|
||||
/// <summary>User chose "Advanced setup" — fall through to the legacy ConnectionPage.</summary>
|
||||
Advanced,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-page nav-bar Next button state override (Phase 5 final). Pages set this on
|
||||
/// <see cref="OnboardingState.SetNextButtonState"/> to opt out of the default
|
||||
/// "always visible+enabled (Disabled only on SetupWarning until path chosen)"
|
||||
/// behavior. <see cref="OnboardingApp"/> consults this for routes that opt in
|
||||
/// (currently only <see cref="OnboardingRoute.LocalSetupProgress"/>).
|
||||
/// </summary>
|
||||
public enum OnboardingNextButtonState
|
||||
{
|
||||
/// <summary>Use legacy nav-bar logic — visible+enabled unless route-specific defaults apply.</summary>
|
||||
Default,
|
||||
/// <summary>Next button collapsed entirely (e.g., LocalSetupProgress Idle state).</summary>
|
||||
Hidden,
|
||||
/// <summary>Next button visible but disabled (e.g., LocalSetupProgress Running / FailedRetryable / FailedTerminal).</summary>
|
||||
VisibleDisabled,
|
||||
/// <summary>Next button visible and enabled (e.g., LocalSetupProgress Complete during the 1s pre-auto-advance window).</summary>
|
||||
VisibleEnabled,
|
||||
}
|
||||
|
||||
@ -6,7 +6,8 @@ using OpenClaw.Shared;
|
||||
namespace OpenClawTray.Onboarding.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Decodes upstream OpenClaw setup codes into gateway URL and bootstrap token fields.
|
||||
/// Decodes base64url-encoded setup codes into gateway URL and token.
|
||||
/// Extracted from ConnectionPage for testability.
|
||||
/// </summary>
|
||||
public static class SetupCodeDecoder
|
||||
{
|
||||
@ -23,10 +24,10 @@ public static class SetupCodeDecoder
|
||||
string json;
|
||||
try
|
||||
{
|
||||
// Base64url decode: replace URL-safe chars, add padding
|
||||
var b64 = setupCode.Trim().Replace('-', '+').Replace('_', '/');
|
||||
var pad = b64.Length % 4;
|
||||
if (pad > 0)
|
||||
b64 += new string('=', 4 - pad);
|
||||
if (pad > 0) b64 += new string('=', 4 - pad);
|
||||
|
||||
json = Encoding.UTF8.GetString(Convert.FromBase64String(b64));
|
||||
}
|
||||
@ -40,17 +41,12 @@ public static class SetupCodeDecoder
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object)
|
||||
return new DecodeResult(false, Error: "Setup code JSON must be an object");
|
||||
|
||||
var doc = JsonDocument.Parse(json);
|
||||
string? url = null;
|
||||
string? token = null;
|
||||
|
||||
if (doc.RootElement.TryGetProperty("url", out var urlProp))
|
||||
{
|
||||
if (urlProp.ValueKind != JsonValueKind.String)
|
||||
return new DecodeResult(false, Error: "Invalid gateway URL in setup code");
|
||||
var decoded = urlProp.GetString() ?? "";
|
||||
if (!string.IsNullOrEmpty(decoded))
|
||||
{
|
||||
@ -62,18 +58,12 @@ public static class SetupCodeDecoder
|
||||
|
||||
if (doc.RootElement.TryGetProperty("bootstrapToken", out var tokenProp))
|
||||
{
|
||||
if (tokenProp.ValueKind != JsonValueKind.String)
|
||||
return new DecodeResult(false, Error: "Invalid bootstrap token in setup code");
|
||||
var decoded = tokenProp.GetString() ?? "";
|
||||
if (decoded.Length > 512)
|
||||
return new DecodeResult(false, Error: "Bootstrap token exceeds 512 character limit");
|
||||
if (!string.IsNullOrEmpty(decoded))
|
||||
if (decoded.Length <= 512)
|
||||
token = decoded;
|
||||
// Token exceeding 512 chars is silently ignored (not set)
|
||||
}
|
||||
|
||||
if (url == null && token == null)
|
||||
return new DecodeResult(false, Error: "Setup code must include a gateway URL or bootstrap token");
|
||||
|
||||
return new DecodeResult(true, Url: url, Token: token);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
@ -1,215 +0,0 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using OpenClaw.Shared;
|
||||
using OpenClawTray.Services;
|
||||
|
||||
namespace OpenClawTray.Onboarding.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Testable, UI-free recovery rules for gateway-backed onboarding wizard flows.
|
||||
/// </summary>
|
||||
public interface IWizardGateway
|
||||
{
|
||||
bool IsConnectedToGateway { get; }
|
||||
event EventHandler<ConnectionStatus>? StatusChanged;
|
||||
Task<JsonElement> SendWizardRequestAsync(string method, object? parameters = null, int timeoutMs = 30000);
|
||||
}
|
||||
|
||||
public sealed class OpenClawWizardGatewayAdapter : IWizardGateway
|
||||
{
|
||||
private readonly OpenClawGatewayClient _client;
|
||||
|
||||
public OpenClawWizardGatewayAdapter(OpenClawGatewayClient client)
|
||||
{
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
|
||||
public bool IsConnectedToGateway => _client.IsConnectedToGateway;
|
||||
|
||||
public event EventHandler<ConnectionStatus>? StatusChanged
|
||||
{
|
||||
add => _client.StatusChanged += value;
|
||||
remove => _client.StatusChanged -= value;
|
||||
}
|
||||
|
||||
public Task<JsonElement> SendWizardRequestAsync(string method, object? parameters = null, int timeoutMs = 30000) =>
|
||||
_client.SendWizardRequestAsync(method, parameters, timeoutMs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mutable recovery guard stored by reference in FunctionalUI state. Do not replace this
|
||||
/// with UseState<bool>: render closures must observe current fields synchronously.
|
||||
/// </summary>
|
||||
public sealed class WizardRecoveryGuardState
|
||||
{
|
||||
private int _restartAttempted;
|
||||
private long _connectionLossEpoch;
|
||||
|
||||
public bool HasRestartedForCurrentLostSession => Volatile.Read(ref _restartAttempted) != 0;
|
||||
public long ConnectionLossEpoch => Interlocked.Read(ref _connectionLossEpoch);
|
||||
|
||||
public void ObserveConnectionStatus(ConnectionStatus status)
|
||||
{
|
||||
if (status is ConnectionStatus.Disconnected or ConnectionStatus.Connecting or ConnectionStatus.Error)
|
||||
{
|
||||
Interlocked.Increment(ref _connectionLossEpoch);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryMarkRestartAttempted() => Interlocked.CompareExchange(ref _restartAttempted, 1, 0) == 0;
|
||||
|
||||
public void ResetAfterSuccessfulStart() => Volatile.Write(ref _restartAttempted, 0);
|
||||
|
||||
public void ResetForManualRestart() => Volatile.Write(ref _restartAttempted, 0);
|
||||
}
|
||||
|
||||
public readonly record struct WizardRequestContext(long ConnectionLossEpoch);
|
||||
|
||||
public enum WizardRecoveryKind
|
||||
{
|
||||
NotEligible,
|
||||
AlreadyAttempted,
|
||||
Recovered,
|
||||
Failed
|
||||
}
|
||||
|
||||
public sealed record WizardRecoveryResult(WizardRecoveryKind Kind, JsonElement? Payload = null, Exception? Exception = null)
|
||||
{
|
||||
public static WizardRecoveryResult NotEligible { get; } = new(WizardRecoveryKind.NotEligible);
|
||||
public static WizardRecoveryResult AlreadyAttempted { get; } = new(WizardRecoveryKind.AlreadyAttempted);
|
||||
public static WizardRecoveryResult Recovered(JsonElement payload) => new(WizardRecoveryKind.Recovered, payload);
|
||||
public static WizardRecoveryResult Failed(Exception exception) => new(WizardRecoveryKind.Failed, null, exception);
|
||||
}
|
||||
|
||||
public static class WizardFlowController
|
||||
{
|
||||
public const string RecoveryFailureMessage = "Setup couldn't continue. Restart wizard to try again.";
|
||||
public const string SlowStepRetryMessage = "Setup is taking longer than expected. Retry?";
|
||||
|
||||
public static WizardRequestContext CaptureRequestContext(WizardRecoveryGuardState guard) =>
|
||||
new(guard.ConnectionLossEpoch);
|
||||
|
||||
public static bool IsStartPayload(JsonElement payload) =>
|
||||
payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty("sessionId", out _);
|
||||
|
||||
public static bool ShouldRecover(Exception exception, IWizardGateway? client, WizardRecoveryGuardState guard, WizardRequestContext requestContext)
|
||||
{
|
||||
if (exception is OperationCanceledException)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (exception is InvalidOperationException invalidOperation)
|
||||
{
|
||||
return invalidOperation.Message.Contains("wizard not found", StringComparison.OrdinalIgnoreCase)
|
||||
|| invalidOperation.Message.Contains("wizard not running", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (exception is TimeoutException)
|
||||
{
|
||||
return client?.IsConnectedToGateway != true
|
||||
|| guard.ConnectionLossEpoch != requestContext.ConnectionLossEpoch;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static async Task<JsonElement> RestartWizardAsync(
|
||||
WizardRecoveryGuardState guard,
|
||||
Action clearWizardSessionState,
|
||||
Func<Task<JsonElement>> startWizardAsync)
|
||||
{
|
||||
guard.ResetForManualRestart();
|
||||
clearWizardSessionState();
|
||||
return await startWizardAsync();
|
||||
}
|
||||
|
||||
public static async Task<WizardRecoveryResult> TryRecoverAsync(
|
||||
Exception exception,
|
||||
IWizardGateway? client,
|
||||
WizardRecoveryGuardState guard,
|
||||
WizardRequestContext requestContext,
|
||||
Func<Task<JsonElement>> startWizardAsync)
|
||||
{
|
||||
if (!ShouldRecover(exception, client, guard, requestContext))
|
||||
{
|
||||
return WizardRecoveryResult.NotEligible;
|
||||
}
|
||||
|
||||
if (!guard.TryMarkRestartAttempted())
|
||||
{
|
||||
return WizardRecoveryResult.AlreadyAttempted;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var payload = await startWizardAsync();
|
||||
return WizardRecoveryResult.Recovered(payload);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return WizardRecoveryResult.Failed(ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits up to <paramref name="maxPollCount"/> poll intervals for the gateway to
|
||||
/// (re-)connect. Returns true if connected at exit, false on timeout. The
|
||||
/// <paramref name="delayAsync"/> delegate is injected so unit tests can run instantly.
|
||||
/// Pass <paramref name="cancellationToken"/> to abort polling early (e.g., on app shutdown
|
||||
/// or page navigation away); throws <see cref="OperationCanceledException"/> if cancelled.
|
||||
/// </summary>
|
||||
public static async Task<bool> WaitForConnectionAsync(
|
||||
IWizardGateway? client,
|
||||
int maxPollCount = 30,
|
||||
Func<Task>? delayAsync = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
delayAsync ??= () => Task.Delay(1000, cancellationToken);
|
||||
for (int poll = 0; poll < maxPollCount && client?.IsConnectedToGateway != true; poll++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await delayAsync();
|
||||
}
|
||||
return client?.IsConnectedToGateway == true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to resume a live wizard session via wizard.next (no answer) before
|
||||
/// falling back to wizard.start. Caller must NOT clear WizardSessionId before calling.
|
||||
/// Call <see cref="WaitForConnectionAsync"/> first so IsConnectedToGateway is true
|
||||
/// when this method runs.
|
||||
/// </summary>
|
||||
public static async Task<(bool Resumed, JsonElement Payload)> TryResumeWithSessionAsync(
|
||||
string? sessionId,
|
||||
IWizardGateway? client,
|
||||
Func<string, Task<JsonElement>> sendWizardNextNoAnswerAsync,
|
||||
Func<Task<JsonElement>> fallbackStartWizardAsync)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(sessionId) && client?.IsConnectedToGateway == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
Logger.Info($"[WizardFlow] TryResume: wizard.next(no answer) sessionId={sessionId}");
|
||||
var stepPayload = await sendWizardNextNoAnswerAsync(sessionId);
|
||||
Logger.Info("[WizardFlow] TryResume: resume succeeded");
|
||||
return (true, stepPayload);
|
||||
}
|
||||
catch (InvalidOperationException ex) when (
|
||||
ex.Message.Contains("wizard not found", StringComparison.OrdinalIgnoreCase) ||
|
||||
ex.Message.Contains("wizard not running", StringComparison.OrdinalIgnoreCase) ||
|
||||
ex.Message.Contains("session not found", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Logger.Warn($"[WizardFlow] TryResume: session not found ({ex.Message}) → fallback wizard.start");
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Logger.Warn($"[WizardFlow] TryResume: unexpected error ({ex.GetType().Name}: {ex.Message}) → fallback wizard.start");
|
||||
}
|
||||
}
|
||||
var startPayload = await fallbackStartWizardAsync();
|
||||
return (false, startPayload);
|
||||
}
|
||||
}
|
||||
@ -1,49 +0,0 @@
|
||||
namespace OpenClawTray.Onboarding.Services;
|
||||
|
||||
public static class WizardStepSelection
|
||||
{
|
||||
public static bool RequiresSelection(string stepType) => stepType is "select" or "multiselect";
|
||||
|
||||
public static int SelectedIndex(string stepInput, IReadOnlyList<string> optionValues)
|
||||
{
|
||||
for (var i = 0; i < optionValues.Count; i++)
|
||||
{
|
||||
if (optionValues[i] == stepInput)
|
||||
return i;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
public static bool HasValidSelection(string stepType, string stepInput, IReadOnlyCollection<string> optionValues)
|
||||
{
|
||||
if (stepType == "select")
|
||||
return optionValues.Contains(stepInput);
|
||||
|
||||
if (stepType == "multiselect")
|
||||
{
|
||||
var selected = SplitMultiSelectValues(stepInput);
|
||||
return selected.Length > 0 && selected.All(optionValues.Contains);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool ShouldDisableContinue(string stepType, string stepInput, IReadOnlyCollection<string> optionValues) =>
|
||||
RequiresSelection(stepType) && !HasValidSelection(stepType, stepInput, optionValues);
|
||||
|
||||
public static bool TryBuildAnswerValue(string stepType, string stepInput, IReadOnlyCollection<string> optionValues, out string answerValue)
|
||||
{
|
||||
if (RequiresSelection(stepType) && !HasValidSelection(stepType, stepInput, optionValues))
|
||||
{
|
||||
answerValue = "";
|
||||
return false;
|
||||
}
|
||||
|
||||
answerValue = string.IsNullOrEmpty(stepInput) ? "true" : stepInput;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string[] SplitMultiSelectValues(string stepInput) =>
|
||||
stepInput.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
}
|
||||
32
src/OpenClaw.Tray.WinUI/Onboarding/Widgets/FeatureRow.cs
Normal file
32
src/OpenClaw.Tray.WinUI/Onboarding/Widgets/FeatureRow.cs
Normal file
@ -0,0 +1,32 @@
|
||||
using OpenClawTray.FunctionalUI;
|
||||
using OpenClawTray.FunctionalUI.Core;
|
||||
using static OpenClawTray.FunctionalUI.Factories;
|
||||
using Microsoft.UI.Xaml;
|
||||
|
||||
namespace OpenClawTray.Onboarding.Widgets;
|
||||
|
||||
public record FeatureRowProps(string Icon, string Title, string Subtitle);
|
||||
|
||||
/// <summary>
|
||||
/// Icon + title + subtitle row for the Ready page.
|
||||
/// </summary>
|
||||
public sealed class FeatureRow : Component<FeatureRowProps>
|
||||
{
|
||||
public override Element Render()
|
||||
{
|
||||
return HStack(12,
|
||||
TextBlock(Props.Icon)
|
||||
.FontSize(20)
|
||||
.Width(28)
|
||||
.HAlign(HorizontalAlignment.Center),
|
||||
VStack(2,
|
||||
TextBlock(Props.Title)
|
||||
.FontSize(14)
|
||||
.FontWeight(new global::Windows.UI.Text.FontWeight(600)),
|
||||
TextBlock(Props.Subtitle)
|
||||
.FontSize(12)
|
||||
.Opacity(0.7)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
22
src/OpenClaw.Tray.WinUI/Onboarding/Widgets/OnboardingCard.cs
Normal file
22
src/OpenClaw.Tray.WinUI/Onboarding/Widgets/OnboardingCard.cs
Normal file
@ -0,0 +1,22 @@
|
||||
using OpenClawTray.FunctionalUI;
|
||||
using OpenClawTray.FunctionalUI.Core;
|
||||
using static OpenClawTray.FunctionalUI.Factories;
|
||||
|
||||
namespace OpenClawTray.Onboarding.Widgets;
|
||||
|
||||
/// <summary>
|
||||
/// Reusable theme-aware card with rounded corners and padding.
|
||||
/// Props: the child <see cref="Element"/> to render inside the card.
|
||||
/// </summary>
|
||||
public sealed class OnboardingCard : Component<Element>
|
||||
{
|
||||
public override Element Render()
|
||||
{
|
||||
return Border(
|
||||
Props
|
||||
)
|
||||
.CornerRadius(12)
|
||||
.BackgroundResource("CardBackgroundFillColorDefaultBrush")
|
||||
.Padding(20, 20, 20, 20);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
using System;
|
||||
|
||||
namespace OpenClawTray.Onboarding.Widgets;
|
||||
|
||||
public enum WizardStepType
|
||||
{
|
||||
Note,
|
||||
Text,
|
||||
Confirm,
|
||||
Select,
|
||||
MultiSelect,
|
||||
Progress,
|
||||
Action,
|
||||
}
|
||||
|
||||
public record WizardStepProps(
|
||||
string Id,
|
||||
string Title,
|
||||
string Message,
|
||||
WizardStepType Type,
|
||||
string[]? Options = null,
|
||||
string? InitialValue = null,
|
||||
string? Placeholder = null,
|
||||
bool Sensitive = false,
|
||||
Action<string>? OnSubmit = null
|
||||
);
|
||||
166
src/OpenClaw.Tray.WinUI/Onboarding/Widgets/WizardStepView.cs
Normal file
166
src/OpenClaw.Tray.WinUI/Onboarding/Widgets/WizardStepView.cs
Normal file
@ -0,0 +1,166 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenClawTray.FunctionalUI;
|
||||
using OpenClawTray.FunctionalUI.Core;
|
||||
using static OpenClawTray.FunctionalUI.Factories;
|
||||
using Microsoft.UI.Xaml;
|
||||
|
||||
namespace OpenClawTray.Onboarding.Widgets;
|
||||
|
||||
/// <summary>
|
||||
/// Dynamic wizard step renderer that adapts UI based on <see cref="WizardStepType"/>.
|
||||
/// Used by the onboarding wizard to render steps received from the gateway RPC protocol.
|
||||
/// </summary>
|
||||
public sealed class WizardStepView : Component<WizardStepProps>
|
||||
{
|
||||
public override Element Render()
|
||||
{
|
||||
var body = Props.Type switch
|
||||
{
|
||||
WizardStepType.Note => RenderNote(),
|
||||
WizardStepType.Text => RenderText(),
|
||||
WizardStepType.Confirm => RenderConfirm(),
|
||||
WizardStepType.Select => RenderSelect(),
|
||||
WizardStepType.MultiSelect => RenderMultiSelect(),
|
||||
WizardStepType.Progress => RenderProgress(),
|
||||
WizardStepType.Action => RenderAction(),
|
||||
_ => RenderNote(),
|
||||
};
|
||||
|
||||
return body
|
||||
.HAlign(HorizontalAlignment.Center)
|
||||
.MaxWidth(460);
|
||||
}
|
||||
|
||||
private Element Header() =>
|
||||
VStack(8,
|
||||
TextBlock(Props.Title)
|
||||
.FontSize(20)
|
||||
.FontWeight(new global::Windows.UI.Text.FontWeight(700))
|
||||
.HAlign(HorizontalAlignment.Center),
|
||||
TextBlock(Props.Message)
|
||||
.FontSize(14)
|
||||
.Opacity(0.7)
|
||||
.HAlign(HorizontalAlignment.Center)
|
||||
.TextWrapping()
|
||||
);
|
||||
|
||||
private Element RenderNote() =>
|
||||
VStack(16, Header());
|
||||
|
||||
private Element RenderText()
|
||||
{
|
||||
var (value, setValue) = UseState(Props.InitialValue ?? "");
|
||||
|
||||
Element input = Props.Sensitive
|
||||
? PasswordBox(value, v => setValue(v), placeholderText: Props.Placeholder)
|
||||
: TextField(value, v => setValue(v), placeholder: Props.Placeholder, header: null);
|
||||
|
||||
return VStack(16,
|
||||
Header(),
|
||||
Border(
|
||||
VStack(12, input).Padding(16)
|
||||
).CornerRadius(8).BackgroundResource("CardBackgroundFillColorDefaultBrush"),
|
||||
Button("Submit", () => Props.OnSubmit?.Invoke(value))
|
||||
.HAlign(HorizontalAlignment.Center)
|
||||
.Disabled(string.IsNullOrWhiteSpace(value))
|
||||
);
|
||||
}
|
||||
|
||||
private Element RenderConfirm()
|
||||
{
|
||||
return VStack(16,
|
||||
Header(),
|
||||
HStack(12,
|
||||
Button("Yes", () => Props.OnSubmit?.Invoke("Yes")),
|
||||
Button("No", () => Props.OnSubmit?.Invoke("No"))
|
||||
).HAlign(HorizontalAlignment.Center)
|
||||
);
|
||||
}
|
||||
|
||||
private Element RenderSelect()
|
||||
{
|
||||
var options = Props.Options ?? [];
|
||||
var initialIndex = Props.InitialValue != null
|
||||
? Array.IndexOf(options, Props.InitialValue)
|
||||
: -1;
|
||||
var (selected, setSelected) = UseState(initialIndex);
|
||||
|
||||
return VStack(16,
|
||||
Header(),
|
||||
Border(
|
||||
VStack(4,
|
||||
options.Select((opt, i) =>
|
||||
RadioButton(opt, selected == i, _ => setSelected(i), groupName: Props.Id)
|
||||
).ToArray()
|
||||
).Padding(16)
|
||||
).CornerRadius(8).BackgroundResource("CardBackgroundFillColorDefaultBrush"),
|
||||
Button("Submit", () =>
|
||||
{
|
||||
if (selected >= 0 && selected < options.Length)
|
||||
Props.OnSubmit?.Invoke(options[selected]);
|
||||
})
|
||||
.HAlign(HorizontalAlignment.Center)
|
||||
.Disabled(selected < 0)
|
||||
);
|
||||
}
|
||||
|
||||
private Element RenderMultiSelect()
|
||||
{
|
||||
var options = Props.Options ?? [];
|
||||
var (selections, setSelections) = UseState(new HashSet<int>());
|
||||
|
||||
var toggles = options.Select((opt, i) =>
|
||||
{
|
||||
var isChecked = selections.Contains(i);
|
||||
return HStack(8,
|
||||
CheckBox(isChecked, _ =>
|
||||
{
|
||||
var next = new HashSet<int>(selections);
|
||||
if (isChecked) next.Remove(i); else next.Add(i);
|
||||
setSelections(next);
|
||||
}),
|
||||
TextBlock(opt).FontSize(13)
|
||||
.VAlign(Microsoft.UI.Xaml.VerticalAlignment.Center)
|
||||
);
|
||||
}).ToArray();
|
||||
|
||||
return VStack(16,
|
||||
Header(),
|
||||
Border(
|
||||
VStack(6, toggles).Padding(16)
|
||||
).CornerRadius(8).BackgroundResource("CardBackgroundFillColorDefaultBrush"),
|
||||
Button("Submit", () =>
|
||||
{
|
||||
var chosen = selections
|
||||
.Where(i => i >= 0 && i < options.Length)
|
||||
.OrderBy(i => i)
|
||||
.Select(i => options[i]);
|
||||
Props.OnSubmit?.Invoke(string.Join(",", chosen));
|
||||
})
|
||||
.HAlign(HorizontalAlignment.Center)
|
||||
.Disabled(selections.Count == 0)
|
||||
);
|
||||
}
|
||||
|
||||
private Element RenderProgress()
|
||||
{
|
||||
return VStack(16,
|
||||
Header(),
|
||||
TextBlock("⏳ Processing…")
|
||||
.FontSize(14)
|
||||
.Opacity(0.6)
|
||||
.HAlign(HorizontalAlignment.Center)
|
||||
);
|
||||
}
|
||||
|
||||
private Element RenderAction()
|
||||
{
|
||||
return VStack(16,
|
||||
Header(),
|
||||
Button(Props.InitialValue ?? "Run", () => Props.OnSubmit?.Invoke("action"))
|
||||
.HAlign(HorizontalAlignment.Center)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -49,21 +49,17 @@
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="OpenClaw.Tray.UITests" />
|
||||
<InternalsVisibleTo Include="OpenClaw.Tray.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.0.1" />
|
||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1839" />
|
||||
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260101001" />
|
||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.4654" />
|
||||
<PackageReference Include="WinUIEx" Version="2.9.0" />
|
||||
<PackageReference Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.3" />
|
||||
<PackageReference Include="System.Diagnostics.PerformanceCounter" Version="10.0.0" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.7" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.0" />
|
||||
<PackageReference Include="Updatum" Version="1.3.4" />
|
||||
<PackageReference Include="NAudio.Wasapi" Version="2.3.0" />
|
||||
<PackageReference Include="org.k2fsa.sherpa.onnx" Version="1.13.0" />
|
||||
<PackageReference Include="Zeroconf" Version="3.6.11" />
|
||||
<PackageReference Include="ZXing.Net" Version="0.16.10" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@ -1,103 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Page
|
||||
x:Class="OpenClawTray.Pages.AboutPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Padding="24" Spacing="16" HorizontalAlignment="Stretch">
|
||||
|
||||
<TextBlock x:Uid="AboutPage_TextBlock_10" Text="About" Style="{StaticResource TitleTextBlockStyle}"/>
|
||||
|
||||
<!-- App Info Card -->
|
||||
<Border Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
|
||||
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||
BorderThickness="1" CornerRadius="8" Padding="16">
|
||||
<StackPanel Orientation="Horizontal" Spacing="16">
|
||||
<TextBlock Text="🦞" FontSize="48" VerticalAlignment="Center"/>
|
||||
<StackPanel Spacing="4" VerticalAlignment="Center">
|
||||
<TextBlock x:Uid="AboutPage_TextBlock_19" Text="OpenClaw Hub" Style="{StaticResource SubtitleTextBlockStyle}"/>
|
||||
<TextBlock x:Uid="VersionText" x:Name="VersionText" Text="v0.1.0"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
|
||||
<TextBlock x:Uid="AboutPage_TextBlock_23" Text=".NET 10 / WinUI 3 / WinAppSDK 1.8"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Gateway Info Card -->
|
||||
<Border Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
|
||||
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||
BorderThickness="1" CornerRadius="8" Padding="16">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock x:Uid="AboutPage_TextBlock_35" Text="Gateway Info" Style="{StaticResource BodyStrongTextBlockStyle}"/>
|
||||
<Grid ColumnSpacing="12" RowSpacing="4">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock x:Uid="AboutPage_TextBlock_48" Grid.Row="0" Grid.Column="0" Text="Version:"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
|
||||
<TextBlock x:Name="GatewayVersionText" Grid.Row="0" Grid.Column="1" Text="—"/>
|
||||
|
||||
<TextBlock x:Uid="AboutPage_TextBlock_52" Grid.Row="1" Grid.Column="0" Text="Model:"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
|
||||
<TextBlock x:Name="GatewayModelText" Grid.Row="1" Grid.Column="1" Text="—"/>
|
||||
|
||||
<TextBlock x:Uid="AboutPage_TextBlock_56" Grid.Row="2" Grid.Column="0" Text="Auth mode:"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
|
||||
<TextBlock x:Name="GatewayAuthText" Grid.Row="2" Grid.Column="1" Text="—"/>
|
||||
|
||||
<TextBlock x:Uid="AboutPage_TextBlock_60" Grid.Row="3" Grid.Column="0" Text="Uptime:"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
|
||||
<TextBlock x:Name="GatewayUptimeText" Grid.Row="3" Grid.Column="1" Text="—"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Debug Section -->
|
||||
<Border Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
|
||||
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||
BorderThickness="1" CornerRadius="8" Padding="16">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock x:Uid="AboutPage_TextBlock_72" Text="Debug" Style="{StaticResource BodyStrongTextBlockStyle}"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button x:Uid="OpenLogButton" x:Name="OpenLogButton" Content="Open Log File"
|
||||
Click="OnOpenLogClick"/>
|
||||
<Button x:Uid="OpenConfigButton" x:Name="OpenConfigButton" Content="Open Config Folder"
|
||||
Click="OnOpenConfigClick"/>
|
||||
<Button x:Uid="CopySupportButton" x:Name="CopySupportButton" Content="Copy Support Context"
|
||||
Click="OnCopySupportClick"/>
|
||||
<Button x:Uid="CheckUpdatesButton" x:Name="CheckUpdatesButton" Content="Check for Updates"
|
||||
Click="OnCheckUpdatesClick"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Links Section -->
|
||||
<Border Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
|
||||
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||
BorderThickness="1" CornerRadius="8" Padding="16">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock x:Uid="AboutPage_TextBlock_91" Text="Links" Style="{StaticResource BodyStrongTextBlockStyle}"/>
|
||||
<HyperlinkButton x:Uid="AboutPage_HyperlinkButton_92" Content="Documentation → openclaw.ai/docs"
|
||||
Click="OnDocumentationClick"/>
|
||||
<HyperlinkButton x:Uid="AboutPage_HyperlinkButton_94" Content="GitHub → github.com/openclaw/openclaw-windows-node"
|
||||
Click="OnGitHubClick"/>
|
||||
<HyperlinkButton x:Uid="AboutPage_HyperlinkButton_96" Content="Dashboard → openclaw://dashboard"
|
||||
Click="OnDashboardClick"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Page>
|
||||
@ -1,129 +0,0 @@
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using OpenClawTray.Windows;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using WinDataTransfer = global::Windows.ApplicationModel.DataTransfer;
|
||||
|
||||
namespace OpenClawTray.Pages;
|
||||
|
||||
public sealed partial class AboutPage : Page
|
||||
{
|
||||
private HubWindow? _hub;
|
||||
|
||||
public AboutPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public void Initialize(HubWindow hub)
|
||||
{
|
||||
_hub = hub;
|
||||
TryLoadGatewayInfo();
|
||||
}
|
||||
|
||||
public void RefreshGatewayInfo() => TryLoadGatewayInfo();
|
||||
|
||||
private void TryLoadGatewayInfo()
|
||||
{
|
||||
var self = _hub?.LastGatewaySelf;
|
||||
if (_hub?.CurrentStatus == OpenClaw.Shared.ConnectionStatus.Connected && self != null)
|
||||
{
|
||||
GatewayVersionText.Text = self.VersionText;
|
||||
GatewayModelText.Text = self.Protocol.HasValue ? $"protocol v{self.Protocol}" : "unknown";
|
||||
GatewayAuthText.Text = string.IsNullOrWhiteSpace(self.AuthMode) ? "unknown" : self.AuthMode;
|
||||
GatewayUptimeText.Text = self.UptimeText;
|
||||
}
|
||||
else
|
||||
{
|
||||
GatewayVersionText.Text = "—";
|
||||
GatewayModelText.Text = "—";
|
||||
GatewayAuthText.Text = "—";
|
||||
GatewayUptimeText.Text = "—";
|
||||
}
|
||||
}
|
||||
|
||||
private void OnOpenLogClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var logPath = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"OpenClawTray", "openclaw-tray.log");
|
||||
Process.Start(new ProcessStartInfo(logPath) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Failed to open log file: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnOpenConfigClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var configPath = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"OpenClawTray");
|
||||
Process.Start(new ProcessStartInfo(configPath) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Failed to open config folder: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnCopySupportClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var context = $"OpenClaw Hub v0.1.0\n"
|
||||
+ $"OS: {Environment.OSVersion}\n"
|
||||
+ $"Runtime: {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}\n"
|
||||
+ $"Connection: {_hub?.CurrentStatus}\n"
|
||||
+ $"Gateway: {_hub?.Settings?.GetEffectiveGatewayUrl() ?? "n/a"}\n";
|
||||
|
||||
var dataPackage = new WinDataTransfer.DataPackage();
|
||||
dataPackage.SetText(context);
|
||||
WinDataTransfer.Clipboard.SetContent(dataPackage);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Failed to copy support context: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCheckUpdatesClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_hub?.CheckForUpdatesAction?.Invoke();
|
||||
}
|
||||
|
||||
private void OnDocumentationClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo("https://openclaw.ai/docs") { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Failed to open docs: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnGitHubClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo("https://github.com/openclaw/openclaw-windows-node") { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Failed to open GitHub: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDashboardClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_hub?.OpenDashboardAction?.Invoke(null);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user