Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2dcc3b372 | ||
|
|
602cc9ee28 | ||
|
|
6dbd5cbe07 | ||
|
|
a56caf46f6 | ||
|
|
05025480ad | ||
|
|
3cf9958cb9 | ||
|
|
6b70a33080 | ||
|
|
8d23692320 | ||
|
|
33d3947da6 | ||
|
|
806163b8ba | ||
|
|
13c2a18286 | ||
|
|
b8f10951ea | ||
|
|
2fcf0879c6 | ||
|
|
1136b4186d | ||
|
|
cca73ad52f | ||
|
|
424bf36c03 | ||
|
|
9eb00da0c4 | ||
|
|
9269e81997 | ||
|
|
6642820aa0 | ||
|
|
8374c6b1c7 | ||
|
|
c8a3251834 | ||
|
|
2418f56273 | ||
|
|
297582941f | ||
|
|
958a61cbc4 | ||
|
|
3096212919 | ||
|
|
aa2b091966 | ||
|
|
1a9cb9095e | ||
|
|
0312159168 | ||
|
|
e204a90398 |
@@ -0,0 +1,156 @@
|
|||||||
|
# V2R1 Gate 1 — Clean-Room Final Evidence
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Date | 2026-07-14 |
|
||||||
|
| Source commit | `502ef30642597fb0b70b90ae2e8a0b6af217d80e` |
|
||||||
|
| Source branch | `origin/work/v2r1-integration-quality-2000` |
|
||||||
|
| Result branch | `fix/v2r1-g1-cleanroom-final-evidence-r2-20260714` |
|
||||||
|
| Result commit reference | `HEAD` |
|
||||||
|
| Gate | `G1` |
|
||||||
|
| Variant | `openclaw-cleanroom` |
|
||||||
|
| Status | `PASS` |
|
||||||
|
|
||||||
|
The immutable report uses `HEAD` for post-commit verification. The resolved commit object ID is recorded by the external acceptance result after this file is committed; a Git commit cannot reliably embed its own object ID because changing the file changes the tree and commit identity.
|
||||||
|
|
||||||
|
## Machine-Readable Result
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
gate: G1
|
||||||
|
variant: openclaw-cleanroom
|
||||||
|
date: 2026-07-14
|
||||||
|
status: PASS
|
||||||
|
source_commit: 502ef30642597fb0b70b90ae2e8a0b6af217d80e
|
||||||
|
source_branch: origin/work/v2r1-integration-quality-2000
|
||||||
|
result_branch: fix/v2r1-g1-cleanroom-final-evidence-r2-20260714
|
||||||
|
result_commit_ref: HEAD
|
||||||
|
tests_total: 32
|
||||||
|
tests_passed: 32
|
||||||
|
tests_failed: 0
|
||||||
|
cleanup_scan: PASS
|
||||||
|
secret_scan: PASS
|
||||||
|
rollback_status: PASS
|
||||||
|
recovery_verified: true
|
||||||
|
workspace_clean_at_test_completion: true
|
||||||
|
merge_performed: false
|
||||||
|
tag_created: false
|
||||||
|
baseline_promoted: false
|
||||||
|
deployment_performed: false
|
||||||
|
```
|
||||||
|
|
||||||
|
## Evidence Provenance
|
||||||
|
|
||||||
|
The test, cleanup, secret-scan, rollback, and recovery results below are execution evidence supplied by OpenClaw for the Gate 1 run. This correction commit changes only the evidence report and does not claim that the GitOps API reran the suites.
|
||||||
|
|
||||||
|
## HMAC Test-Key Length Verification
|
||||||
|
|
||||||
|
| Test file | Variable | Test-only value | Length |
|
||||||
|
|---|---|---|---:|
|
||||||
|
| `tests/v2r1/test_runtime_wiring.py:24` | `internal_key` | `b"runtime-internal-context-key-is-32bytes"` | 39 bytes |
|
||||||
|
| `tests/v2r1/test_runtime_wiring.py:25` | `ingress_key` | `b"runtime-external-ingress-key-is-32bytes"` | 39 bytes |
|
||||||
|
|
||||||
|
Both values are test fixtures. The supplied evidence reports that no production secret was introduced.
|
||||||
|
|
||||||
|
## Test Results Supplied by OpenClaw
|
||||||
|
|
||||||
|
### V2R1 suite — 29 of 29 passed
|
||||||
|
|
||||||
|
```shell
|
||||||
|
PYTHONPATH=active-runtime/app python3 -m pytest \
|
||||||
|
tests/v2r1/test_hermes_fabric.py \
|
||||||
|
tests/v2r1/test_message_ledger_append.py \
|
||||||
|
tests/v2r1/test_router_isolation.py \
|
||||||
|
tests/v2r1/test_runtime_identity_binding.py \
|
||||||
|
tests/v2r1/test_runtime_wiring.py \
|
||||||
|
tests/v2r1/test_tenant_context.py -v
|
||||||
|
# Reported result: 29 passed, exit code 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration suite — 3 of 3 passed
|
||||||
|
|
||||||
|
```shell
|
||||||
|
PYTHONPATH=active-runtime/app python3 -m pytest tests/integration/test_runtime_ingress_flow.py -v
|
||||||
|
# Reported result: 3 passed, exit code 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reported total: 32 of 32 passed.**
|
||||||
|
|
||||||
|
## Cleanup, Security, and Recovery Evidence
|
||||||
|
|
||||||
|
| Check | Supplied result |
|
||||||
|
|---|---|
|
||||||
|
| Conflict-marker scan | PASS |
|
||||||
|
| Cleanup scan | PASS |
|
||||||
|
| Secret scan | PASS |
|
||||||
|
| Legitimate hardcoded test keys | Two 39-byte fixtures in `test_runtime_wiring.py` |
|
||||||
|
| Rollback rehearsal | PASS; 13 of 13 core tests reported after rollback to `6077331` |
|
||||||
|
| Recovery verification | PASS |
|
||||||
|
|
||||||
|
## Deployment State
|
||||||
|
|
||||||
|
| Operation | Performed |
|
||||||
|
|---|---|
|
||||||
|
| Merge | No |
|
||||||
|
| Tag creation | No |
|
||||||
|
| Baseline promotion | No |
|
||||||
|
| Staging deployment | No |
|
||||||
|
| Production deployment | No |
|
||||||
|
|
||||||
|
## Post-Commit Verification
|
||||||
|
|
||||||
|
Run from the repository root after the commit is created:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SOURCE=502ef30642597fb0b70b90ae2e8a0b6af217d80e
|
||||||
|
RESULT_FILE=.agent/results/openclaw/RESULT-V2R1-G1-OPENCLAW-FINAL-EVIDENCE-20260714.md
|
||||||
|
|
||||||
|
git rev-parse HEAD
|
||||||
|
|
||||||
|
test "$(git diff --name-only "$SOURCE" HEAD | wc -l | tr -d ' ')" = "1"
|
||||||
|
test "$(git diff --name-only "$SOURCE" HEAD)" = "$RESULT_FILE"
|
||||||
|
|
||||||
|
git ls-tree -r HEAD -- "$RESULT_FILE"
|
||||||
|
|
||||||
|
python3 - "$RESULT_FILE" <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
path = Path(sys.argv[1])
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
encoded_tokens = [
|
||||||
|
[84, 66, 68],
|
||||||
|
[80, 69, 78, 68, 73, 78, 71],
|
||||||
|
[84, 79, 68, 79],
|
||||||
|
[70, 73, 88, 77, 69],
|
||||||
|
[91, 123, 99, 111, 109, 109, 105, 116, 125, 93],
|
||||||
|
]
|
||||||
|
tokens = ["".join(chr(value) for value in item) for item in encoded_tokens]
|
||||||
|
angle_token = re.compile(chr(60) + r"[^>\n]+" + chr(62))
|
||||||
|
|
||||||
|
hits = [token for token in tokens if token in text]
|
||||||
|
if angle_token.search(text):
|
||||||
|
hits.append("angle-delimited-token")
|
||||||
|
|
||||||
|
if hits:
|
||||||
|
print("TOKEN_SCAN=FAIL", hits)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
print("TOKEN_SCAN=PASS")
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
## Acceptance Conditions
|
||||||
|
|
||||||
|
Gate 1 is eligible to proceed only when the external acceptance record confirms all of the following against the resolved `HEAD` commit:
|
||||||
|
|
||||||
|
- the commit exists and has source commit `502ef30642597fb0b70b90ae2e8a0b6af217d80e` as its parent;
|
||||||
|
- exactly this one report file changed;
|
||||||
|
- the committed report contains no unresolved legacy token;
|
||||||
|
- the result tree is not identical to rejected commit `71239b4d187e683e05fd22fc9d5df8ae324e3390`;
|
||||||
|
- no merge, tag, baseline promotion, or deployment occurred.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
{
|
||||||
|
"record_type": "central-baseline-governance-closure",
|
||||||
|
"version": "V2R1C1",
|
||||||
|
"semver": "2.1.0-rc.1",
|
||||||
|
"status": "PRODUCTION_DEPLOYED",
|
||||||
|
"release": {
|
||||||
|
"branch": "release/AIOS-V2R1C1-20260721",
|
||||||
|
"release_oid": "4e0e6dcb90e6732aa9838fe9d0af0b34746829b0",
|
||||||
|
"tag": "V2R1C1",
|
||||||
|
"tag_oid": "4e0e6dcb90e6732aa9838fe9d0af0b34746829b0",
|
||||||
|
"previous_baseline": "V1R4C6",
|
||||||
|
"previous_runtime_release": "b158041b8633e658c34ce182f3c8aa97b4bf5f9a"
|
||||||
|
},
|
||||||
|
"openclaw_companion": {
|
||||||
|
"version": "2.1.0-rc.4",
|
||||||
|
"source_oid": "83f8f878c7da5dd8263f2af2efa80750492e5cb3",
|
||||||
|
"companion_oid": "12d1c13170e0c5a691d67a9408e739e7a17064a3",
|
||||||
|
"artifact_sha256": "aadd5f3cfa0d8e796a21f6368f5c95e8c60e9063d6e9fcd9c4947cfa0c013671",
|
||||||
|
"contract_version": "aios.openclaw.command.v2",
|
||||||
|
"signature_algorithm": "Ed25519"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"preproduction_status": "PREPRODUCTION_VALIDATED",
|
||||||
|
"production_status": "PRODUCTION_DEPLOYED",
|
||||||
|
"gate_a": "PASS",
|
||||||
|
"gate_b": "PASS",
|
||||||
|
"openclaw_live": "PASS",
|
||||||
|
"openclaw_negative_tests": "PASS",
|
||||||
|
"hermes_live": "PASS 17/17",
|
||||||
|
"grant_tests": "PASS 11/11",
|
||||||
|
"restart_recovery": "PASS",
|
||||||
|
"rollback": "PASS",
|
||||||
|
"final_health": "PASS",
|
||||||
|
"sqlite_check": "PASS",
|
||||||
|
"secret_scan": "PASS",
|
||||||
|
"blockers": "none"
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"root": "/var/lib/aios-gitops/acceptance/aios-v2r1c1-openclaw-v2-companion-20260721T094442Z",
|
||||||
|
"final_audit_manifest_sha256": "2cdb884abf195969de3cdaa44c76aaac52a9165fb4918436855291e46b2f8d55",
|
||||||
|
"governance_root": "/var/lib/aios-gitops/acceptance/aios-v2r1c1-baseline-registry-20260721T141705Z"
|
||||||
|
},
|
||||||
|
"governance": {
|
||||||
|
"branch": "patch/chatgpt/AIOS-V2R1C1-BASELINE-REGISTRY-20260721",
|
||||||
|
"base_ref": "refs/heads/main",
|
||||||
|
"base_oid": "3cf9958cb94614449e093639cbf902216d73447f",
|
||||||
|
"manifest_commit_oid": "a56caf46f6bc335ec2395b674ead4b2a427c5edd",
|
||||||
|
"baseline_commit_oid": "6dbd5cbe07d2960ca653049fefbcf3a3e18f9ab1",
|
||||||
|
"current_baseline_before": "V1R4C6",
|
||||||
|
"current_baseline_after": "V2R1C1",
|
||||||
|
"reviewer": "gpt-5.3-codex-spark validator and docs_reporter",
|
||||||
|
"review_status": "PASS",
|
||||||
|
"reviewed_at": "2026-07-21T14:23:00Z"
|
||||||
|
},
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"command": "GET https://cnnetmkt.top/api/run; GET /api/base; GET /api/ver",
|
||||||
|
"start_time": "2026-07-21T14:09:40Z",
|
||||||
|
"end_time": "2026-07-21T14:09:40Z",
|
||||||
|
"exit_code": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "bash /tmp/registry-create-branch.sh",
|
||||||
|
"start_time": "2026-07-21T14:17:05Z",
|
||||||
|
"end_time": "2026-07-21T14:17:05Z",
|
||||||
|
"exit_code": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "bash /tmp/registry-validate-core.sh",
|
||||||
|
"start_time": "2026-07-21T14:19:56Z",
|
||||||
|
"end_time": "2026-07-21T14:19:56Z",
|
||||||
|
"exit_code": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rollback_metadata": {
|
||||||
|
"action": "retain versions/V2R1C1.json and restore only baseline/current.json plus VERSION to V1R4C6 through a new reviewed governance commit",
|
||||||
|
"production_runtime_action": "none",
|
||||||
|
"tag_action": "none",
|
||||||
|
"release_oid_action": "none"
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"product_code_changed": false,
|
||||||
|
"tag_changed": false,
|
||||||
|
"production_redeployed": false,
|
||||||
|
"production_traffic_changed": false,
|
||||||
|
"secret_material_recorded": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# V2R1C1 Central Baseline Closure Result
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
- Governance task: register the already deployed `V2R1C1` release in the central Registry.
|
||||||
|
- Baseline: `V1R4C6` -> `V2R1C1`.
|
||||||
|
- Release OID and tag OID: `4e0e6dcb90e6732aa9838fe9d0af0b34746829b0`.
|
||||||
|
- Companion OID: `12d1c13170e0c5a691d67a9408e739e7a17064a3`.
|
||||||
|
- Artifact SHA256: `aadd5f3cfa0d8e796a21f6368f5c95e8c60e9063d6e9fcd9c4947cfa0c013671`.
|
||||||
|
- Production evidence root: `/var/lib/aios-gitops/acceptance/aios-v2r1c1-openclaw-v2-companion-20260721T094442Z`.
|
||||||
|
- Final audit manifest SHA256: `2cdb884abf195969de3cdaa44c76aaac52a9165fb4918436855291e46b2f8d55`.
|
||||||
|
- Production status: `PRODUCTION_DEPLOYED`.
|
||||||
|
|
||||||
|
## Governance Branch
|
||||||
|
|
||||||
|
- Branch: `patch/chatgpt/AIOS-V2R1C1-BASELINE-REGISTRY-20260721`.
|
||||||
|
- Base ref: `refs/heads/main`.
|
||||||
|
- Base OID: `3cf9958cb94614449e093639cbf902216d73447f`.
|
||||||
|
- Manifest commit: `a56caf46f6bc335ec2395b674ead4b2a427c5edd`.
|
||||||
|
- Baseline commit: `6dbd5cbe07d2960ca653049fefbcf3a3e18f9ab1`.
|
||||||
|
- Review: `PASS` at `2026-07-21T14:23:00Z` by `gpt-5.3-codex-spark` validator and docs reporter.
|
||||||
|
|
||||||
|
## Commands and Evidence
|
||||||
|
|
||||||
|
| Command | Start UTC | End UTC | Exit code |
|
||||||
|
| --- | --- | --- | ---: |
|
||||||
|
| `GET /api/run; GET /api/base; GET /api/ver` | 2026-07-21T14:09:40Z | 2026-07-21T14:09:40Z | 0 |
|
||||||
|
| `bash /tmp/registry-create-branch.sh` | 2026-07-21T14:17:05Z | 2026-07-21T14:17:05Z | 0 |
|
||||||
|
| `bash /tmp/registry-validate-core.sh` | 2026-07-21T14:19:56Z | 2026-07-21T14:19:56Z | 0 |
|
||||||
|
|
||||||
|
The machine-readable record is `.agent/results/releases/V2R1C1-BASELINE-CLOSURE.json`. The external governance evidence root is `/var/lib/aios-gitops/acceptance/aios-v2r1c1-baseline-registry-20260721T141705Z`.
|
||||||
|
|
||||||
|
## Rollback Metadata
|
||||||
|
|
||||||
|
If the Registry parser or governance service fails, retain `versions/V2R1C1.json` and create a new reviewed governance commit that restores only `baseline/current.json` and `VERSION` to `V1R4C6`. Do not roll back production, move `V2R1C1`, change the release OID, or rewrite Git history.
|
||||||
|
|
||||||
|
## Scope Protection
|
||||||
|
|
||||||
|
- `product_code_changed=false`
|
||||||
|
- `tag_changed=false`
|
||||||
|
- `production_redeployed=false`
|
||||||
|
- `production_traffic_changed=false`
|
||||||
|
- `secret_material_recorded=false`
|
||||||
@@ -7,6 +7,7 @@ from typing import Any
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(os.getenv('AIOS_GITOPS_WORKSPACE', '/data/aios-gitops/workspace')) / 'runtime' / 'agent-os'))
|
sys.path.insert(0, str(Path(os.getenv('AIOS_GITOPS_WORKSPACE', '/data/aios-gitops/workspace')) / 'runtime' / 'agent-os'))
|
||||||
from orchestrator.action_monitor import ActionMonitor
|
from orchestrator.action_monitor import ActionMonitor
|
||||||
|
import threading
|
||||||
|
|
||||||
ROOT = Path(os.getenv('AIOS_GITOPS_ROOT', '/data/aios-gitops'))
|
ROOT = Path(os.getenv('AIOS_GITOPS_ROOT', '/data/aios-gitops'))
|
||||||
WS = Path(os.getenv('AIOS_GITOPS_WORKSPACE', str(ROOT / 'workspace')))
|
WS = Path(os.getenv('AIOS_GITOPS_WORKSPACE', str(ROOT / 'workspace')))
|
||||||
@@ -17,6 +18,7 @@ PORT = int(os.getenv('AIOS_GITOPS_PORT', '18890'))
|
|||||||
OPENAPI = WS / 'docs' / 'gitops' / 'openapi-chatgpt-actions.yaml'
|
OPENAPI = WS / 'docs' / 'gitops' / 'openapi-chatgpt-actions.yaml'
|
||||||
BARE_REPO = Path(os.getenv('AIOS_GITOPS_BARE_REPO', str(ROOT / 'runtime' / 'gitea-bin' / 'data' / 'repositories' / 'aios' / 'aios.git')))
|
BARE_REPO = Path(os.getenv('AIOS_GITOPS_BARE_REPO', str(ROOT / 'runtime' / 'gitea-bin' / 'data' / 'repositories' / 'aios' / 'aios.git')))
|
||||||
monitor = ActionMonitor()
|
monitor = ActionMonitor()
|
||||||
|
WRITE_LOCK = threading.RLock()
|
||||||
|
|
||||||
BASE_MODELS = [
|
BASE_MODELS = [
|
||||||
{'id':'base.deepseekv4-flash','provider':'one-api','model':'deepseekv4-flash','display_name':'DeepSeek V4 Flash','scope':'global'},
|
{'id':'base.deepseekv4-flash','provider':'one-api','model':'deepseekv4-flash','display_name':'DeepSeek V4 Flash','scope':'global'},
|
||||||
@@ -117,28 +119,211 @@ def write_file(path, content):
|
|||||||
if content_has_secret(content): raise ValueError('secret_rejected: use secret_ref; plaintext API keys must not enter Git')
|
if content_has_secret(content): raise ValueError('secret_rejected: use secret_ref; plaintext API keys must not enter Git')
|
||||||
r=relpath(path); t=WS/r; t.parent.mkdir(parents=True, exist_ok=True); t.write_text(content, encoding='utf-8'); return r
|
r=relpath(path); t=WS/r; t.parent.mkdir(parents=True, exist_ok=True); t.write_text(content, encoding='utf-8'); return r
|
||||||
def write_json(path, data): return write_file(path, json.dumps(data, ensure_ascii=False, indent=2) + '\n')
|
def write_json(path, data): return write_file(path, json.dumps(data, ensure_ascii=False, indent=2) + '\n')
|
||||||
|
def controller_status() -> dict:
|
||||||
|
branch = sh(
|
||||||
|
['branch', '--show-current']
|
||||||
|
)['stdout'] or None
|
||||||
|
|
||||||
|
head = sh(
|
||||||
|
['rev-parse', 'HEAD'],
|
||||||
|
True,
|
||||||
|
)['stdout']
|
||||||
|
|
||||||
|
dirty = bool(
|
||||||
|
sh(
|
||||||
|
['status', '--porcelain']
|
||||||
|
)['stdout']
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'workspace_mode': (
|
||||||
|
'branch'
|
||||||
|
if branch
|
||||||
|
else 'detached'
|
||||||
|
),
|
||||||
|
'workspace_branch': branch,
|
||||||
|
'workspace_ref': head,
|
||||||
|
'workspace_dirty': dirty,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def detach_controller() -> dict:
|
||||||
|
state = controller_status()
|
||||||
|
|
||||||
|
if state['workspace_branch']:
|
||||||
|
sh(
|
||||||
|
[
|
||||||
|
'switch',
|
||||||
|
'--detach',
|
||||||
|
'HEAD',
|
||||||
|
],
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return controller_status()
|
||||||
|
|
||||||
|
|
||||||
def sync_branch(branch: str) -> dict:
|
def sync_branch(branch: str) -> dict:
|
||||||
if not branch or branch in ('main','master') or branch.startswith('main/') or branch.startswith('master/'):
|
if (
|
||||||
return {'status':'skipped','reason':'protected_branch','branch':branch}
|
not branch
|
||||||
|
or branch in ('main', 'master')
|
||||||
|
or branch.startswith('main/')
|
||||||
|
or branch.startswith('master/')
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
'status': 'skipped',
|
||||||
|
'reason': 'protected_branch',
|
||||||
|
'branch': branch,
|
||||||
|
}
|
||||||
|
|
||||||
|
state = detach_controller()
|
||||||
|
|
||||||
|
common_git_dir = Path(
|
||||||
|
sh(
|
||||||
|
[
|
||||||
|
'rev-parse',
|
||||||
|
'--path-format=absolute',
|
||||||
|
'--git-common-dir',
|
||||||
|
],
|
||||||
|
True,
|
||||||
|
)['stdout']
|
||||||
|
).resolve()
|
||||||
|
|
||||||
if BARE_REPO.exists():
|
if BARE_REPO.exists():
|
||||||
ref=f'refs/heads/{branch}:refs/heads/{branch}'
|
target = BARE_REPO.resolve()
|
||||||
p=subprocess.run(['git','--git-dir',str(BARE_REPO),'fetch',str(WS),ref], text=True, capture_output=True)
|
|
||||||
if p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or f'git sync failed for {branch}')
|
if target == common_git_dir:
|
||||||
return {'status':'synced','target':'bare','branch':branch,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
|
return {
|
||||||
p=subprocess.run(['git','push','origin',f'HEAD:refs/heads/{branch}'], cwd=str(WS), text=True, capture_output=True)
|
'status': 'synced',
|
||||||
if p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or f'git push failed for {branch}')
|
'target': 'common_git_dir',
|
||||||
return {'status':'synced','target':'origin','branch':branch,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
|
'branch': branch,
|
||||||
|
**state,
|
||||||
|
}
|
||||||
|
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
'git',
|
||||||
|
'push',
|
||||||
|
'--porcelain',
|
||||||
|
str(target),
|
||||||
|
f'HEAD:refs/heads/{branch}',
|
||||||
|
],
|
||||||
|
cwd=str(WS),
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
'git',
|
||||||
|
'push',
|
||||||
|
'--porcelain',
|
||||||
|
'origin',
|
||||||
|
f'HEAD:refs/heads/{branch}',
|
||||||
|
],
|
||||||
|
cwd=str(WS),
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
proc.stderr
|
||||||
|
or proc.stdout
|
||||||
|
or (
|
||||||
|
'git push failed for branch '
|
||||||
|
+ branch
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'synced',
|
||||||
|
'target': (
|
||||||
|
str(BARE_REPO.resolve())
|
||||||
|
if BARE_REPO.exists()
|
||||||
|
else 'origin'
|
||||||
|
),
|
||||||
|
'branch': branch,
|
||||||
|
'stdout': proc.stdout.strip(),
|
||||||
|
'stderr': proc.stderr.strip(),
|
||||||
|
**state,
|
||||||
|
}
|
||||||
|
|
||||||
def sync_tag(tag: str) -> dict:
|
def sync_tag(tag: str) -> dict:
|
||||||
if not tag: return {'status':'skipped','reason':'empty_tag'}
|
state = detach_controller()
|
||||||
|
|
||||||
|
common_git_dir = Path(
|
||||||
|
sh(
|
||||||
|
[
|
||||||
|
'rev-parse',
|
||||||
|
'--path-format=absolute',
|
||||||
|
'--git-common-dir',
|
||||||
|
],
|
||||||
|
True,
|
||||||
|
)['stdout']
|
||||||
|
).resolve()
|
||||||
|
|
||||||
|
tag_ref = f'refs/tags/{tag}'
|
||||||
|
|
||||||
if BARE_REPO.exists():
|
if BARE_REPO.exists():
|
||||||
ref=f'refs/tags/{tag}:refs/tags/{tag}'
|
target = BARE_REPO.resolve()
|
||||||
p=subprocess.run(['git','--git-dir',str(BARE_REPO),'fetch',str(WS),ref], text=True, capture_output=True)
|
|
||||||
if p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or f'git tag sync failed for {tag}')
|
if target == common_git_dir:
|
||||||
return {'status':'synced','target':'bare','tag':tag,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
|
return {
|
||||||
p=subprocess.run(['git','push','origin',tag], cwd=str(WS), text=True, capture_output=True)
|
'status': 'synced',
|
||||||
if p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or f'git tag push failed for {tag}')
|
'target': 'common_git_dir',
|
||||||
return {'status':'synced','target':'origin','tag':tag,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
|
'tag': tag,
|
||||||
|
**state,
|
||||||
|
}
|
||||||
|
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
'git',
|
||||||
|
'push',
|
||||||
|
'--porcelain',
|
||||||
|
str(target),
|
||||||
|
f'{tag_ref}:{tag_ref}',
|
||||||
|
],
|
||||||
|
cwd=str(WS),
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
'git',
|
||||||
|
'push',
|
||||||
|
'--porcelain',
|
||||||
|
'origin',
|
||||||
|
f'{tag_ref}:{tag_ref}',
|
||||||
|
],
|
||||||
|
cwd=str(WS),
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
proc.stderr
|
||||||
|
or proc.stdout
|
||||||
|
or (
|
||||||
|
'git push failed for tag '
|
||||||
|
+ tag
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'synced',
|
||||||
|
'target': (
|
||||||
|
str(BARE_REPO.resolve())
|
||||||
|
if BARE_REPO.exists()
|
||||||
|
else 'origin'
|
||||||
|
),
|
||||||
|
'tag': tag,
|
||||||
|
'stdout': proc.stdout.strip(),
|
||||||
|
'stderr': proc.stderr.strip(),
|
||||||
|
**state,
|
||||||
|
}
|
||||||
|
|
||||||
def commit(paths, msg):
|
def commit(paths, msg):
|
||||||
ensure_git(); paths = [relpath(p) for p in paths] if paths else ['.']; sh(['add'] + paths, True)
|
ensure_git(); paths = [relpath(p) for p in paths] if paths else ['.']; sh(['add'] + paths, True)
|
||||||
@@ -210,25 +395,123 @@ class H(BaseHTTPRequestHandler):
|
|||||||
monitor.record_failure('do_GET', str(e), {'path':path})
|
monitor.record_failure('do_GET', str(e), {'path':path})
|
||||||
return self.sendj({'error':str(e)},500)
|
return self.sendj({'error':str(e)},500)
|
||||||
def do_PUT(self):
|
def do_PUT(self):
|
||||||
path=urllib.parse.urlparse(self.path).path.rstrip('/'); data=self.body()
|
path = urllib.parse.urlparse(
|
||||||
if path == '/api/model-priority': return self.model_priority(data)
|
self.path
|
||||||
return self.sendj({'error':'not_found','path':path},404)
|
).path.rstrip('/')
|
||||||
def do_POST(self):
|
|
||||||
path=urllib.parse.urlparse(self.path).path.rstrip('/'); data=self.body()
|
data = self.body()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if path in ('/api/branch','/api/create-branch'): return self.branch(data)
|
with WRITE_LOCK:
|
||||||
if path in ('/api/file','/api/commit-file'): return self.file(data)
|
if path == '/api/model-priority':
|
||||||
if path == '/api/commit': return self.commit(data)
|
return self.model_priority(data)
|
||||||
if path in ('/api/patch','/api/create-patch'): return self.patch(data)
|
|
||||||
if path in ('/api/tag','/api/create-tag'): return self.tag(data)
|
return self.sendj(
|
||||||
if path == '/api/rollback': return self.rollback(data)
|
{
|
||||||
if path == '/api/model': return self.model(data)
|
'error': 'not_found',
|
||||||
if path == '/api/model-priority': return self.model_priority(data)
|
'path': path,
|
||||||
if path == '/api/nl': return self.nl(data)
|
},
|
||||||
return self.sendj({'error':'not_found','path':path},404)
|
404,
|
||||||
except Exception as e:
|
)
|
||||||
monitor.record_failure('do_POST', str(e), {'path':path})
|
except Exception as exc:
|
||||||
return self.sendj({'error':str(e)},500)
|
monitor.record_failure(
|
||||||
|
'do_PUT',
|
||||||
|
str(exc),
|
||||||
|
{'path': path},
|
||||||
|
)
|
||||||
|
return self.sendj(
|
||||||
|
{'error': str(exc)},
|
||||||
|
500,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
detach_controller()
|
||||||
|
except Exception as detach_error:
|
||||||
|
monitor.record_failure(
|
||||||
|
'detach_controller',
|
||||||
|
str(detach_error),
|
||||||
|
{
|
||||||
|
'path': path,
|
||||||
|
'method': 'PUT',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def do_POST(self):
|
||||||
|
path = urllib.parse.urlparse(
|
||||||
|
self.path
|
||||||
|
).path.rstrip('/')
|
||||||
|
|
||||||
|
data = self.body()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with WRITE_LOCK:
|
||||||
|
if path in (
|
||||||
|
'/api/branch',
|
||||||
|
'/api/create-branch',
|
||||||
|
):
|
||||||
|
return self.branch(data)
|
||||||
|
|
||||||
|
if path in (
|
||||||
|
'/api/file',
|
||||||
|
'/api/commit-file',
|
||||||
|
):
|
||||||
|
return self.file(data)
|
||||||
|
|
||||||
|
if path == '/api/commit':
|
||||||
|
return self.commit(data)
|
||||||
|
|
||||||
|
if path in (
|
||||||
|
'/api/patch',
|
||||||
|
'/api/create-patch',
|
||||||
|
):
|
||||||
|
return self.patch(data)
|
||||||
|
|
||||||
|
if path in (
|
||||||
|
'/api/tag',
|
||||||
|
'/api/create-tag',
|
||||||
|
):
|
||||||
|
return self.tag(data)
|
||||||
|
|
||||||
|
if path == '/api/rollback':
|
||||||
|
return self.rollback(data)
|
||||||
|
|
||||||
|
if path == '/api/model':
|
||||||
|
return self.model(data)
|
||||||
|
|
||||||
|
if path == '/api/model-priority':
|
||||||
|
return self.model_priority(data)
|
||||||
|
|
||||||
|
if path == '/api/nl':
|
||||||
|
return self.nl(data)
|
||||||
|
|
||||||
|
return self.sendj(
|
||||||
|
{
|
||||||
|
'error': 'not_found',
|
||||||
|
'path': path,
|
||||||
|
},
|
||||||
|
404,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
monitor.record_failure(
|
||||||
|
'do_POST',
|
||||||
|
str(exc),
|
||||||
|
{'path': path},
|
||||||
|
)
|
||||||
|
return self.sendj(
|
||||||
|
{'error': str(exc)},
|
||||||
|
500,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
detach_controller()
|
||||||
|
except Exception as detach_error:
|
||||||
|
monitor.record_failure(
|
||||||
|
'detach_controller',
|
||||||
|
str(detach_error),
|
||||||
|
{
|
||||||
|
'path': path,
|
||||||
|
'method': 'POST',
|
||||||
|
},
|
||||||
|
)
|
||||||
def branch(self,data):
|
def branch(self,data):
|
||||||
a=self.need('branch:create');
|
a=self.need('branch:create');
|
||||||
if not a: return
|
if not a: return
|
||||||
@@ -393,6 +676,25 @@ def api_spec():
|
|||||||
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run','GET /admin/ai-evolution'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True}}
|
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run','GET /admin/ai-evolution'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True}}
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
state = controller_status()
|
||||||
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
|
|
||||||
|
if state['workspace_dirty']:
|
||||||
|
raise RuntimeError(
|
||||||
|
'controller workspace is dirty; '
|
||||||
|
'refusing to start GitOps writes'
|
||||||
|
)
|
||||||
|
|
||||||
|
state = detach_controller()
|
||||||
|
|
||||||
|
print(
|
||||||
|
'AIOS ChatGPT GitOps adapter listening '
|
||||||
|
f'on {HOST}:{PORT}; '
|
||||||
|
f'workspace_mode={state["workspace_mode"]}; '
|
||||||
|
f'workspace_ref={state["workspace_ref"]}'
|
||||||
|
)
|
||||||
|
|
||||||
|
ThreadingHTTPServer(
|
||||||
|
(HOST, PORT),
|
||||||
|
H,
|
||||||
|
).serve_forever()
|
||||||
if __name__ == '__main__': main()
|
if __name__ == '__main__': main()
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
WT="${AIOS_GITOPS_WORKSPACE:-/data/aios-gitops/worktrees/controller}"
|
||||||
|
BRANCH="${AIOS_GITOPS_CONTROLLER_BRANCH:-main}"
|
||||||
|
|
||||||
|
git -C "$WT" rev-parse \
|
||||||
|
--is-inside-work-tree >/dev/null
|
||||||
|
|
||||||
|
DIRTY="$(
|
||||||
|
git -C "$WT" status --porcelain=v1
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [[ -n "$DIRTY" ]]; then
|
||||||
|
echo "controller workspace is dirty" >&2
|
||||||
|
echo "$DIRTY" >&2
|
||||||
|
exit 73
|
||||||
|
fi
|
||||||
|
|
||||||
|
git -C "$WT" fetch --prune origin \
|
||||||
|
"+refs/heads/${BRANCH}:refs/remotes/origin/${BRANCH}"
|
||||||
|
|
||||||
|
git -C "$WT" switch --detach \
|
||||||
|
"refs/remotes/origin/${BRANCH}"
|
||||||
|
|
||||||
|
COMMON="$(
|
||||||
|
git -C "$WT" \
|
||||||
|
rev-parse \
|
||||||
|
--path-format=absolute \
|
||||||
|
--git-common-dir
|
||||||
|
)"
|
||||||
|
|
||||||
|
git --git-dir="$COMMON" \
|
||||||
|
worktree prune --expire now
|
||||||
|
|
||||||
|
CURRENT_BRANCH="$(
|
||||||
|
git -C "$WT" branch --show-current
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [[ -n "$CURRENT_BRANCH" ]]; then
|
||||||
|
echo "controller remains attached: $CURRENT_BRANCH" >&2
|
||||||
|
exit 74
|
||||||
|
fi
|
||||||
|
|
||||||
|
DIRTY_AFTER="$(
|
||||||
|
git -C "$WT" status --porcelain=v1
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [[ -n "$DIRTY_AFTER" ]]; then
|
||||||
|
echo "controller became dirty" >&2
|
||||||
|
echo "$DIRTY_AFTER" >&2
|
||||||
|
exit 75
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "workspace_mode=detached"
|
||||||
|
echo "workspace_branch=null"
|
||||||
|
echo "workspace_dirty=false"
|
||||||
|
echo "workspace_ref=$(git -C "$WT" rev-parse --short HEAD)"
|
||||||
+14
-7
@@ -1,12 +1,19 @@
|
|||||||
{
|
{
|
||||||
"current_baseline": "V1R4C5",
|
"current_baseline": "V2R1C1",
|
||||||
"previous_baseline": "V1R4C3",
|
"previous_baseline": "V1R4C6",
|
||||||
"promoted_at": "2026-07-11T02:30:00+08:00",
|
"promoted_at": "2026-07-21T14:17:05Z",
|
||||||
"git": "a32c76a3c1baa49562e53fb8adce10a5ed0e6704",
|
"git": "4e0e6dcb90e6732aa9838fe9d0af0b34746829b0",
|
||||||
"promoted_by": "openclaw/admin",
|
"promoted_by": "chatgpt/owner-approved-governance",
|
||||||
"promotion_reason": "V1R4C5 reliability, watcher cleanup, user data isolation gate, and admin workbench read-only preparation; memory search remains disabled",
|
"promotion_reason": "Register the already validated and deployed V2R1C1 production release in the central GitOps baseline Registry.",
|
||||||
|
"version": "V2R1C1",
|
||||||
|
"release_oid": "4e0e6dcb90e6732aa9838fe9d0af0b34746829b0",
|
||||||
|
"tag_oid": "4e0e6dcb90e6732aa9838fe9d0af0b34746829b0",
|
||||||
|
"status": "PRODUCTION_DEPLOYED",
|
||||||
"constraints": {
|
"constraints": {
|
||||||
"memory_search_enabled": false,
|
"product_code_changed": false,
|
||||||
|
"tag_changed": false,
|
||||||
|
"production_redeployed": false,
|
||||||
|
"production_traffic_changed": false,
|
||||||
"force_push_allowed": false,
|
"force_push_allowed": false,
|
||||||
"gitea_db_mutation_requires_owner_approval": true
|
"gitea_db_mutation_requires_owner_approval": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# V1R4C6 Backup Result
|
||||||
|
|
||||||
|
- version: V1R4C6
|
||||||
|
- status: NOT_STARTED
|
||||||
|
- reason: No confirmed production Stack, persistent volume, database migration target, or V1R4C5 image digest is bound to this source release. A backup cannot be claimed or executed safely without an identified target datastore.
|
||||||
|
- prohibited_actions_not_performed: production database deletion; persistent-volume deletion; docker system prune --volumes; direct copy of live database files.
|
||||||
|
- required_before_resume: confirmed Stack identity and its documented backup/restore command.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# V1R4C6 Deployment Result
|
||||||
|
|
||||||
|
- version: V1R4C6
|
||||||
|
- source_commit: e204a90398bf646d46245b3088226749fa056edd
|
||||||
|
- tag_commit: d8cf6058e99f5c7f2dfd2b45a406c0a7edf955b2
|
||||||
|
- image_repository: UNRESOLVED
|
||||||
|
- image_digest: UNRESOLVED
|
||||||
|
- previous_image_digest: UNRESOLVED
|
||||||
|
- portainer_environment: UNRESOLVED
|
||||||
|
- portainer_stack: UNRESOLVED
|
||||||
|
- deployment_action: NOT_EXECUTED
|
||||||
|
- reason: No source-to-image-to-Portainer-Stack mapping exists. Existing Portainer containers and known Docker workloads do not expose a V1R4C6/e204 deployment target or Portainer stack labels. The active GitOps API is a direct systemd Python process, not a Portainer-managed Docker Stack.
|
||||||
|
- rollback_status: NOT_APPLICABLE_NO_CHANGE
|
||||||
|
- final_status: BLOCKED_MISSING_CONFIRMED_PRODUCTION_STACK
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# V1R4C6 Production Preflight Result
|
||||||
|
|
||||||
|
- version: V1R4C6
|
||||||
|
- started_at_utc: 2026-07-11T16:57:04.0596039Z
|
||||||
|
- source_tag_object: 356a8a8c1860b94b7d61a456c29228c88e57d963
|
||||||
|
- source_tag_commit: d8cf6058e99f5c7f2dfd2b45a406c0a7edf955b2
|
||||||
|
- source_commit: e204a90398bf646d46245b3088226749fa056edd
|
||||||
|
- tag_to_source_relation: tag commit is direct parent of source commit (verified)
|
||||||
|
- production_host_identifier: cn-network-mkt (hw / 1.95.164.183)
|
||||||
|
- host_preflight: Docker 29.5.3; root filesystem 29 percent used; memory available approximately 56 GiB
|
||||||
|
- GitOps_runtime: active at /data/aios-gitops/workspace via aios-gitops-model-api.service
|
||||||
|
- runtime_base: current_baseline V1R4C6; previous_baseline V1R4C5; git e204a90398bf646d46245b3088226749fa056edd; service status ok
|
||||||
|
- Portainer discovery: Portainer instances are reachable on ubuntu-admin and ubuntu-aihost, but no confirmed Portainer Environment/Stack maps to V1R4C6 or e204a90398bf646d46245b3088226749fa056edd.
|
||||||
|
- source_build_contract: target source contains only deploy/gitea/docker-compose.yml and no application Dockerfile/Containerfile or V1R4C6 image repository declaration.
|
||||||
|
- status: BLOCKED_MISSING_CONFIRMED_PRODUCTION_STACK
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# V1R4C6 Rollback Result
|
||||||
|
|
||||||
|
- version: V1R4C6
|
||||||
|
- rollback_required: No
|
||||||
|
- rollback_executed: No
|
||||||
|
- reason: No container, Stack, database, image, or production configuration was changed during this run.
|
||||||
|
- captured_v1r4c5_digest: UNRESOLVED because no matching current production Stack was confirmed.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# V1R4C6 Verification Result
|
||||||
|
|
||||||
|
- version: V1R4C6
|
||||||
|
- healthcheck_result: GitOps adapter /health = 200 and /api/base = 200; this verifies the control-plane API only, not a V1R4C6 production deployment.
|
||||||
|
- gate_a_result: NOT_RUN (production admin workbench target and authenticated test path unresolved)
|
||||||
|
- gate_b_result: NOT_RUN (production Search Gateway target/provider binding unresolved)
|
||||||
|
- gate_c_result: NOT_RUN (production Agent Platform target/runtime request path unresolved)
|
||||||
|
- runtime_acceptance_result: NOT_RUN (0/8; cannot reuse non-production results)
|
||||||
|
- critical_control_plane_finding: gitea-branch-consistency-sentinel is failed with five Git-ref-to-Gitea-metadata drift entries and autonomous safe mode emitted. This is a hard safety gate failure.
|
||||||
|
- no_unresolved_critical_errors: false
|
||||||
|
- final_status: BLOCKED_MISSING_CONFIRMED_PRODUCTION_STACK
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# 微信文本-图片缓存隔离事故报告
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
本次事故已经按最新备份链路回滚并完成第二轮评审修复。`3d9782fc346d1e7defde090ab8637e62ab28ab8a` 进一步修复了英文诊断文本和未确认引用文本误入图片路径的问题,`706c0b0` 则是后续最小修复提交。当前文档只记录证据、部署和验收边界,不把“真实 owner inbound 已完成”写成结论。
|
||||||
|
|
||||||
|
## 现象
|
||||||
|
|
||||||
|
- 纯文本消息被误答成旧图片回复,属于 stale-image reply on pure text。
|
||||||
|
- 现场证据显示 `types=1` 且 `hasMedia=false`,但后续又进入 `message_type=image`、`image_count=0`、`implicit_image_message`,最终落到 `cached_image_expired`。
|
||||||
|
- 这说明“诊断文本”被误判成图片意图后,又继承了 sender 级旧图缓存。
|
||||||
|
|
||||||
|
## 根因
|
||||||
|
|
||||||
|
- `fixes/patch_openclaw_weixin_hermes.py:768-839` 把文本意图判断和缓存回退放在同一条链上。
|
||||||
|
- `looksLikeHermesImageFollowup()` 会把含图片关键字的诊断文本推进 image intent。
|
||||||
|
- `resolveHermesFollowupImageContext()` 随后会读取 sender 级缓存;一旦缓存命中失败或过期,就会落到 `cached_image_expired`,从而让纯文本沾上旧图上下文。
|
||||||
|
- `handleHermesImplicitImage()` 继续把消息送入 `handleHermes(..., "implicit_image_message")`,所以误判会沿着同一条图片处理通道传播。
|
||||||
|
|
||||||
|
## 护栏不变量
|
||||||
|
|
||||||
|
- 图片处理上下文必须来自当前消息图片、已确认的引用图片,或精确匹配的缓存图片 ID;缺少这三类证据时,纯文本不能进入 `handleHermesImplicitImage()`。
|
||||||
|
- 未确认的引用文本不能继承 sender 级旧图缓存,必须返回 `referenced_image_not_confirmed`。
|
||||||
|
- 只有精确缓存图片 ID 命中且缓存仍有效时,才允许走 `resolveHermesCachedImageEntry()` 的 `cached_image_matched` 路径。
|
||||||
|
- `/hermes` 这类 slash 路径保持原有分流,slash/channel 面不变。
|
||||||
|
- 这次修复的守卫目标,是把中英文诊断文本、未确认引用文本与图片缓存回退拆开,避免再把普通文本提升成图片意图。
|
||||||
|
|
||||||
|
## TDD 与幂等性
|
||||||
|
|
||||||
|
- `66d637fc0364a617c7b9fd8cd9baa68fb0368675`(`fix(weixin): isolate text from stale image cache`)引入了隔离修复和 `tests/test_weixin_image_followup_intent.py`。
|
||||||
|
- `2de29c59bb946cc3db3e14e8a06270391ede8401`(`fix(weixin): add process-only runtime patch mode`)继续收紧 process-only 运行模式,保持回放幂等。
|
||||||
|
- `3d9782fc346d1e7defde090ab8637e62ab28ab8a`(`fix(weixin): require confirmed image reference context`)补齐英文诊断文本和未确认引用文本用例,并把图片引用收紧到已确认上下文。
|
||||||
|
- 用户实测的 TDD 路径为 RED `8 PASS / 1 FAIL`,随后 GREEN `9 PASS`。
|
||||||
|
- 最小修复提交为 `706c0b0`,对应 quoted pure text 的最后一层收敛修复。
|
||||||
|
- 第二轮回归结果为 `8 PASS`;node check 同样通过,重复应用补丁保持幂等。
|
||||||
|
- 生产 npm 分发侧本身缺少 `tsconfig/tests`,baseline 与 patched 共享这一限制,所以这里不把分发目录的缺口当成回归点;验收以 node check 和 8 tests 为准。
|
||||||
|
|
||||||
|
## 用户实测补充
|
||||||
|
|
||||||
|
- 普通文本两项为 PASS。
|
||||||
|
- quoted pure text 为 FAIL,日志证据包含 `ref_msg.message_item.type=0/NONE`、quoted id、`implicit_image_message`、`referenced_image_unresolved`。
|
||||||
|
- 这组证据说明 quoted pure text 没有被当成真正的图片 follow-up,且没有越过引用确认边界。
|
||||||
|
|
||||||
|
## 回滚与备份
|
||||||
|
|
||||||
|
- 回滚按最新备份执行,基线指向 `/data/aios-gitops/runtime/latest-backup-dir.txt` 所示的最新备份目录。
|
||||||
|
- 备份路径链路按供给证据记录为 `T064506Z`、`T074212Z`、`T081658Z`。
|
||||||
|
- 第一轮恢复后的部署过程哈希记录为 `e9625d12` 和 `c00c58f8`。
|
||||||
|
- v2 部署备份为 `/var/backups/aios/weixin-text-cache-isolation-20260711T094908Z`。
|
||||||
|
- v2 部署文件哈希为 TS `024c6498284d1871b048f6157d5d3dbd4d34e54b53a05afd744b00458ceeb79f`、JS `d73b15d6752056192a1d1939b04107334419785b41175b39f1cbc1132ac65c47`;slash/channel 口径保持不变。
|
||||||
|
- 首次部署因为 `-- No entries --` 门禁误判而回滚,随后纠正部署在线,PID 为 `2496603`。
|
||||||
|
- 在线修复后的 hashes 为 `86dfe...` / `a9b854...`,`channel 4` accounts healthy,cache unchanged。
|
||||||
|
|
||||||
|
## 主线合并与生产发布
|
||||||
|
|
||||||
|
- 主线 fast-forward source rev 为 `9269e819972c2e709e0c27bed7eab590da6961c9`,本次不 tag、不 baseline。
|
||||||
|
- 生产发布结论为 PASS,release dir 为 `/var/backups/aios/weixin-mainline-release-20260711T181842Z`。
|
||||||
|
- old PID 为 `2655072`,new PID 为 `2656106`。
|
||||||
|
- TS hash 为 `86dfe4543c4414bc86778a4b218c244a742a139c66e3c9f19cf6f0de871234c3`,JS hash 为 `a9b854015108020458ce6f31b9d9187eff82e170c4c214259b0afb9be6a7336f`。
|
||||||
|
- `18789` 监听,`channel 4` accounts healthy,`error_count=0`,cache unchanged。
|
||||||
|
- 一次参数门禁失败已回滚,且不影响最终发布。
|
||||||
|
- owner quoted-text live acceptance 仍为 PENDING。
|
||||||
|
|
||||||
|
## 运行态验证
|
||||||
|
|
||||||
|
- 第一轮 gateway PID 由 `4025932` 变为 `1723576`;v2 部署后由 `1723576` 变为 `1876713`。
|
||||||
|
- v2 现场/供给证据为 `active`、监听 `18789`、`error_count=0`、`channel gate pass`、`cache preserved`。
|
||||||
|
- 最终 read-only gate 通过,说明部署后运行态未出现新的插件错误,channel 状态和缓存均保持预期。
|
||||||
|
- quoted pure text 的第二次 owner live acceptance 仍为 PENDING。
|
||||||
|
|
||||||
|
## 未完成的真实验收
|
||||||
|
|
||||||
|
- owner 纯文本
|
||||||
|
- diagnostic text
|
||||||
|
- genuine image-followup message
|
||||||
|
- quoted pure text 的第二次 owner live acceptance
|
||||||
|
|
||||||
|
以上四类消息仍需要由 owner 真实 inbound 到达,才能把这次“文本隔离”结论升格为完整的线上接受;本报告明确保留该项为 pending,不把它们写成已经完成。
|
||||||
|
|
||||||
|
## 引用文字语义修复与发布(2026-07-12)
|
||||||
|
|
||||||
|
- 后续 owner 实测证明图片误路由已消失,但模型在没有被引正文时根据 `chat_id/message_id/timestamp` 编造解释,因此图片隔离 PASS、语义验收 FAIL。
|
||||||
|
- 现场 payload 的 `ref_msg.message_item.type=0/NONE` 只有 quoted ID 与时间字段,没有 `text_item`;session transcript 又没有原始 Weixin 数字 message ID,不能精确回表。
|
||||||
|
- 出站 `clientId` 与引用的 19 位数字 `msg_id` 格式不同,发送 API 也不返回服务端 message ID。本次明确禁止 recent-message、时间窗口、session 顺序和近似 ID 猜测。
|
||||||
|
- `1136b4186d7578cd77f255297204b10152c47dab` 增加证据优先处理:payload 明确含 `text_item.text` 时才构造引用上下文;引用存在但正文缺失时,经标准 dispatcher 确定性回复“无法读取被引用文字,请复制原文后再提问。”,并在模型 dispatch 前返回。
|
||||||
|
- TDD 先出现两项预期 RED(quoted-text resolver 不存在),最终 GREEN 为 `17 passed`;`py_compile`、`git diff --check`、`node --check` 均通过。
|
||||||
|
- Scratch 双跑验证只改变 `src/messaging/process-message.ts` 与 `dist/src/messaging/process-message.js`,第二次应用 diff 为 0;slash/channel 文件保持不变。
|
||||||
|
- 主线为 `1136b4186d7578cd77f255297204b10152c47dab`,生产发布目录为 `/var/backups/aios/weixin-quoted-text-failclosed-release-20260712T054451Z`。
|
||||||
|
- 发布后 PID 从 `2656106` 变为 `3670157`;TS hash 为 `1a73e735e9c8fb40a1f82d2a66bc022396c8105ed989aac95e11b66326e9fdf1`,JS hash 为 `1c76bf6ec3b45021e68b932cbf52222ba2130a867b95e41d7a91f261b991a304`。
|
||||||
|
- `18789`、4 个 Weixin accounts、post-start error 0 和图片 cache unchanged 均通过。新的 owner live acceptance 仍为 PENDING。
|
||||||
|
|
||||||
|
## Owner 引用文字 Fail-Closed 验收(2026-07-12)
|
||||||
|
|
||||||
|
- Owner 引用一条微信未提供正文的文字消息并发送“是什么意思”,收到固定回复“无法读取被引用文字,请复制原文后再提问。”。
|
||||||
|
- 日志窗口为 `2026-07-12 14:06:23.424-14:06:23.893 +08:00`:入站 `types=1`、`bodyLen=6`、`hasMedia=false`,出站 `textLen=20` 且发送成功。
|
||||||
|
- 同一窗口 `model-fetch=0`、`implicit_image_message=0`、Hermes route=0,`quoted_text_unavailable=1` 且 `quoted_message_id=present`。
|
||||||
|
- 该证据确认无正文引用不会进入模型猜测或图片缓存路径。Owner quoted-text no-body live acceptance 为 **PASS**。
|
||||||
|
- 微信 payload 明确携带 `text_item.text` 时的 grounded explanation 和 genuine image follow-up 仍作为独立兼容性验收项保留。
|
||||||
|
|
||||||
|
## 可见引用摘要解析修复(2026-07-12)
|
||||||
|
|
||||||
|
- Owner 进一步确认微信客户端能够显示被引用正文,因此上一节的“未提供正文”只适用于旧 resolver 的观察结果,不代表协议 payload 没有可用文本。
|
||||||
|
- 根因是插件 `inbound.ts` 已将协议字段 `ref_msg.title` 定义为引用摘要并拼入 `ctx.Body`,而 `extractHermesReferencedText()` 只读取 `ref_msg.message_item.text_item.text`,导致客户端可见摘要被错误丢弃。
|
||||||
|
- `13c2a182860131abcad0f5a70ae8263477578588` 在真实 `text_item.text` 缺失时,仅从 reference envelope 读取 `ref_msg.title`;不会读取 `message_item.title` 或任意嵌套通用 `title`,也不改变图片、slash 或 channel 路由。
|
||||||
|
- TDD 先得到 `18 passed / 1 failed`,失败项为可见 `ref_msg.title` 仍落到 `quoted_text_unavailable`;修复后为 `19 passed`,`py_compile`、`node --check` 与 `git diff --check` 通过。
|
||||||
|
- 生产发布目录为 `/var/backups/aios/weixin-quoted-text-title-resolution-20260712T155112+0800`,PID 从 `3859376` 变为 `3860863`,post-start error 为 0,图片 cache unchanged。
|
||||||
|
- 部署后 TS hash 为 `8e77ce61214688a2507b07f0cf1ee85401b58198b8271bebd69efc2681872726`,JS hash 为 `663db8d30a5cd5eb80fe46cf941cce6e6f5d04cb7e4b8d9b4d0086d87faba290`。
|
||||||
|
- 可见正文引用的 owner live acceptance 尚待下一条真实微信消息确认;预期日志必须命中 `quoted_text_resolved`,且同一窗口不得出现 `quoted_text_unavailable` 或图片路由。
|
||||||
|
|
||||||
|
## 安全说明
|
||||||
|
|
||||||
|
- 本次仅更新本报告,没有修改代码、runtime、services 或 secrets。
|
||||||
|
- 报告里没有任何凭据、密钥或原始敏感 ID。
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# V2R1C1 中央 Baseline 治理收口
|
||||||
|
|
||||||
|
## 收口范围
|
||||||
|
|
||||||
|
本次变更只登记已经完成生产验证并上线的 `V2R1C1`。它新增中央版本 Manifest、把中央 Baseline Pointer 从 `V1R4C6` 切换为 `V2R1C1`,并建立 Release、Companion、Artifact 与最终生产 Evidence 的追溯关系。
|
||||||
|
|
||||||
|
本次不修改产品实现,不移动或重建 `V2R1C1` Tag,不改变 Release OID,不重新部署生产,不切换生产流量,也不重写历史证据。
|
||||||
|
|
||||||
|
## 权威标识
|
||||||
|
|
||||||
|
- Release:`release/AIOS-V2R1C1-20260721`
|
||||||
|
- Release OID:`4e0e6dcb90e6732aa9838fe9d0af0b34746829b0`
|
||||||
|
- Tag / Tag OID:`V2R1C1` / `4e0e6dcb90e6732aa9838fe9d0af0b34746829b0`
|
||||||
|
- SemVer:`2.1.0-rc.1`
|
||||||
|
- OpenClaw Companion:`2.1.0-rc.4`
|
||||||
|
- Companion Source OID:`83f8f878c7da5dd8263f2af2efa80750492e5cb3`
|
||||||
|
- Companion OID:`12d1c13170e0c5a691d67a9408e739e7a17064a3`
|
||||||
|
- Artifact SHA256:`aadd5f3cfa0d8e796a21f6368f5c95e8c60e9063d6e9fcd9c4947cfa0c013671`
|
||||||
|
- Contract:`aios.openclaw.command.v2` / `Ed25519`
|
||||||
|
- Evidence Root:`/var/lib/aios-gitops/acceptance/aios-v2r1c1-openclaw-v2-companion-20260721T094442Z`
|
||||||
|
- Final Audit Manifest SHA256:`2cdb884abf195969de3cdaa44c76aaac52a9165fb4918436855291e46b2f8d55`
|
||||||
|
- Production Status:`PRODUCTION_DEPLOYED`
|
||||||
|
|
||||||
|
## 治理路径
|
||||||
|
|
||||||
|
- Governance Branch:`patch/chatgpt/AIOS-V2R1C1-BASELINE-REGISTRY-20260721`
|
||||||
|
- Base Ref:`refs/heads/main`
|
||||||
|
- Base OID:`3cf9958cb94614449e093639cbf902216d73447f`
|
||||||
|
- Manifest Commit:`a56caf46f6bc335ec2395b674ead4b2a427c5edd`
|
||||||
|
- Baseline Commit:`6dbd5cbe07d2960ca653049fefbcf3a3e18f9ab1`
|
||||||
|
- Reviewer:`gpt-5.3-codex-spark validator and docs_reporter`
|
||||||
|
- Review:`PASS`(`2026-07-21T14:23:00Z`)
|
||||||
|
|
||||||
|
## 命令与退出码
|
||||||
|
|
||||||
|
| 阶段 | 命令 | 开始 UTC | 结束 UTC | Exit Code |
|
||||||
|
| --- | --- | --- | --- | ---: |
|
||||||
|
| 中央只读核对 | `GET /api/run; GET /api/base; GET /api/ver` | 2026-07-21T14:09:40Z | 2026-07-21T14:09:40Z | 0 |
|
||||||
|
| 创建治理分支 | `bash /tmp/registry-create-branch.sh` | 2026-07-21T14:17:05Z | 2026-07-21T14:17:05Z | 0 |
|
||||||
|
| 核心元数据验证 | `bash /tmp/registry-validate-core.sh` | 2026-07-21T14:19:56Z | 2026-07-21T14:19:56Z | 0 |
|
||||||
|
|
||||||
|
完整机读记录位于 `.agent/results/releases/V2R1C1-BASELINE-CLOSURE.json`,治理运行证据位于 `/var/lib/aios-gitops/acceptance/aios-v2r1c1-baseline-registry-20260721T141705Z`。
|
||||||
|
|
||||||
|
## 元数据回滚
|
||||||
|
|
||||||
|
若中央 Registry 解析或治理服务异常,保留 `versions/V2R1C1.json`,通过新的受审治理提交把 `baseline/current.json` 和 `VERSION` 临时恢复到 `V1R4C6`。该操作不得回滚生产系统、移动 Tag、修改 Release OID 或重写历史。
|
||||||
|
|
||||||
|
## 范围证明
|
||||||
|
|
||||||
|
```text
|
||||||
|
product_code_changed=false
|
||||||
|
tag_changed=false
|
||||||
|
production_redeployed=false
|
||||||
|
production_traffic_changed=false
|
||||||
|
secret_material_recorded=false
|
||||||
|
```
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Weixin Quoted Text Fail-Closed Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Resolve explicit quoted text safely and fail closed without a model call when Weixin omits the quoted body.
|
||||||
|
|
||||||
|
**Architecture:** Add pure JS/TS reference-text helpers and one process hook guard to the existing patcher. Keep the patch surface limited to generated `process-message.ts/js`; do not introduce a persistent cache or modify send/monitor/API modules.
|
||||||
|
|
||||||
|
**Tech Stack:** Python patch generator, TypeScript/JavaScript, Node test harness, pytest.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Sub-agents use `gpt-5.3-codex-spark`.
|
||||||
|
- No force push, tag, baseline promotion, credential persistence, recent-message fallback, or approximate text matching.
|
||||||
|
- Existing confirmed image and ordinary text behavior must remain unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add Quoted Text Resolver With TDD
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/test_weixin_image_followup_intent.py`
|
||||||
|
- Modify: `fixes/patch_openclaw_weixin_hermes.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `extractHermesReferencedText(items)` and `resolveHermesQuotedTextContext(items, currentText)` in TS and JS helper templates.
|
||||||
|
|
||||||
|
- [ ] Add a Node-backed failing test with `ref_msg.message_item.type=1` and explicit `text_item.text="被引用的正文"`; assert `reason="quoted_text_resolved"`, `handled=false`, and a prompt containing only delimited quoted/current text.
|
||||||
|
- [ ] Add a Node-backed failing test with `type=0`, quoted ID, no text, and no confirmed image; assert `reason="quoted_text_unavailable"`, `handled=true`, and exact deterministic `replyText`.
|
||||||
|
- [ ] Run `pytest -q tests/test_weixin_image_followup_intent.py -k 'quoted_text'` and record expected RED because the resolver is absent.
|
||||||
|
- [ ] Implement the smallest recursive explicit-text extractor. Accept only known referenced text fields; cap each extracted string at 4000 characters and reject blank values.
|
||||||
|
- [ ] Implement the resolver in both TS and JS templates. Do not read session history, image latest cache, timestamps, or metadata values.
|
||||||
|
- [ ] Re-run the focused tests and record GREEN.
|
||||||
|
- [ ] Commit with `fix(weixin): fail closed on unavailable quoted text`.
|
||||||
|
|
||||||
|
### Task 2: Guard The Real Process Hook
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/test_weixin_image_followup_intent.py`
|
||||||
|
- Modify: `fixes/patch_openclaw_weixin_hermes.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `resolveHermesQuotedTextContext(items, currentText)`.
|
||||||
|
- Produces: process hook behavior that replies deterministically or replaces only the ordinary text prompt.
|
||||||
|
|
||||||
|
- [ ] Add a failing JS hook test that stubs the ordinary model dispatch and reply function. For type 0 without text, assert reply called once and model called zero times.
|
||||||
|
- [ ] Add a failing JS hook test for explicit quoted text. Assert model called once with delimited context and no message metadata.
|
||||||
|
- [ ] Add regression assertions for plain text, current image, and exact quoted image.
|
||||||
|
- [ ] Run focused tests and record RED for the missing guard.
|
||||||
|
- [ ] Insert the resolver after reference/image evidence extraction and before ordinary model dispatch. Confirmed image references must bypass the text fail-closed branch.
|
||||||
|
- [ ] Emit structured reason-only diagnostics without message bodies.
|
||||||
|
- [ ] Run focused tests and record GREEN.
|
||||||
|
- [ ] Commit with `fix(weixin): guard quoted text model dispatch`.
|
||||||
|
|
||||||
|
### Task 3: Idempotence, Review, And Release
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/incidents/WEIXIN_TEXT_IMAGE_CACHE_ISOLATION_20260711.md`
|
||||||
|
|
||||||
|
- [ ] Run `pytest -q tests/test_weixin_image_followup_intent.py` and require all tests pass.
|
||||||
|
- [ ] Run `python3 -m py_compile fixes/patch_openclaw_weixin_hermes.py tests/test_weixin_image_followup_intent.py`.
|
||||||
|
- [ ] Run `git diff --check`.
|
||||||
|
- [ ] Apply `--process-only` twice to a scratch production-plugin copy and require identical second-run hashes; require slash/channel files unchanged.
|
||||||
|
- [ ] Obtain independent spec and code review.
|
||||||
|
- [ ] Update the incident document with RED/GREEN evidence, main revision, production hashes, rollback directory, and owner live acceptance state.
|
||||||
|
- [ ] Merge by non-force fast-forward from a clean main-based branch, deploy only the two generated process-message files, restart only `openclaw-gateway.service`, and verify service, port 18789, four accounts, zero post-start errors, and unchanged image cache.
|
||||||
|
- [ ] Owner live acceptance: quote a type 0 text message and verify deterministic no-body response; quote a payload that includes explicit text and verify grounded explanation.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Weixin Quoted Text Fail-Closed Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Prevent quoted-text questions from being answered with invented message metadata when Weixin does not provide the quoted body. Preserve normal text and confirmed image behavior.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- A quoted text event can arrive as `ref_msg.message_item.type=0` with only `msg_id` and timestamps.
|
||||||
|
- Session transcripts retain text but not the original Weixin numeric message ID.
|
||||||
|
- Outbound `clientId` values are not the same format as quoted numeric `msg_id` values, and the send API does not expose a server message ID.
|
||||||
|
- Therefore recent-message, timestamp, session, and outbound-client-ID matching are not exact evidence and must not be used.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Use an evidence-only resolver in `process-message`:
|
||||||
|
|
||||||
|
1. If reference payload contains an explicit text item, extract that text and build a bounded quoted-text context for the ordinary text model path.
|
||||||
|
2. If reference context exists but has no explicit text and is not a confirmed image, return a deterministic response: `无法读取被引用文字,请复制原文后再提问。`
|
||||||
|
3. The unresolved branch is handled before ordinary model dispatch and before Hermes image dispatch.
|
||||||
|
4. Do not infer quoted text from metadata, recent messages, session order, timestamps, or approximate IDs.
|
||||||
|
|
||||||
|
No persistent text cache is introduced in this change. Outbound ID aliasing remains a separate observation task because an exact mapping has not been proved.
|
||||||
|
|
||||||
|
## Interfaces
|
||||||
|
|
||||||
|
- `extractHermesReferencedText(items): string` returns only explicit referenced text, otherwise an empty string.
|
||||||
|
- `resolveHermesQuotedTextContext(items, currentText)` returns one of:
|
||||||
|
- `{ referenced: false, handled: false, prompt: currentText, reason: "none" }`
|
||||||
|
- `{ referenced: true, handled: false, prompt, reason: "quoted_text_resolved" }`
|
||||||
|
- `{ referenced: true, handled: true, replyText: "无法读取被引用文字,请复制原文后再提问。", reason: "quoted_text_unavailable" }`
|
||||||
|
- Confirmed referenced images bypass this text resolver and keep the existing exact image path.
|
||||||
|
|
||||||
|
## Privacy And Logging
|
||||||
|
|
||||||
|
- Do not log quoted or current text.
|
||||||
|
- Log only `reason`, account ID, a one-way bounded hash of quoted ID when available, and boolean evidence flags.
|
||||||
|
- Do not persist tokens, credentials, full payloads, or message text.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- Explicit quoted text is supplied to the ordinary text model with a clear delimiter.
|
||||||
|
- Type 0 reference without text returns the deterministic Chinese response and does not call the model.
|
||||||
|
- Plain text, current image, and exact quoted image behavior remain unchanged.
|
||||||
|
- Generated TS and JS remain byte-idempotent under `--process-only`.
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
TS_HELPER = '''// AIOS Weixin lossless getUpdates JSON begin
|
||||||
|
const WEIXIN_EXACT_ID_FIELDS = new Set(["message_id", "msg_id", "root_id", "parent_id", "server_msg_id", "new_msg_id"]);
|
||||||
|
function parseWeixinGetUpdatesJson(rawText: string): GetUpdatesResp {
|
||||||
|
return JSON.parse(rawText, (key: string, value: unknown, context?: { source?: string }) => {
|
||||||
|
if (!WEIXIN_EXACT_ID_FIELDS.has(key) || value == null || typeof value === "string") return value;
|
||||||
|
if (typeof value !== "number") throw new Error(`invalid ${key} type in getUpdates response`);
|
||||||
|
const source = context?.source;
|
||||||
|
if (!source || !/^(?:0|[1-9]\\d*)$/.test(source)) throw new Error(`unsafe numeric ${key} in getUpdates response`);
|
||||||
|
return source;
|
||||||
|
}) as GetUpdatesResp;
|
||||||
|
}
|
||||||
|
// AIOS Weixin lossless getUpdates JSON end'''
|
||||||
|
|
||||||
|
JS_HELPER = '''// AIOS Weixin lossless getUpdates JSON begin
|
||||||
|
const WEIXIN_EXACT_ID_FIELDS = new Set(["message_id", "msg_id", "root_id", "parent_id", "server_msg_id", "new_msg_id"]);
|
||||||
|
function parseWeixinGetUpdatesJson(rawText) {
|
||||||
|
return JSON.parse(rawText, (key, value, context) => {
|
||||||
|
if (!WEIXIN_EXACT_ID_FIELDS.has(key) || value == null || typeof value === "string")
|
||||||
|
return value;
|
||||||
|
if (typeof value !== "number")
|
||||||
|
throw new Error(`invalid ${key} type in getUpdates response`);
|
||||||
|
const source = context?.source;
|
||||||
|
if (!source || !/^(?:0|[1-9]\\d*)$/.test(source))
|
||||||
|
throw new Error(`unsafe numeric ${key} in getUpdates response`);
|
||||||
|
return source;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// AIOS Weixin lossless getUpdates JSON end'''
|
||||||
|
|
||||||
|
|
||||||
|
def insert_once(text: str, marker: str, addition: str) -> str:
|
||||||
|
if addition in text:
|
||||||
|
return text
|
||||||
|
if marker not in text:
|
||||||
|
raise RuntimeError(f'marker not found: {marker}')
|
||||||
|
return text.replace(marker, marker + '\n' + addition, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def patch_api_ts(text: str) -> str:
|
||||||
|
text = insert_once(text, 'import { redactBody, redactUrl } from "../util/redact.js";', '\n' + TS_HELPER)
|
||||||
|
old = 'const resp: GetUpdatesResp = JSON.parse(rawText);'
|
||||||
|
new = 'const resp: GetUpdatesResp = parseWeixinGetUpdatesJson(rawText);'
|
||||||
|
if new not in text:
|
||||||
|
if old not in text:
|
||||||
|
raise RuntimeError('TypeScript getUpdates parser marker not found')
|
||||||
|
text = text.replace(old, new, 1)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def patch_api_js(text: str) -> str:
|
||||||
|
text = insert_once(text, 'import { redactBody, redactUrl } from "../util/redact.js";', '\n' + JS_HELPER)
|
||||||
|
old = 'const resp = JSON.parse(rawText);'
|
||||||
|
new = 'const resp = parseWeixinGetUpdatesJson(rawText);'
|
||||||
|
if new not in text:
|
||||||
|
if old not in text:
|
||||||
|
raise RuntimeError('JavaScript getUpdates parser marker not found')
|
||||||
|
text = text.replace(old, new, 1)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def patch_types_ts(text: str) -> str:
|
||||||
|
new = 'message_id?: string | number;'
|
||||||
|
if new in text:
|
||||||
|
return text
|
||||||
|
old = 'message_id?: number;'
|
||||||
|
if old not in text:
|
||||||
|
raise RuntimeError('message_id type marker not found')
|
||||||
|
return text.replace(old, new, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def write_if_changed(path: Path, content: str) -> bool:
|
||||||
|
original = path.read_text(encoding='utf-8')
|
||||||
|
if original == content:
|
||||||
|
return False
|
||||||
|
path.write_text(content, encoding='utf-8')
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def apply(plugin_root: Path) -> dict[str, bool]:
|
||||||
|
paths = {
|
||||||
|
'src/api/api.ts': patch_api_ts,
|
||||||
|
'dist/src/api/api.js': patch_api_js,
|
||||||
|
'src/api/types.ts': patch_types_ts,
|
||||||
|
}
|
||||||
|
changed: dict[str, bool] = {}
|
||||||
|
for relative, transform in paths.items():
|
||||||
|
path = plugin_root / relative
|
||||||
|
if not path.exists():
|
||||||
|
raise RuntimeError(f'missing plugin file: {path}')
|
||||||
|
original = path.read_text(encoding='utf-8')
|
||||||
|
changed[relative] = write_if_changed(path, transform(original))
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('plugin_root', type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
changed = apply(args.plugin_root.resolve())
|
||||||
|
print(json.dumps({'plugin_root': str(args.plugin_root.resolve()), 'changed': changed}, ensure_ascii=False))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+2726
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def replace_required(text: str, old: str, new: str, label: str) -> str:
|
||||||
|
if new in text:
|
||||||
|
return text
|
||||||
|
if old not in text:
|
||||||
|
raise RuntimeError(f'{label} marker not found')
|
||||||
|
return text.replace(old, new, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def patch_send_ts(text: str) -> str:
|
||||||
|
text = replace_required(
|
||||||
|
text,
|
||||||
|
'}): Promise<{ messageId: string }> {\n const { to, text, opts } = params;\n',
|
||||||
|
'}): Promise<{ messageId: string; createTimeMs: number }> {\n const { to, text, opts } = params;\n',
|
||||||
|
'TS send return type',
|
||||||
|
)
|
||||||
|
text = replace_required(
|
||||||
|
text,
|
||||||
|
' const clientId = generateClientId();\n const req = buildSendMessageReq({',
|
||||||
|
' const clientId = generateClientId();\n const createTimeMs = Date.now();\n const req = buildSendMessageReq({',
|
||||||
|
'TS create time',
|
||||||
|
)
|
||||||
|
text = replace_required(
|
||||||
|
text,
|
||||||
|
' });\n try {\n await sendMessageApi({',
|
||||||
|
' });\n if (!req.msg?.item_list?.[0]) throw new Error("sendMessageWeixin: text item missing");\n req.msg.item_list[0].create_time_ms = createTimeMs;\n try {\n await sendMessageApi({',
|
||||||
|
'TS request timestamp',
|
||||||
|
)
|
||||||
|
text = replace_required(
|
||||||
|
text,
|
||||||
|
' return { messageId: clientId };\n}\n\n/** Send a single structured MessageItem downstream. */',
|
||||||
|
' return { messageId: clientId, createTimeMs };\n}\n\n/** Send a single structured MessageItem downstream. */',
|
||||||
|
'TS send result',
|
||||||
|
)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def patch_send_js(text: str) -> str:
|
||||||
|
text = replace_required(
|
||||||
|
text,
|
||||||
|
' const clientId = generateClientId();\n const req = buildSendMessageReq({',
|
||||||
|
' const clientId = generateClientId();\n const createTimeMs = Date.now();\n const req = buildSendMessageReq({',
|
||||||
|
'JS create time',
|
||||||
|
)
|
||||||
|
text = replace_required(
|
||||||
|
text,
|
||||||
|
' });\n try {\n await sendMessageApi({',
|
||||||
|
' });\n if (!req.msg?.item_list?.[0])\n throw new Error("sendMessageWeixin: text item missing");\n req.msg.item_list[0].create_time_ms = createTimeMs;\n try {\n await sendMessageApi({',
|
||||||
|
'JS request timestamp',
|
||||||
|
)
|
||||||
|
text = replace_required(
|
||||||
|
text,
|
||||||
|
' return { messageId: clientId };\n}\n/** Send a single structured MessageItem downstream. */',
|
||||||
|
' return { messageId: clientId, createTimeMs };\n}\n/** Send a single structured MessageItem downstream. */',
|
||||||
|
'JS send result',
|
||||||
|
)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def patch_process_ts(text: str) -> str:
|
||||||
|
old = '''await sendMessageWeixin({ to: ctx.To, text, opts: {
|
||||||
|
baseUrl: deps.baseUrl,
|
||||||
|
token: deps.token,
|
||||||
|
contextToken,
|
||||||
|
runId,
|
||||||
|
}});'''
|
||||||
|
new = '''const sentText = await sendMessageWeixin({ to: ctx.To, text, opts: {
|
||||||
|
baseUrl: deps.baseUrl,
|
||||||
|
token: deps.token,
|
||||||
|
contextToken,
|
||||||
|
runId,
|
||||||
|
}});
|
||||||
|
rememberHermesOutboundTextMessage(deps.accountId, ctx.To, text, sentText.createTimeMs);'''
|
||||||
|
text = replace_required(text, old, new, 'TS fallback text send')
|
||||||
|
old = '''await sendMessageWeixin({ to: ctx.To, text, opts: {
|
||||||
|
baseUrl: deps.baseUrl,
|
||||||
|
token: deps.token,
|
||||||
|
contextToken,
|
||||||
|
runId,
|
||||||
|
}});'''
|
||||||
|
new = '''const sentText = await sendMessageWeixin({ to: ctx.To, text, opts: {
|
||||||
|
baseUrl: deps.baseUrl,
|
||||||
|
token: deps.token,
|
||||||
|
contextToken,
|
||||||
|
runId,
|
||||||
|
}});
|
||||||
|
rememberHermesOutboundTextMessage(deps.accountId, ctx.To, text, sentText.createTimeMs);'''
|
||||||
|
return replace_required(text, old, new, 'TS normal text send')
|
||||||
|
|
||||||
|
|
||||||
|
def patch_process_js(text: str) -> str:
|
||||||
|
old = '''await sendMessageWeixin({ to: ctx.To, text, opts: {
|
||||||
|
baseUrl: deps.baseUrl,
|
||||||
|
token: deps.token,
|
||||||
|
contextToken,
|
||||||
|
runId,
|
||||||
|
} });'''
|
||||||
|
new = '''const sentText = await sendMessageWeixin({ to: ctx.To, text, opts: {
|
||||||
|
baseUrl: deps.baseUrl,
|
||||||
|
token: deps.token,
|
||||||
|
contextToken,
|
||||||
|
runId,
|
||||||
|
} });
|
||||||
|
rememberHermesOutboundTextMessage(deps.accountId, ctx.To, text, sentText.createTimeMs);'''
|
||||||
|
text = replace_required(text, old, new, 'JS fallback text send')
|
||||||
|
old = '''await sendMessageWeixin({ to: ctx.To, text, opts: {
|
||||||
|
baseUrl: deps.baseUrl,
|
||||||
|
token: deps.token,
|
||||||
|
contextToken,
|
||||||
|
runId,
|
||||||
|
} });'''
|
||||||
|
new = '''const sentText = await sendMessageWeixin({ to: ctx.To, text, opts: {
|
||||||
|
baseUrl: deps.baseUrl,
|
||||||
|
token: deps.token,
|
||||||
|
contextToken,
|
||||||
|
runId,
|
||||||
|
} });
|
||||||
|
rememberHermesOutboundTextMessage(deps.accountId, ctx.To, text, sentText.createTimeMs);'''
|
||||||
|
return replace_required(text, old, new, 'JS normal text send')
|
||||||
|
|
||||||
|
|
||||||
|
def apply(root: Path) -> dict[str, bool]:
|
||||||
|
transforms = {
|
||||||
|
'src/messaging/send.ts': patch_send_ts,
|
||||||
|
'dist/src/messaging/send.js': patch_send_js,
|
||||||
|
'src/messaging/process-message.ts': patch_process_ts,
|
||||||
|
'dist/src/messaging/process-message.js': patch_process_js,
|
||||||
|
}
|
||||||
|
changed = {}
|
||||||
|
for relative, transform in transforms.items():
|
||||||
|
path = root / relative
|
||||||
|
original = path.read_text(encoding='utf-8')
|
||||||
|
updated = transform(original)
|
||||||
|
changed[relative] = updated != original
|
||||||
|
if updated != original:
|
||||||
|
path.write_text(updated, encoding='utf-8')
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('plugin_root', type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
root = args.plugin_root.resolve()
|
||||||
|
print(json.dumps({'plugin_root': str(root), 'changed': apply(root)}, ensure_ascii=False))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"type": "branch",
|
||||||
|
"branch": "deploy/v1r4c6-production-20260711",
|
||||||
|
"base": "main",
|
||||||
|
"created_by": "chatgpt-gitops-bot",
|
||||||
|
"created_at": "2026-07-11T13:10:51Z",
|
||||||
|
"direct_main_push": false,
|
||||||
|
"owner_approval_required": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
PATCHER_PATH = Path(__file__).resolve().parents[1] / 'fixes' / 'patch_openclaw_weixin_bigint.py'
|
||||||
|
|
||||||
|
|
||||||
|
def load_patcher():
|
||||||
|
spec = importlib.util.spec_from_file_location('patch_openclaw_weixin_bigint', PATCHER_PATH)
|
||||||
|
assert spec is not None and spec.loader is not None
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_patch_is_idempotent_and_scoped() -> None:
|
||||||
|
patcher = load_patcher()
|
||||||
|
ts = '\n'.join([
|
||||||
|
'import { fileURLToPath } from "node:url";',
|
||||||
|
'import { redactBody, redactUrl } from "../util/redact.js";',
|
||||||
|
'const resp: GetUpdatesResp = JSON.parse(rawText);',
|
||||||
|
])
|
||||||
|
js = '\n'.join([
|
||||||
|
'import { fileURLToPath } from "node:url";',
|
||||||
|
'import { redactBody, redactUrl } from "../util/redact.js";',
|
||||||
|
'const resp = JSON.parse(rawText);',
|
||||||
|
])
|
||||||
|
types = 'export interface WeixinMessage {\n message_id?: number;\n}\n'
|
||||||
|
|
||||||
|
ts_once = patcher.patch_api_ts(ts)
|
||||||
|
js_once = patcher.patch_api_js(js)
|
||||||
|
types_once = patcher.patch_types_ts(types)
|
||||||
|
|
||||||
|
assert patcher.patch_api_ts(ts_once) == ts_once
|
||||||
|
assert patcher.patch_api_js(js_once) == js_once
|
||||||
|
assert patcher.patch_types_ts(types_once) == types_once
|
||||||
|
assert 'parseWeixinGetUpdatesJson(rawText)' in ts_once
|
||||||
|
assert 'parseWeixinGetUpdatesJson(rawText)' in js_once
|
||||||
|
assert 'message_id?: string | number;' in types_once
|
||||||
|
|
||||||
|
|
||||||
|
def test_node_reviver_preserves_only_weixin_exact_ids() -> None:
|
||||||
|
script = '''
|
||||||
|
const ids = new Set(["message_id", "msg_id", "root_id", "parent_id", "server_msg_id", "new_msg_id"]);
|
||||||
|
const parsed = JSON.parse('{"ret":0,"seq":42,"msgs":[{"message_id":7482038281766381234,"create_time_ms":1783856936191,"item_list":[{"msg_id":"3123141975110186863"}]}]}', (key, value, context) => {
|
||||||
|
if (!ids.has(key) || value == null || typeof value === "string") return value;
|
||||||
|
if (typeof value !== "number" || !context?.source || !/^(?:0|[1-9]\\d*)$/.test(context.source)) throw new Error("unsafe id");
|
||||||
|
return context.source;
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify({ messageId: parsed.msgs[0].message_id, msgId: parsed.msgs[0].item_list[0].msg_id, seqType: typeof parsed.seq, timestampType: typeof parsed.msgs[0].create_time_ms }));
|
||||||
|
'''
|
||||||
|
completed = subprocess.run(['node', '-e', script], check=True, capture_output=True, text=True)
|
||||||
|
result = json.loads(completed.stdout)
|
||||||
|
assert result == {
|
||||||
|
'messageId': '7482038281766381234',
|
||||||
|
'msgId': '3123141975110186863',
|
||||||
|
'seqType': 'number',
|
||||||
|
'timestampType': 'number',
|
||||||
|
}
|
||||||
@@ -0,0 +1,563 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def load_js_process_helpers() -> str:
|
||||||
|
patcher_path = Path(__file__).resolve().parents[1] / 'fixes' / 'patch_openclaw_weixin_hermes.py'
|
||||||
|
assert patcher_path.exists(), f'missing patcher: {patcher_path}'
|
||||||
|
spec = importlib.util.spec_from_file_location('patch_openclaw_weixin_hermes', patcher_path)
|
||||||
|
assert spec is not None and spec.loader is not None
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module.JS_PROCESS_HELPERS
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def load_patcher_module() -> object:
|
||||||
|
patcher_path = Path(__file__).resolve().parents[1] / 'fixes' / 'patch_openclaw_weixin_hermes.py'
|
||||||
|
assert patcher_path.exists(), f'missing patcher: {patcher_path}'
|
||||||
|
spec = importlib.util.spec_from_file_location('patch_openclaw_weixin_hermes', patcher_path)
|
||||||
|
assert spec is not None and spec.loader is not None
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def run_js_process_helpers(tmp_path: Path, body: str) -> dict:
|
||||||
|
js_process_helpers = load_js_process_helpers()
|
||||||
|
node_prelude = '\n'.join(
|
||||||
|
[
|
||||||
|
'const hermesTestFs = require("node:fs");',
|
||||||
|
'globalThis.existsSync = hermesTestFs.existsSync;',
|
||||||
|
'globalThis.readFileSync = hermesTestFs.readFileSync;',
|
||||||
|
'globalThis.writeFileSync = hermesTestFs.writeFileSync;',
|
||||||
|
'globalThis.mkdirSync = hermesTestFs.mkdirSync;',
|
||||||
|
'globalThis.appendFileSync = hermesTestFs.appendFileSync;',
|
||||||
|
'globalThis.path = require("node:path");',
|
||||||
|
'globalThis.logger = { info() {}, warn() {}, error() {} };',
|
||||||
|
]
|
||||||
|
)
|
||||||
|
node_script = tmp_path / 'hermes_image_followup_check.js'
|
||||||
|
node_script.write_text(node_prelude + '\n\n' + js_process_helpers + '\n\n' + body, encoding='utf-8')
|
||||||
|
completed = subprocess.run(['node', str(node_script)], check=True, capture_output=True, text=True)
|
||||||
|
return json.loads(completed.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('is_ts', [True, False], ids=['ts', 'js'])
|
||||||
|
def test_replace_or_insert_process_helpers_is_byte_idempotent(is_ts: bool) -> None:
|
||||||
|
patcher = load_patcher_module()
|
||||||
|
marker = 'export async function processOneMessage'
|
||||||
|
source = marker + '(message) {\n return message;\n}\n'
|
||||||
|
|
||||||
|
once = patcher.replace_or_insert_process_helpers(source, is_ts=is_ts)
|
||||||
|
twice = patcher.replace_or_insert_process_helpers(once, is_ts=is_ts)
|
||||||
|
helpers = patcher.TS_PROCESS_HELPERS if is_ts else patcher.JS_PROCESS_HELPERS
|
||||||
|
|
||||||
|
assert helpers + '\n\n' + marker in once
|
||||||
|
assert twice == once
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
('is_ts', 'hook_name'),
|
||||||
|
[(True, 'TS_PROCESS_HOOK'), (False, 'JS_PROCESS_HOOK')],
|
||||||
|
ids=['ts', 'js'],
|
||||||
|
)
|
||||||
|
def test_remove_and_reinsert_process_hook_is_byte_idempotent(is_ts: bool, hook_name: str) -> None:
|
||||||
|
patcher = load_patcher_module()
|
||||||
|
hook = getattr(patcher, hook_name)
|
||||||
|
indent = ' ' if is_ts else ' '
|
||||||
|
needle = indent + 'ctx.CommandAuthorized = commandAuthorized;\n'
|
||||||
|
next_nonblank = indent + 'continueProcessing();\n'
|
||||||
|
|
||||||
|
def cycle(text: str) -> str:
|
||||||
|
without_hook = patcher.remove_existing_process_hermes_hook(text)
|
||||||
|
return without_hook.replace(needle, needle + hook + '\n', 1)
|
||||||
|
|
||||||
|
once = cycle(needle + next_nonblank)
|
||||||
|
twice = cycle(once)
|
||||||
|
|
||||||
|
assert hook + '\n' + next_nonblank in once
|
||||||
|
assert twice == once
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_looks_like_hermes_image_followup_distinguishes_diagnostics_from_real_image_questions(tmp_path: Path) -> None:
|
||||||
|
cases = {
|
||||||
|
'diag_cache_expired': ('\u56fe\u7247\u7f13\u5b58\u5df2\u5931\u6548\uff0c\u8bf7\u91cd\u65b0\u53d1\u9001\u56fe\u7247\u3002', False),
|
||||||
|
'diag_cache_error_en': ('image cache error', False),
|
||||||
|
'diag_cache_expired_en': ('the image cache expired, please resend', False),
|
||||||
|
'diag_copying_text_shows_cache_error': ('\u4e3a\u4ec0\u4e48\u6211\u590d\u5236\u53d1\u7ed9\u4f60\u7684\u6587\u5b57\uff0c\u4f60\u90fd\u663e\u793a\u56fe\u7247\u7f13\u5b58\u5df2\u5931\u6548\uff0c\u8bf7\u91cd\u65b0\u53d1\u9001\u56fe\u7247\u3002', False),
|
||||||
|
'diag_not_image': ('\u8fd9\u4e0d\u662f\u56fe\u7247\uff0c\u662f\u6587\u5b57\u6d88\u606f', False),
|
||||||
|
'diag_text_not_image_en': ('this is a text message, not an image', False),
|
||||||
|
'diag_no_image_sent': ('\u6211\u6ca1\u6709\u53d1\u56fe\u7247', False),
|
||||||
|
'diag_system_logs': ('\u8bf7\u68c0\u67e5\u7cfb\u7edf\u65e5\u5fd7\uff0c\u4ece\u673a\u5236\u4e0a\u4fee\u590d\u9519\u8bef', False),
|
||||||
|
'image_cn_1': ('\u8bf7\u770b\u4e00\u4e0b\u8fd9\u5f20\u56fe\u7247', True),
|
||||||
|
'image_cn_2': ('\u8fd9\u56fe\u662f\u4ec0\u4e48', True),
|
||||||
|
'image_cn_3': ('\u8bc6\u522b\u56fe\u7247\u5185\u5bb9', True),
|
||||||
|
'image_en': ('look at this image', True),
|
||||||
|
}
|
||||||
|
results = run_js_process_helpers(
|
||||||
|
tmp_path,
|
||||||
|
'const cases = '
|
||||||
|
+ json.dumps({key: text for key, (text, _) in cases.items()}, ensure_ascii=False)
|
||||||
|
+ ';\nconst results = {};\n'
|
||||||
|
+ 'for (const [key, text] of Object.entries(cases)) {\n'
|
||||||
|
+ ' results[key] = looksLikeHermesImageFollowup(text);\n'
|
||||||
|
+ '}\n'
|
||||||
|
+ 'console.log(JSON.stringify(results));\n',
|
||||||
|
)
|
||||||
|
for key, (_, expected) in cases.items():
|
||||||
|
assert results[key] is expected, f'{key}: expected {expected}, got {results[key]!r}'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_hermes_referenced_image_followup_requires_exact_cache(tmp_path: Path) -> None:
|
||||||
|
cache_path = tmp_path / 'hermes-last-image-cache.json'
|
||||||
|
results = run_js_process_helpers(
|
||||||
|
tmp_path,
|
||||||
|
'\n'.join(
|
||||||
|
[
|
||||||
|
'const fs = require("fs");',
|
||||||
|
f'const cachePath = {json.dumps(str(cache_path))};',
|
||||||
|
'const accountId = "account-a";',
|
||||||
|
'const senderId = "sender-b";',
|
||||||
|
'const exactQuotedId = "quoted-123";',
|
||||||
|
'const cache = {};',
|
||||||
|
'cache[hermesLastImageCacheKey(accountId, senderId)] = {',
|
||||||
|
' mediaUrl: "data:image/png;base64,iVBORw0KGgo=",',
|
||||||
|
' timestampMs: Date.now(),',
|
||||||
|
' messageId: "latest-999",',
|
||||||
|
' mediaType: "image/png",',
|
||||||
|
'};',
|
||||||
|
'cache[hermesExactImageCacheKey(accountId, senderId, exactQuotedId)] = {',
|
||||||
|
' mediaUrl: "data:image/png;base64,iVBORw0KGgo=",',
|
||||||
|
' timestampMs: Date.now(),',
|
||||||
|
' messageId: exactQuotedId,',
|
||||||
|
' mediaType: "image/png",',
|
||||||
|
'};',
|
||||||
|
'fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2), "utf-8");',
|
||||||
|
'process.env.AIOS_HERMES_WEIXIN_LAST_IMAGE_CACHE = cachePath;',
|
||||||
|
'const generic = resolveHermesReferencedImageFollowup(accountId, senderId, "plain quoted text", "quoted-999");',
|
||||||
|
'const exact = resolveHermesReferencedImageFollowup(accountId, senderId, "plain quoted text", exactQuotedId);',
|
||||||
|
'const forced = resolveHermesFollowupImageContext(accountId, senderId, "plain quoted text", exactQuotedId, false, true);',
|
||||||
|
'console.log(JSON.stringify({ generic, exact, forced }));',
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert results['generic']['imageIntent'] is False
|
||||||
|
assert results['generic']['matched'] is False
|
||||||
|
assert results['generic']['reason'] == 'referenced_image_not_confirmed'
|
||||||
|
assert results['exact']['imageIntent'] is True
|
||||||
|
assert results['exact']['matched'] is True
|
||||||
|
assert results['exact']['reason'] == 'cached_image_matched'
|
||||||
|
assert results['forced']['imageIntent'] is True
|
||||||
|
assert results['forced']['matched'] is True
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_referenced_text_without_media_evidence_stays_out_of_image_cache(tmp_path: Path) -> None:
|
||||||
|
cache_path = tmp_path / 'hermes-last-image-cache.json'
|
||||||
|
results = run_js_process_helpers(
|
||||||
|
tmp_path,
|
||||||
|
'\n'.join(
|
||||||
|
[
|
||||||
|
'const fs = require("fs");',
|
||||||
|
f'const cachePath = {json.dumps(str(cache_path))};',
|
||||||
|
'const accountId = "account-a";',
|
||||||
|
'const senderId = "sender-b";',
|
||||||
|
'const cache = {};',
|
||||||
|
'cache[hermesLastImageCacheKey(accountId, senderId)] = {',
|
||||||
|
' mediaUrl: "data:image/png;base64,iVBORw0KGgo=",',
|
||||||
|
' timestampMs: Date.now(),',
|
||||||
|
' messageId: "latest-999",',
|
||||||
|
' mediaType: "image/png",',
|
||||||
|
'};',
|
||||||
|
'fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2), "utf-8");',
|
||||||
|
'process.env.AIOS_HERMES_WEIXIN_LAST_IMAGE_CACHE = cachePath;',
|
||||||
|
'const full = { item_list: [{ ref_msg: { message_item: { type: 0, id: "quoted-999" } } }] };',
|
||||||
|
'const hermesContent = "\u8fd9\u662f\u4ec0\u4e48";',
|
||||||
|
'const hermesReferenceContextPresent = hasHermesReferenceContext(full.item_list);',
|
||||||
|
'const hermesQuotedMessageId = findHermesReferencedMessageId(full.item_list);',
|
||||||
|
'const refMediaItem = undefined;',
|
||||||
|
'const hermesReferencedMediaIsImage = Boolean(refMediaItem && hermesCurrentMessageIsImage({}, refMediaItem));',
|
||||||
|
'const hermesReferencedImageFollowupIntent = hermesReferenceContextPresent && !refMediaItem;',
|
||||||
|
'const hermesCurrentImage = false;',
|
||||||
|
'const hermesFollowup = hermesCurrentImage',
|
||||||
|
' ? { imageIntent: false, matched: false, reason: "current_image_message", cacheStrategy: "current", quotedMessageId: hermesQuotedMessageId }',
|
||||||
|
' : (hermesReferencedMediaIsImage',
|
||||||
|
' ? resolveHermesFollowupImageContext(accountId, senderId, hermesContent, hermesQuotedMessageId, false, true)',
|
||||||
|
' : (hermesReferencedImageFollowupIntent',
|
||||||
|
' ? resolveHermesReferencedImageFollowup(accountId, senderId, hermesContent, hermesQuotedMessageId)',
|
||||||
|
' : resolveHermesFollowupImageContext(accountId, senderId, hermesContent)));',
|
||||||
|
'console.log(JSON.stringify({ hermesReferenceContextPresent, hermesQuotedMessageId, hermesReferencedMediaIsImage, hermesReferencedImageFollowupIntent, hermesFollowup }));',
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert results['hermesReferenceContextPresent'] is True
|
||||||
|
assert results['hermesQuotedMessageId'] == 'quoted-999'
|
||||||
|
assert results['hermesReferencedMediaIsImage'] is False
|
||||||
|
assert results['hermesReferencedImageFollowupIntent'] is True
|
||||||
|
assert results['hermesFollowup']['imageIntent'] is False
|
||||||
|
assert results['hermesFollowup']['matched'] is False
|
||||||
|
assert results['hermesFollowup']['reason'] == 'referenced_image_not_confirmed'
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_quoted_text_builds_grounded_plain_text_context(tmp_path: Path) -> None:
|
||||||
|
results = run_js_process_helpers(
|
||||||
|
tmp_path,
|
||||||
|
'\n'.join(
|
||||||
|
[
|
||||||
|
'const available = typeof resolveHermesQuotedTextContext === "function";',
|
||||||
|
'const items = [{ type: 1, ref_msg: { message_item: { type: 1, text_item: { text: "被引用的正文" } } } }];',
|
||||||
|
'const result = available ? resolveHermesQuotedTextContext(items, "这是什么意思?") : null;',
|
||||||
|
'console.log(JSON.stringify({ available, result }));',
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert results['available'] is True
|
||||||
|
assert results['result'] == {
|
||||||
|
'referenced': True,
|
||||||
|
'handled': False,
|
||||||
|
'prompt': '[被引用文字]\n被引用的正文\n[/被引用文字]\n\n[当前问题]\n这是什么意思?\n[/当前问题]',
|
||||||
|
'reason': 'quoted_text_resolved',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_visible_ref_title_builds_grounded_plain_text_context(tmp_path: Path) -> None:
|
||||||
|
results = run_js_process_helpers(
|
||||||
|
tmp_path,
|
||||||
|
'\n'.join(
|
||||||
|
[
|
||||||
|
'const items = [{ type: 1, ref_msg: { title: "客户端可见的引用正文", message_item: { type: 0, msg_id: "quoted-123" } } }];',
|
||||||
|
'const result = resolveHermesQuotedTextContext(items, "这是什么意思?");',
|
||||||
|
'console.log(JSON.stringify(result));',
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert results == {
|
||||||
|
'referenced': True,
|
||||||
|
'handled': False,
|
||||||
|
'prompt': '[被引用文字]\n客户端可见的引用正文\n[/被引用文字]\n\n[当前问题]\n这是什么意思?\n[/当前问题]',
|
||||||
|
'reason': 'quoted_text_resolved',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_nested_generic_title_is_not_treated_as_quoted_text(tmp_path: Path) -> None:
|
||||||
|
results = run_js_process_helpers(
|
||||||
|
tmp_path,
|
||||||
|
'\n'.join(
|
||||||
|
[
|
||||||
|
'const items = [{ type: 1, ref_msg: { message_item: { type: 0, title: "非协议引用摘要" } } }];',
|
||||||
|
'const result = resolveHermesQuotedTextContext(items, "这是什么意思?");',
|
||||||
|
'console.log(JSON.stringify(result));',
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert results == {
|
||||||
|
'referenced': True,
|
||||||
|
'handled': True,
|
||||||
|
'replyText': '无法读取被引用文字,请复制原文后再提问。',
|
||||||
|
'reason': 'quoted_text_unavailable',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_message_id_cache_resolves_server_reference_without_guessing(tmp_path: Path) -> None:
|
||||||
|
cache_path = tmp_path / 'hermes-message-text-cache.json'
|
||||||
|
results = run_js_process_helpers(
|
||||||
|
tmp_path,
|
||||||
|
'\n'.join(
|
||||||
|
[
|
||||||
|
f'process.env.AIOS_HERMES_WEIXIN_TEXT_MESSAGE_CACHE = {json.dumps(str(cache_path))};',
|
||||||
|
'const accountId = "account-a";',
|
||||||
|
'const senderId = "sender-b";',
|
||||||
|
'const original = { item_list: [{ type: 1, msg_id: "7481729105240418312", text_item: { text: "客户端已经显示的原始正文" } }] };',
|
||||||
|
'rememberHermesTextMessage(accountId, senderId, original, "客户端已经显示的原始正文");',
|
||||||
|
'const quotedItems = [{ type: 1, ref_msg: { message_item: { type: 0, msg_id: "7481729105240418312" } }, text_item: { text: "这是什么意思?" } }];',
|
||||||
|
'const quotedId = findHermesReferencedMessageId(quotedItems);',
|
||||||
|
'const exact = resolveHermesCachedQuotedText(accountId, senderId, quotedId);',
|
||||||
|
'const wrong = resolveHermesCachedQuotedText(accountId, senderId, "7481729105240418313");',
|
||||||
|
'const resolved = resolveHermesQuotedTextContext(quotedItems, "这是什么意思?", exact);',
|
||||||
|
'const mode = (hermesTestFs.statSync(process.env.AIOS_HERMES_WEIXIN_TEXT_MESSAGE_CACHE).mode & 0o777).toString(8);',
|
||||||
|
'console.log(JSON.stringify({ quotedId, exact, wrong, resolved, mode }));',
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert results['quotedId'] == '7481729105240418312'
|
||||||
|
assert results['exact'] == '客户端已经显示的原始正文'
|
||||||
|
assert results['wrong'] == ''
|
||||||
|
assert results['resolved'] == {
|
||||||
|
'referenced': True,
|
||||||
|
'handled': False,
|
||||||
|
'prompt': '[被引用文字]\n客户端已经显示的原始正文\n[/被引用文字]\n\n[当前问题]\n这是什么意思?\n[/当前问题]',
|
||||||
|
'reason': 'quoted_text_resolved',
|
||||||
|
}
|
||||||
|
assert results['mode'] == '600'
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_message_id_cache_entry_is_not_resolved(tmp_path: Path) -> None:
|
||||||
|
cache_path = tmp_path / 'hermes-message-text-cache.json'
|
||||||
|
results = run_js_process_helpers(
|
||||||
|
tmp_path,
|
||||||
|
'\n'.join(
|
||||||
|
[
|
||||||
|
f'process.env.AIOS_HERMES_WEIXIN_TEXT_MESSAGE_CACHE = {json.dumps(str(cache_path))};',
|
||||||
|
'const key = hermesTextMessageCacheKey("account-a", "sender-b", "quoted-old");',
|
||||||
|
'hermesTestFs.writeFileSync(process.env.AIOS_HERMES_WEIXIN_TEXT_MESSAGE_CACHE, JSON.stringify({ [key]: { text: "过期正文", timestampMs: Date.now() - (8 * 24 * 60 * 60 * 1000) } }), { encoding: "utf-8", mode: 0o600 });',
|
||||||
|
'const result = resolveHermesCachedQuotedText("account-a", "sender-b", "quoted-old");',
|
||||||
|
'console.log(JSON.stringify({ result }));',
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert results['result'] == ''
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsafe_numeric_message_id_is_rejected_before_cache_keying(tmp_path: Path) -> None:
|
||||||
|
results = run_js_process_helpers(
|
||||||
|
tmp_path,
|
||||||
|
'\n'.join(
|
||||||
|
[
|
||||||
|
'const exactString = extractHermesMessageId({ message_id: "7482038281766381234" });',
|
||||||
|
'const unsafeNumber = extractHermesMessageId({ message_id: 7482038281766381234 });',
|
||||||
|
'const safeNumber = extractHermesMessageId({ message_id: 12345 });',
|
||||||
|
'console.log(JSON.stringify({ exactString, unsafeNumber: unsafeNumber ?? null, safeNumber }));',
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert results == {
|
||||||
|
'exactString': '7482038281766381234',
|
||||||
|
'unsafeNumber': None,
|
||||||
|
'safeNumber': '12345',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_outbound_create_time_resolves_quoted_bot_text(tmp_path: Path) -> None:
|
||||||
|
cache_path = tmp_path / 'hermes-message-text-cache.json'
|
||||||
|
results = run_js_process_helpers(
|
||||||
|
tmp_path,
|
||||||
|
'\n'.join(
|
||||||
|
[
|
||||||
|
f'process.env.AIOS_HERMES_WEIXIN_TEXT_MESSAGE_CACHE = {json.dumps(str(cache_path))};',
|
||||||
|
'const accountId = "account-a";',
|
||||||
|
'const senderId = "sender-b";',
|
||||||
|
'const createTimeMs = 1783857900123;',
|
||||||
|
'rememberHermesOutboundTextMessage(accountId, senderId, "机器人出站正文", createTimeMs);',
|
||||||
|
'const quotedItems = [{ type: 1, ref_msg: { message_item: { type: 0, msg_id: "server-quoted", create_time_ms: createTimeMs } }, text_item: { text: "进展汇报" } }];',
|
||||||
|
'const referencedTime = findHermesReferencedCreateTimeMs(quotedItems);',
|
||||||
|
'const exact = resolveHermesCachedQuotedText(accountId, senderId, "server-quoted", referencedTime);',
|
||||||
|
'const wrong = resolveHermesCachedQuotedText(accountId, senderId, "server-quoted", createTimeMs + 1);',
|
||||||
|
'const resolved = resolveHermesQuotedTextContext(quotedItems, "进展汇报", exact);',
|
||||||
|
'console.log(JSON.stringify({ referencedTime, exact, wrong, resolved }));',
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert results['referencedTime'] == '1783857900123'
|
||||||
|
assert results['exact'] == '机器人出站正文'
|
||||||
|
assert results['wrong'] == ''
|
||||||
|
assert results['resolved']['reason'] == 'quoted_text_resolved'
|
||||||
|
assert '机器人出站正文' in results['resolved']['prompt']
|
||||||
|
|
||||||
|
|
||||||
|
def test_outbound_create_time_collision_fails_closed(tmp_path: Path) -> None:
|
||||||
|
cache_path = tmp_path / 'hermes-message-text-cache.json'
|
||||||
|
results = run_js_process_helpers(
|
||||||
|
tmp_path,
|
||||||
|
'\n'.join(
|
||||||
|
[
|
||||||
|
f'process.env.AIOS_HERMES_WEIXIN_TEXT_MESSAGE_CACHE = {json.dumps(str(cache_path))};',
|
||||||
|
'const createTimeMs = 1783857900123;',
|
||||||
|
'rememberHermesOutboundTextMessage("account-a", "sender-b", "第一条", createTimeMs);',
|
||||||
|
'rememberHermesOutboundTextMessage("account-a", "sender-b", "第二条", createTimeMs);',
|
||||||
|
'const resolved = resolveHermesCachedQuotedText("account-a", "sender-b", undefined, createTimeMs);',
|
||||||
|
'console.log(JSON.stringify({ resolved }));',
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert results['resolved'] == ''
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_quoted_text_fails_closed_without_model_prompt(tmp_path: Path) -> None:
|
||||||
|
results = run_js_process_helpers(
|
||||||
|
tmp_path,
|
||||||
|
'\n'.join(
|
||||||
|
[
|
||||||
|
'const available = typeof resolveHermesQuotedTextContext === "function";',
|
||||||
|
'const items = [{ type: 1, ref_msg: { message_item: { type: 0, msg_id: "7481729105240418312" } } }];',
|
||||||
|
'const result = available ? resolveHermesQuotedTextContext(items, "这是什么意思?") : null;',
|
||||||
|
'let modelCalls = 0;',
|
||||||
|
'if (result && !result.handled) modelCalls += 1;',
|
||||||
|
'console.log(JSON.stringify({ available, result, modelCalls }));',
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert results['available'] is True
|
||||||
|
assert results['result'] == {
|
||||||
|
'referenced': True,
|
||||||
|
'handled': True,
|
||||||
|
'replyText': '无法读取被引用文字,请复制原文后再提问。',
|
||||||
|
'reason': 'quoted_text_unavailable',
|
||||||
|
}
|
||||||
|
assert results['modelCalls'] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('hook_name', ['TS_PROCESS_HOOK', 'JS_PROCESS_HOOK'])
|
||||||
|
def test_process_hook_stops_unavailable_quote_before_model_dispatch(hook_name: str) -> None:
|
||||||
|
patcher = load_patcher_module()
|
||||||
|
hook = getattr(patcher, hook_name)
|
||||||
|
guard_name = 'TS_QUOTED_TEXT_DISPATCH_GUARD' if hook_name.startswith('TS_') else 'JS_QUOTED_TEXT_DISPATCH_GUARD'
|
||||||
|
guard = getattr(patcher, guard_name)
|
||||||
|
|
||||||
|
assert 'rememberHermesTextMessage(deps.accountId, hermesSenderId, full, textBody)' in hook
|
||||||
|
assert 'findHermesReferencedCreateTimeMs(full.item_list)' in hook
|
||||||
|
assert 'resolveHermesCachedQuotedText(deps.accountId, hermesSenderId, hermesQuotedMessageId, hermesQuotedCreateTimeMs)' in hook
|
||||||
|
assert 'resolveHermesQuotedTextContext(full.item_list, hermesContent, hermesCachedQuotedText)' in hook
|
||||||
|
assert '!hermesQuotedTextContext.referenced && !textBody.startsWith("/")' in hook
|
||||||
|
assert 'ctx.Body = hermesQuotedTextContext.prompt' in hook
|
||||||
|
assert 'await dispatchHermesQuotedTextFailClosed(hermesQuotedTextContext, dispatcher)' in guard
|
||||||
|
assert 'markDispatchIdle();' in guard
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_quoted_text_fail_closed_uses_dispatcher_without_model(tmp_path: Path) -> None:
|
||||||
|
result = run_js_process_helpers(
|
||||||
|
tmp_path,
|
||||||
|
'\n'.join(
|
||||||
|
[
|
||||||
|
'(async () => {',
|
||||||
|
' const calls = [];',
|
||||||
|
' const dispatcher = {',
|
||||||
|
' sendFinalReply(payload) { calls.push(["final", payload]); },',
|
||||||
|
' markComplete() { calls.push(["complete"]); },',
|
||||||
|
' async waitForIdle() { calls.push(["idle"]); },',
|
||||||
|
' };',
|
||||||
|
' const context = resolveHermesQuotedTextContext([{ ref_msg: { message_item: { type: 0, msg_id: "quoted-1" } } }], "这是什么意思?");',
|
||||||
|
' let modelCalls = 0;',
|
||||||
|
' const handled = await dispatchHermesQuotedTextFailClosed(context, dispatcher);',
|
||||||
|
' if (!handled) modelCalls += 1;',
|
||||||
|
' console.log(JSON.stringify({ handled, modelCalls, calls }));',
|
||||||
|
'})();',
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result['handled'] is True
|
||||||
|
assert result['modelCalls'] == 0
|
||||||
|
assert result['calls'] == [
|
||||||
|
['final', {'text': '无法读取被引用文字,请复制原文后再提问。'}],
|
||||||
|
['complete'],
|
||||||
|
['idle'],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
('is_ts', 'guard_name', 'needle'),
|
||||||
|
[
|
||||||
|
(True, 'TS_QUOTED_TEXT_DISPATCH_GUARD', ' logger.debug(`dispatchReplyFromConfig: starting agentId=${route.agentId ?? "(none)"}`);\n'),
|
||||||
|
(False, 'JS_QUOTED_TEXT_DISPATCH_GUARD', ' logger.debug(`dispatchReplyFromConfig: starting agentId=${route.agentId ?? "(none)"}`);\n'),
|
||||||
|
],
|
||||||
|
ids=['ts', 'js'],
|
||||||
|
)
|
||||||
|
def test_dispatch_guard_remove_and_reinsert_is_byte_idempotent(is_ts: bool, guard_name: str, needle: str) -> None:
|
||||||
|
patcher = load_patcher_module()
|
||||||
|
guard = getattr(patcher, guard_name)
|
||||||
|
|
||||||
|
def cycle(text: str) -> str:
|
||||||
|
without = patcher.remove_existing_process_hermes_hook(text)
|
||||||
|
return without.replace(needle, guard + '\n' + needle, 1)
|
||||||
|
|
||||||
|
once = cycle(needle)
|
||||||
|
twice = cycle(once)
|
||||||
|
|
||||||
|
assert guard + '\n' + needle in once
|
||||||
|
assert twice == once
|
||||||
|
|
||||||
|
|
||||||
|
def test_reference_metadata_content_is_not_treated_as_quoted_text(tmp_path: Path) -> None:
|
||||||
|
result = run_js_process_helpers(
|
||||||
|
tmp_path,
|
||||||
|
'\n'.join(
|
||||||
|
[
|
||||||
|
'const items = [{ ref_msg: { message_item: { type: 0, msg_id: "quoted-1", content: "metadata only" } } }];',
|
||||||
|
'console.log(JSON.stringify(resolveHermesQuotedTextContext(items, "这是什么意思?")));',
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result['handled'] is True
|
||||||
|
assert result['reason'] == 'quoted_text_unavailable'
|
||||||
|
assert result['replyText'] == '无法读取被引用文字,请复制原文后再提问。'
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_plugin_process_only_skips_slash_and_channel(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
patcher = load_patcher_module()
|
||||||
|
calls: list[tuple[str, Path]] = []
|
||||||
|
|
||||||
|
def record(name: str, result):
|
||||||
|
def _inner(root: Path):
|
||||||
|
calls.append((name, root))
|
||||||
|
return result
|
||||||
|
|
||||||
|
return _inner
|
||||||
|
|
||||||
|
monkeypatch.setattr(patcher, 'patch_ts', record('patch_ts', False))
|
||||||
|
monkeypatch.setattr(patcher, 'patch_js', record('patch_js', False))
|
||||||
|
monkeypatch.setattr(patcher, 'patch_process_messages', record('patch_process_messages', {'src/messaging/process-message.ts': True, 'dist/src/messaging/process-message.js': False}))
|
||||||
|
monkeypatch.setattr(patcher, 'patch_channel_files', record('patch_channel_files', {'src/channel.ts': False, 'dist/src/channel.js': False}))
|
||||||
|
monkeypatch.setattr(patcher, 'verify_plugin_hook', record('verify_plugin_hook', {
|
||||||
|
'hook_status': {},
|
||||||
|
'hook_installed': False,
|
||||||
|
'process_status': {'src/messaging/process-message.ts': True, 'dist/src/messaging/process-message.js': True},
|
||||||
|
'process_hook_installed': True,
|
||||||
|
'channel_status': {},
|
||||||
|
'channel_hook_installed': False,
|
||||||
|
'dispatch_reference_found': False,
|
||||||
|
'dispatch_reference_files': [],
|
||||||
|
}))
|
||||||
|
|
||||||
|
result = patcher.patch_plugin(tmp_path, process_only=True)
|
||||||
|
|
||||||
|
assert calls == [
|
||||||
|
('patch_process_messages', tmp_path),
|
||||||
|
('verify_plugin_hook', tmp_path),
|
||||||
|
]
|
||||||
|
assert result['mode'] == 'process-only'
|
||||||
|
assert result['plugin_root'] == str(tmp_path)
|
||||||
|
assert result['slash_changes'] == {}
|
||||||
|
assert result['channel_changes'] == {}
|
||||||
|
assert result['process_changes'] == {'src/messaging/process-message.ts': True, 'dist/src/messaging/process-message.js': False}
|
||||||
|
assert result['behavior_changes'] == {'src/messaging/process-message.ts': True, 'dist/src/messaging/process-message.js': False}
|
||||||
|
assert result['verification']['process_hook_installed'] is True
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_args_supports_default_root_and_process_only_flag() -> None:
|
||||||
|
patcher = load_patcher_module()
|
||||||
|
|
||||||
|
defaults = patcher.parse_args([])
|
||||||
|
assert defaults.plugin_root == patcher.DEFAULT_PLUGIN_ROOT
|
||||||
|
assert defaults.process_only is False
|
||||||
|
|
||||||
|
process_only = patcher.parse_args(['--process-only'])
|
||||||
|
assert process_only.plugin_root == patcher.DEFAULT_PLUGIN_ROOT
|
||||||
|
assert process_only.process_only is True
|
||||||
|
|
||||||
|
custom_root = Path('/tmp/custom-plugin-root')
|
||||||
|
custom = patcher.parse_args([str(custom_root), '--process-only'])
|
||||||
|
assert custom.plugin_root == custom_root
|
||||||
|
assert custom.process_only is True
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
PATCHER_PATH = Path(__file__).resolve().parents[1] / 'fixes' / 'patch_openclaw_weixin_outbound_quotes.py'
|
||||||
|
|
||||||
|
|
||||||
|
def load_patcher():
|
||||||
|
spec = importlib.util.spec_from_file_location('patch_openclaw_weixin_outbound_quotes', PATCHER_PATH)
|
||||||
|
assert spec is not None and spec.loader is not None
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_patch_adds_exact_create_time_and_is_idempotent() -> None:
|
||||||
|
patcher = load_patcher()
|
||||||
|
ts = '''function buildTextMessageReq(params: {
|
||||||
|
to: string;
|
||||||
|
text: string;
|
||||||
|
contextToken?: string;
|
||||||
|
runId?: string;
|
||||||
|
clientId: string;
|
||||||
|
}): SendMessageReq {
|
||||||
|
const { to, text, contextToken, runId, clientId } = params;
|
||||||
|
return { msg: { client_id: clientId, message_type: MessageType.BOT } };
|
||||||
|
}
|
||||||
|
export async function sendMessageWeixin(params: {}): Promise<{ messageId: string }> {
|
||||||
|
const { to, text, opts } = params;
|
||||||
|
const clientId = generateClientId();
|
||||||
|
const req = buildSendMessageReq({
|
||||||
|
payload: {},
|
||||||
|
clientId,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await sendMessageApi({ body: req });
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return { messageId: clientId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a single structured MessageItem downstream. */'''
|
||||||
|
js = '''function buildTextMessageReq(params) {
|
||||||
|
const { to, text, contextToken, runId, clientId } = params;
|
||||||
|
return { msg: { client_id: clientId, message_type: MessageType.BOT } };
|
||||||
|
}
|
||||||
|
export async function sendMessageWeixin(params) {
|
||||||
|
const clientId = generateClientId();
|
||||||
|
const req = buildSendMessageReq({
|
||||||
|
payload: {},
|
||||||
|
clientId,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await sendMessageApi({ body: req });
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return { messageId: clientId };
|
||||||
|
}
|
||||||
|
/** Send a single structured MessageItem downstream. */'''
|
||||||
|
|
||||||
|
ts_once = patcher.patch_send_ts(ts)
|
||||||
|
js_once = patcher.patch_send_js(js)
|
||||||
|
assert patcher.patch_send_ts(ts_once) == ts_once
|
||||||
|
assert patcher.patch_send_js(js_once) == js_once
|
||||||
|
for result in (ts_once, js_once):
|
||||||
|
assert 'const createTimeMs = Date.now();' in result
|
||||||
|
assert 'text item missing' in result
|
||||||
|
assert 'req.msg.item_list[0].create_time_ms = createTimeMs' in result
|
||||||
|
assert 'req.msg.create_time_ms = createTimeMs' not in result
|
||||||
|
assert 'return { messageId: clientId, createTimeMs }' in result
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"version": "V2R1C1",
|
||||||
|
"codename": "openclaw-command-v2-production-baseline",
|
||||||
|
"status": "PRODUCTION_DEPLOYED",
|
||||||
|
"tag": "V2R1C1",
|
||||||
|
"tag_ref": "refs/tags/V2R1C1",
|
||||||
|
"tag_object": "4e0e6dcb90e6732aa9838fe9d0af0b34746829b0",
|
||||||
|
"previous_version": "V1R4C6",
|
||||||
|
"rollback_version": "V1R4C6",
|
||||||
|
"baseline": "V1R4C6",
|
||||||
|
"release_reason": "Register the already deployed AIOS V2R1C1 release, immutable tag, OpenClaw Command v2 companion, and final production evidence in the central GitOps Registry.",
|
||||||
|
"purpose": "Close the central version-governance gap without changing product implementation, the V2R1C1 tag, production release, deployment, or traffic.",
|
||||||
|
"changes": [
|
||||||
|
"governance: add the canonical V2R1C1 version manifest",
|
||||||
|
"governance: advance the central baseline pointer from V1R4C6 to V2R1C1",
|
||||||
|
"governance: link release, companion, artifact, validation, and final audit evidence"
|
||||||
|
],
|
||||||
|
"artifacts": [
|
||||||
|
"docs/releases/V2R1C1-BASELINE-CLOSURE.md",
|
||||||
|
".agent/results/releases/V2R1C1-BASELINE-CLOSURE.json",
|
||||||
|
".agent/results/releases/V2R1C1-BASELINE-CLOSURE.md",
|
||||||
|
"/var/lib/aios-gitops/acceptance/aios-v2r1c1-openclaw-v2-companion-20260721T094442Z"
|
||||||
|
],
|
||||||
|
"validated_actions": [
|
||||||
|
"release and tag OID equality",
|
||||||
|
"local and Gitea ref synchronization",
|
||||||
|
"OpenClaw Command v2 positive and negative validation",
|
||||||
|
"Gate A and Gate B",
|
||||||
|
"Hermes lifecycle 17/17",
|
||||||
|
"grant isolation 11/11",
|
||||||
|
"restart recovery",
|
||||||
|
"rollback and forward recovery",
|
||||||
|
"SQLite quick_check",
|
||||||
|
"secret scan with zero violations",
|
||||||
|
"final production healthcheck"
|
||||||
|
],
|
||||||
|
"secret_plaintext_written": false,
|
||||||
|
"owner_approval_required": true,
|
||||||
|
"owner_notification_channels": [
|
||||||
|
"codex_owner_instruction",
|
||||||
|
"gitops_audit"
|
||||||
|
],
|
||||||
|
"constraints_original": {
|
||||||
|
"product_code_changed": false,
|
||||||
|
"tag_changed": false,
|
||||||
|
"production_redeployed": false,
|
||||||
|
"production_traffic_changed": false,
|
||||||
|
"force_push_allowed": false,
|
||||||
|
"incubator_in_release_graph": false
|
||||||
|
},
|
||||||
|
"promoted_at": "2026-07-21T14:17:05Z",
|
||||||
|
"promoted_by": "chatgpt/owner-approved-governance",
|
||||||
|
"created_at": "2026-07-21T14:17:05Z",
|
||||||
|
"author": "chatgpt"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user