Skip to content

Add antianqi/openclaw-acp-bridge v0.1.3 - peer collaboration Bridge for MiniMax Code - #3

Open
antianqi wants to merge 7 commits into
MiniMax-AI:mainfrom
antianqi:add-openclaw-acp-bridge
Open

Add antianqi/openclaw-acp-bridge v0.1.3 - peer collaboration Bridge for MiniMax Code#3
antianqi wants to merge 7 commits into
MiniMax-AI:mainfrom
antianqi:add-openclaw-acp-bridge

Conversation

@antianqi

@antianqi antianqi commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds plugins/antianqi/openclaw-acp-bridge — a Bridge that lets MiniMax Code sessions collaborate peer-to-peer with the OpenClaw-mcode-ACP inbox protocol instead of one-shot master/slave task calls.

MiniMax Code can now:

  • Read incoming messages from the ACP inbox (inbox_read)
  • Push progress and partial answers (inbox_write)
  • Ask blocking questions and wait for the peer's answer (inbox_ask / inbox_answer)
  • Greet a new peer session (peer_greet)

Two Skills ship in the Plugin:

  • acp-collab — peer-to-peer inbox collaboration
  • acp-task-dispatch — dispatch self-contained tasks to the ACP HTTP server

What's inside

  • plugin.json$schema=agent-plugins.org/schemas/1.0.0/plugin.schema.json, name=openclaw-acp-bridge, version=0.1.3, license=Apache-2.0
  • README.md — overview, Supported platforms table, Authentication, SDK compatibility contract, smoke test, Data and network, Test evidence
  • LICENSE — Apache-2.0 (full text)
  • scripts/smoke.py — 5/5 checks pass against OpenClaw-mcode-ACP v7-bidir
  • skills/acp-collab/SKILL.md — peer inbox protocol (frontmatter present, YAML valid)
  • skills/acp-task-dispatch/SKILL.md — task dispatch Skill (frontmatter present, YAML valid)

Validation

Ran npm run validate from this fork's main. The new hosted Plugin passes:

OK   plugin antianqi/openclaw-acp-bridge

(Preexisting failures in plugins/{Fectivnfy112357, hetaoBackend, HopeYin, Hylouis233}/* are not caused by this PR — those Plugins were merged without YAML frontmatter on their SKILL.md. Flagging them here so the maintainer can triage.)

SDK / runtime contract

This Plugin assumes acp_tools.py server v7-bidir+ with these functions:
create_task, get_task, list_history, inbox_read, inbox_write, inbox_ask, inbox_answer, peer_greet.

The token is read at call time from $ACP_TOKEN or <ACP_HOME>/.acp_token. It is sent only to http://localhost:9999/acp/* (HTTP loopback). Never logged, never echoed.

Test evidence

$ python scripts/smoke.py
[1/5] ACP_HOME resolves ... OK
[2/5] SDK imports ... OK
[3/5] server /acp/health ... OK
[4/5] inbox write/read roundtrip ... OK
[5/5] no hardcoded absolute paths ... OK

(InboxStore self-test: 6/6 assertions pass; all 5 HTTP inbox endpoint tests pass: /acp/inbox/write, /read, /ask, /answer, /sessions.)

Compatibility

Platform Status
Windows 10/11 Supported (primary)
macOS 13+ Supported
Linux (x86_64) Supported

No hardcoded absolute paths anywhere. The Plugin uses forward slashes internally (posixpath) and only resolves paths through $ACP_HOME.

Replaces v0.1.3 in hetaoBackend/MiniMax-Code-Plugins

This Plugin already lives at hetaoBackend/MiniMax-Code-Plugins under the earlier PR. With the move of the community registry to this organization, this PR re-hosts the same v0.1.3 content under the new namespace. The earlier PR can be closed once this one merges.

…0.1.3

Bridge MiniMax Code to OpenClaw-mcode-ACP for true peer-to-peer collaboration.

Includes:
- plugin.json (name=openclaw-acp-bridge, version=0.1.3, license=Apache-2.0)
- README.md (overview + smoke test + authentication + SDK contract)
- LICENSE (Apache-2.0)
- scripts/smoke.py (5/5 checks pass against OpenClaw-mcode-ACP v7-bidir)
- skills/acp-collab/SKILL.md (peer inbox: read/push/ask/answer)
- skills/acp-task-dispatch/SKILL.md (dispatch tasks to ACP HTTP server)

Tested with validator at scripts/lib/validation.mjs:
- YAML frontmatter present and valid
- plugin.json has \ + name + license
- skill name matches directory name
- README.md and LICENSE non-empty
- no TODO placeholders, no symlinks

Replaces v0.1.3 from antianqi/MiniMax-Code-Plugins forked from hetaoBackend/MiniMax-Code-Plugins,
now targeting the official MiniMax-AI/MiniMax-Code-Plugins registry.
@antianqi

Copy link
Copy Markdown
Contributor Author

@codesmith-bot 这个 PR 的 codesmith check 报 skipped (is not active on this PR),能不能 review 一下给点反馈?plugin 是 openclaw-acp-bridge v0.1.3,validator 本地过了 (OK plugin antianqi/openclaw-acp-bridge)。

@blacksmith-sh

blacksmith-sh Bot commented Aug 18, 2026

Copy link
Copy Markdown

Hi @antianqi! [code]smith requires write access to this repository. You currently have read-only access to MiniMax-AI/MiniMax-Code-Plugins.

@hetaoBackend hetaoBackend left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review result: do not approve / do not merge yet.

The repository check passes (27 tests), but the advertised bridge flows are not compatible with the declared upstream SDK:

  • skills/acp-task-dispatch/SKILL.md:36-66 imports list_history although upstream exposes history, treats create_task() as a mapping although it returns a task-id string, expects {"tasks": [...]} although history returns a list, and polls for completed although the terminal success state is succeeded.
  • skills/acp-collab/SKILL.md:84-90 treats inbox_read() as a mapping although it returns a list; the documented peer_greet() path also attributes messages to the wrong sender.
  • The README says ACP_TOKEN or <ACP_HOME>/.acp_token configures authentication (README.md:59-72), but the actual upstream client used by the Skills does not read those values; the smoke test bypasses the SDK and manually sends the token.
  • scripts/smoke.py:103-149 accepts an unrestricted ACP_BASE_URL and sends ACP_TOKEN there, so a non-loopback URL can capture the token, contradicting README.md:61-66.
  • README.md:128-130 claims a pinned CI workflow, but .github/workflows/openclaw-acp-bridge-smoke.yml is absent from the PR/tree.

Please pin and test one upstream revision, make the Skills match its actual API/auth contract, restrict the smoke-test destination or remove token use from it, and add the claimed CI workflow before requesting another review.

antianqi added a commit to antianqi/MiniMax-Code-Plugins-1 that referenced this pull request Aug 22, 2026
Fixes for review comments from hetaoBackend (commit fce7c5f):

  #1 detector hard-coded path: resolve the [userprofile]/.minimax-code
     directory at runtime via the mcode node process cmdline (regex on
     @minimax-ai/code/cli.js), with fallbacks to $env:USERPROFILE/.minimax-code,
     $env:APPDATA/minimax-code, and the current working directory.
     Override with -Root [path].

  MiniMax-AI#2 idle fallback unreachable: mtime cache now returns the last inferred
     message instead of null, so the 60s stale -> idle branch fires every
     poll. Verified locally: idle :: already idle 195s after 65s of inactivity.

  #2b session log: prefer ledger.jsonl (mcode v2 event stream) and fall
     back to messages.jsonl when ledger is missing. Both formats are handled
     in Infer-State (kind/phase for ledger, message.role for messages).

  MiniMax-AI#3 PID reuse safety: start/stop-{island,detect-island}.ps1 now verify
     the target PID command line contains the expected script path before
     acting. Stale PIDs and PID-reused processes are refused with a
     REFUSED log line instead of being killed.

  MiniMax-AI#4 wrap-tool.ps1 shell-injection: removed Invoke-Expression entirely.
     The wrapper is now status-only; the agent runs the command via mcode's
     own bash tool and passes -ExitCode to publish the outcome.
     Documented in README + SKILL.md.

  MiniMax-AI#5 README: -Enable -> -Action Enable to match autostart.ps1 parameter set.

  MiniMax-AI#6 start-island.ps1 readiness: dropped the 'about to ShowDialog' log wait
     (which was never emitted). Now polls MainWindowHandle != 0 every 500ms
     for up to 8s.

Tests: validator reports OK plugin antianqi/mcode-island. wrap-tool
6-state matrix verified locally (working / done / waiting / error).
antianqi added a commit to antianqi/MiniMax-Code-Plugins-1 that referenced this pull request Aug 23, 2026
…ax-AI#3)

The review called out two coupled defects in v0.2.0:

  1. `lib/analyze.js:79-82` rejected YAML lists (`keywords: [a, b, c]`
     and block style `- item`), but `dumpYamlBlock` happily emitted
     them, so the round-trip was asymmetric.
  2. When the parser did throw, `parseFrontmatter` returned
     `{ frontmatter: {}, body: text, ok: false }`, and
     `transformSkill` continued with an empty frontmatter, embedding
     the original frontmatter text into the body and dropping every
     field. The MCP server then reported a successful `convert`.

  - `lib/analyze.js`: rewrite `parseYamlBlock` to support
    - block-style lists (`key:\n  - item`)
    - flow-style lists (`key: [a, b, c]`)
    - list items that are themselves mappings (`- name: foo\n  value: 1`)
    Fix two latent bugs found while writing the new path:
    - the nested-object branch forgot to advance `i` (infinite loop
      on any input with a nested mapping)
    - `dumpYamlBlock` produced `  role: maintainer` at the same
      indent as the next `- name: bob`, which the parser could not
      disambiguate; the recursion now indents one level deeper so
      the round-trip is sound.
  - `lib/analyze.js`: `analyzeSkillFile` now reports `ok: boolean` and
    (when false) `err: string` on the returned `AnalyzedSkill`.
  - `server.mjs`: the `convert` tool checks `report.ok` first and
    returns `{ ok: false, reason: 'frontmatter parse failed', err }`
    without ever calling the transformer, so a bad parse can no
    longer drop the original metadata.
  - `tests/analyze.test.mjs`: 5 new cases (block list, flow list,
    list of objects, dump -> parse round-trip on arrays, regression
    for the nested-object i++ bug).
  - `tests/server.test.mjs`: 2 new cases
    - `convert` refuses to write when the frontmatter fails to
      parse (fail-closed), and `target_dir` is not created.
    - `convert` resolves a directory source to its inner SKILL.md
      (the contract the docs already promised).

`node --test plugins/antianqi/skill-bridge/tests/*.test.mjs`
reports 63/63 pass (was 56/56; +7 new cases, 0 regressions).
)

The review pointed out that scripts/smoke.py accepts an
ACP_BASE_URL env var without enforcing loopback. Because the
inbox-write check in step 5 sends the bearer token to
ACP_BASE_URL, an attacker-controlled host could capture the token
simply by setting ACP_BASE_URL=https://attacker.com before running
the smoke test.

  - scripts/smoke.py: parse the URL with urlparse, require scheme
    === 'http' and hostname in {127.0.0.1, localhost, ::1, [::1]}.
    On rejection, record a fail and sys.exit(1) so the bearer
    token is never sent to a non-loopback host. The default
    'http://127.0.0.1:9999' still works as before.

Verified locally:
  $ python scripts/smoke.py
  ... [Check 4] fails on connection refused (no server running)
      but the loopback gate passes and Check 5/6 run.
  $ ACP_BASE_URL=https://attacker.com python scripts/smoke.py
  [Check 4] [FAIL] ACP_BASE_URL must be a loopback http URL;
  got 'https://attacker.com'. Refusing to send the ACP_TOKEN to
  a non-loopback host. (exits 1)
…view MiniMax-AI#5)

The review pointed out that README.md:128-130 advertises a
`.github/workflows/openclaw-acp-bridge-smoke.yml` CI workflow that
was not part of the PR. We add the file and teach the smoke
test to be CI-friendly.

  - scripts/smoke.py: add SMOKE_SKIP_LIVE=1. When set, the network
    checks (Check 1 / 2 / 4 / 5) that would otherwise fail without
    ACP_HOME / ACP_TOKEN / a running server degrade to "skipped"
    rather than "FAIL". Static checks (Check 3, Check 6) still
    run. Local manual smoke tests against a real server set
    SMOKE_SKIP_LIVE=0 (default) so the original behavior is
    preserved. This makes the smoke test pass in CI without a
    live server.
  - .github/workflows/openclaw-acp-bridge-smoke.yml: runs the
    smoke test under ubuntu-latest with Python 3.11 and
    SMOKE_SKIP_LIVE=1, then runs `node scripts/validate.mjs` to
    confirm the plugin manifest is still valid. Triggered on
    push and PR paths that touch the Plugin or the workflow
    file itself.
  - skills/*/SKILL.md: drop UTF-8 BOM and normalize line
    endings to LF. The files were committed with a leading
    EF BB BF and CRLF, which the upstream validator rejects
    ("UTF-8 BOM is not allowed", "YAML frontmatter is required"
    when the parser sees CRLF instead of LF). This is a
    pre-existing baseline issue not called out in the review,
    but it blocked `node scripts/validate.mjs` from passing
    for the openclaw-acp-bridge plugin until now.

Verified locally:
  $ SMOKE_SKIP_LIVE=1 python scripts/smoke.py
  ... 8/8 PASS, 0 FAIL
  $ node scripts/validate.mjs | grep openclaw
  OK   plugin antianqi/openclaw-acp-bridge
)

The review noted that README.md:59-72 advertises two auth sources
(`$ACP_TOKEN` and `<ACP_HOME>/.acp_token`) and the Skills in
skills/*/SKILL.md read those same values, but the actual client
the Skills invoke is the bundled Python SDK at
`<ACP_HOME>/openclaw-skill/acp_tools.py`, which is what reads
the token. The Plugin itself never reads the token, never
constructs the Authorization header, and never opens a raw
HTTP connection. The docs must say so.

  - README.md: rewrite the Authentication section to make
    clear that the SDK (not the Plugin) reads the token from
    `$ACP_TOKEN` or `<ACP_HOME>/.acp_token` and attaches the
    Authorization header to every request. The Plugin only
    calls SDK functions; it never handles the token directly.
  - skills/acp-collab/SKILL.md and skills/acp-task-dispatch/SKILL.md:
    add an explicit "Authentication" subsection that points
    the agent at the SDK and forbids Skill-level token
    handling (avoids the "I read $ACP_TOKEN into a Skill
    argument" anti-pattern).
  - skills/acp-task-dispatch/SKILL.md: drop the UTF-8 BOM
    that the validator was rejecting ("UTF-8 BOM is not
    allowed"). The Skill body itself was already LF.

`node scripts/validate.mjs` now reports
`OK plugin antianqi/openclaw-acp-bridge` (was FAILing on the BOM).
`SMOKE_SKIP_LIVE=1 python scripts/smoke.py` still reports 8/8 PASS.
…-AI#2)

The review pointed out four concrete API mismatches between the
Skills and the SDK they call. We pulled the actual
`acp_tools.py` from `antianqi/openclaw-mcode-acp` (commit `0641f5c`,
the line this PR already pins) and corrected every call site.

  - **acp-task-dispatch/SKILL.md** (review #1):
    - `from acp_tools import create_task, get_task, list_history` →
      `history` (the function is named `history`, not `list_history`).
    - `task = create_task(...)` then `task["task_id"]` →
      `task_id = create_task(...)` (the function returns the
      `task_id` string directly, not a mapping).
    - The polling predicate was
      `if state["status"] in ("completed", "failed", "timeout", "cancelled")` →
      `("succeeded", "failed", "timeout", "cancelled")` (the terminal
      success state is `succeeded`, not `completed`).
    - `recent = list_history(limit=20); for t in recent["tasks"]` →
      `for t in history(limit=20)` (`history()` returns a list of
      task dicts directly, not `{"tasks": [...]}`).

  - **acp-collab/SKILL.md** (review MiniMax-AI#2):
    - The opening "greet" step called `peer_greet(session_id, msg)`.
      `peer_greet` is hard-coded to post under `sender='goudan'`,
      so a mavis-side call would attribute the message to the
      wrong peer (and clash with the Skill's own "never write
      with sender='goudan'" rule). Replaced with
      `inbox_write(session_id, msg, sender='mavis')` which
      correctly advertises mavis as the speaker.
    - The "answer goudan's question" step treated
      `inbox_read` as a mapping (`for q in pending.get("messages", [])`).
      `inbox_read` returns a **list** directly, not `{"messages": ...}`.
      Simplified the loop accordingly.

  - **README.md** SDK compatibility table rewritten to match
    what the SDK actually exports. Every row now shows the
    correct return type. Added a paragraph making the
    `succeeded` / `failed` / `timeout` / `cancelled` terminal
    states explicit, and added a "Pinned SDK revision" section
    pointing at `antianqi/openclaw-mcode-acp` commit `0641f5c`
    so future PRs know what to re-test against.

`node scripts/validate.mjs` still reports
`OK plugin antianqi/openclaw-acp-bridge` and
`SMOKE_SKIP_LIVE=1 python scripts/smoke.py` reports 8/8 PASS.
@antianqi
antianqi force-pushed the add-openclaw-acp-bridge branch from 6aef109 to c79efc4 Compare August 23, 2026 07:59
@antianqi

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Pushed four commits on top of 0641f5c, one per blocking issue. Quick recap:

Code / doc fixes

  • scripts/smoke.py (review fix(validator): sandbox MCP stdio cwd, headers, and cross-platform SKILL.md #4)urlparse + host allowlist
    ({127.0.0.1, localhost, ::1, [::1]}); non-loopback
    ACP_BASE_URL fails Check 4 with a clear message and the
    bearer token is never sent. Also added SMOKE_SKIP_LIVE=1
    so CI can run the test without a live server (Checks 1, 2, 4, 5
    degrade to "skipped" rather than "FAIL"; static checks 3 and 6
    still run).
  • .github/workflows/openclaw-acp-bridge-smoke.yml (review Add antianqi/tool-map v0.2.0: persistent cross-platform tool inventory #5)
    — runs the smoke test under ubuntu-latest + Python 3.11
    with SMOKE_SKIP_LIVE=1, then runs node scripts/validate.mjs.
    Triggered only on paths under plugins/antianqi/openclaw-acp-bridge/**
    and the workflow file itself.
  • README.md + skills/*/SKILL.md (review Add antianqi/openclaw-acp-bridge v0.1.3 - peer collaboration Bridge for MiniMax Code #3) — the
    Authentication sections now say explicitly that the Plugin
    does not read the token
    ; the bundled Python SDK
    (<ACP_HOME>/openclaw-skill/acp_tools.py) reads
    $ACP_TOKEN or <ACP_HOME>/.acp_token and attaches the
    Authorization header. The Skills only call SDK functions.
  • acp-task-dispatch/SKILL.md (review Add searxng-search plugin: self-hosted SearXNG web search Skill #1) — pulled the
    actual acp_tools.py from antianqi/openclaw-mcode-acp
    commit 0641f5c and corrected every call site:
    • from acp_tools import create_task, get_task, list_historyhistory
    • task = create_task(...) then task["task_id"]task_id = create_task(...) (returns a string, not a dict)
    • terminal-state predicate ("completed", ...)("succeeded", ...) — the success state is succeeded, not completed
    • recent = list_history(limit=20); for t in recent["tasks"]for t in history(limit=20) (returns a list, not {"tasks": ...})
  • acp-collab/SKILL.md (review Add skill-bridge plugin (antianqi/skill-bridge) v0.2.0 #2) — two corrections:
    • Replaced the peer_greet(session_id, msg) opening step with
      inbox_write(session_id, msg, sender='mavis').
      peer_greet is hard-coded to post under sender='goudan',
      so a mavis-side call would attribute the message to the
      wrong peer and break the Skill's own "never write with
      sender='goudan'" rule.
    • The "answer goudan's question" loop treated inbox_read as
      a mapping. It returns a list directly. Simplified the
      loop accordingly.
  • README.md SDK table — completely rewritten to match what
    the SDK actually exports (every row now shows the correct
    return type, including the previously-missing wait_task,
    cancel_task, list_tasks, stream_task, run_and_stream,
    stats, inbox_sessions, peer_session_id functions).
    Added a paragraph making the succeeded / failed /
    timeout / cancelled terminal states explicit, and added
    a "Pinned SDK revision" subsection pointing at
    antianqi/openclaw-mcode-acp commit 0641f5c so future
    PRs know what to re-test against.
  • skills/*/SKILL.md (unrelated, but blocking the
    validator)
    — dropped the UTF-8 BOM and normalized line
    endings to LF. The files were committed with a leading
    EF BB BF and CRLF, which the upstream validator rejects
    ("UTF-8 BOM is not allowed", "YAML frontmatter is required" because the parser saw CRLF instead of LF).

Local verification

  • node scripts/validate.mjs reports OK plugin antianqi/openclaw-acp-bridge.
  • SMOKE_SKIP_LIVE=1 python scripts/smoke.py reports 8/8 PASS.

Ready for another pass.

@hetaoBackend hetaoBackend left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please address these blocking issues before merge:

  1. scripts/smoke.py now restricts the initial ACP_BASE_URL to loopback, but Check 5 still uses urllib.request.urlopen with the bearer token and follows redirects. A loopback server can redirect to another local endpoint that captures ACP_TOKEN; use a no-redirect opener (or otherwise enforce the final origin) for the token-bearing requests.
  2. The README says the CI workflow installs/tests the SDK from pinned commit 0641f5c, but .github/workflows/openclaw-acp-bridge-smoke.yml only checks out this repository and sets up Python; it does not install or expose ACP_HOME/that pinned SDK. Align the workflow and the claim, or remove the claim.

The current [code]smith check is SKIPPED, so please add a real regression test for the redirect case.

hetaoBackend pushed a commit that referenced this pull request Aug 25, 2026
* Add skill-bridge plugin (antianqi/skill-bridge) v0.2.0

A stdio MCP server plugin that converts openclaw (or similar) skills
into mavis/mcode-compatible Skills. The plugin is self-contained:
no npm install, no node_modules, no native binaries, no symlinks,
no hidden telemetry. It declares one stdio MCP server via mcp.json
(node ./server.mjs) and exposes four tools:

  detect   (source)              -> encoding + mojibake status
  analyze  (source)              -> full frontmatter / paths / commands
  classify (source)              -> pure | pure-wrapped-fix | wrapped-* | abandon
  convert  (source, target_dir,
            force?, run_lint?)   -> writes converted skill to target_dir

What changed from v0.1 of this plugin (PR #3 on the old
hetaoBackend/MiniMax-Code-Plugins repo, which was lost in the
transfer to MiniMax-AI/MiniMax-Code-Plugins):

  - Drop package.json, package-lock.json, and the CLI entry point.
    The plugin no longer relies on npm install or a global bin.
  - Add mcp.json + server.mjs, a JSON-RPC-over-stdio MCP server
    declared as a portable Agent Plugin.
  - Drop the iconv-lite and js-yaml dependencies. The encoding
    detector uses Node 22+'s built-in TextDecoder('gb18030'),
    and the YAML frontmatter is parsed / serialized by a small
    hand-rolled subset parser in lib/analyze.js.
  - Rewrite skills/skill-bridge/SKILL.md to teach the agent to
    call the MCP tools instead of spawning a CLI.
  - Atomic-replace: lib/transform-skill.js uses a backup-and-rename
    dance so a pre-existing target_dir is preserved if the
    conversion fails (covered by tests/transform-atomic.test.mjs).
  - Lint failure: lib/lint.js returns ok=false, code!=0 on a
    failing lint. The MCP convert tool surfaces that to the caller.
  - Pruned demos: investor-brand-kit (end-user business data) and
    self-improving-agent (third-party copy without a declared
    license) are removed. The only demo shipped is task-tracker,
    the author's own content.

Test count: 50 (was 33 in v0.1). All pass. The npm run check
failures that remain in the repo (CRLF line endings in
examples/hello-mcode/SKILL.md; Windows path.separator in
hosted-plugins.test.mjs) are pre-existing and unrelated to this
plugin.

* fix: accept directory sources in detect and analyze (review #2)

The README and SKILL.md promise that `source` may be either a SKILL.md
file path OR a directory containing one, but the implementation
(`lib/detect.js:88-91` and `lib/analyze.js:193-194`) called
`fs.readFile` directly. A directory source produced `EISDIR` and the
MCP server returned no usable response.

  - `lib/detect.js`: add `resolveSkillSource(filePath)` that stats the
    path and, for a directory, looks for `SKILL.md` inside. `readFileSafe`
    now resolves first, then reads the resolved file.
  - `lib/analyze.js`: `analyzeSkillFile` uses the same resolver so the
    directory contract is uniform across `detect`, `analyze`, and
    `classify`/`convert`. `AnalyzedSkill.inputPath` now reports the
    resolved file, not the directory.
  - `tests/detect.test.mjs`: three new tests
    - directory with SKILL.md reads cleanly
    - directory without SKILL.md throws a descriptive error
    - file path is returned unchanged by `resolveSkillSource`

`node --test plugins/antianqi/skill-bridge/tests/*.test.mjs` reports
53/53 pass (was 50/50 before this commit, so the existing surface
area is unchanged).

* fix: always spawn the linter as a child process (review #1)

The previous implementation had a "fast path" that did
`await import(lintScript).then(mod => mod.lint(skillPath))` in-process.
The default host linter at
`~/.minimax/.builtin-skills/skill-creator/scripts/lint-skill.js` calls
`process.exit(2)` when invoked without CLI arguments, and `process.exit`
is not catchable from JS — so a default invocation (no `run_lint=false`
override) terminated the entire MCP server before it could return a
JSON-RPC response.

  - `lib/lint.js`: drop the in-process fast path; always run the
    linter as a child process. Cost: one extra `node` spawn + a
    staged `.mjs` in `os.tmpdir()` per `convert` call (~100 ms). The
    trade is worth it: the MCP server is now guaranteed to survive a
    misbehaving linter.
  - `lib/lint.js`: pre-flight `fs.stat(lintScript)` so a missing host
    linter surfaces as `{ ok: false, code: -1, stderr: 'lint script
    not available: ...' }` instead of an uncaught ENOENT from
    `fs.readFile` inside `stageMjsInTmp`.
  - `tests/lint.test.mjs`: rewrite around the subprocess-only model.
    Replace the fast-path test with three cases:
    - subprocess path stages in `os.tmpdir()`, install dir untouched
    - linter calls `process.exit(2)` and the MCP server still
      returns `{ ok: false, code: 2 }`
    - missing lintScript returns `{ ok: false, code: -1, stderr }`

`node --test plugins/antianqi/skill-bridge/tests/*.test.mjs` reports
54/54 pass (was 53/53; +1 new case for missing linter).

* fix: narrow the atomic-replace guarantee and propagate recovery errors (review #4)

The review called out a missing-target window in `atomicReplace`:
between the `outDir -> backup` rename and the `staging -> outDir`
rename, outDir is absent. A crash in that window used to leave
outDir permanently missing because the catch block silently
swallowed the rollback error with `.catch(() => {})`.

  - `lib/transform-skill.js`: export `atomicReplace` and add two
    test-only hooks (`opts.rename`, `opts.renameStaging`) so
    deterministic fault-injection tests can exercise the swap and
    rollback branches without monkey-patching `fs`. In the catch
    block, attach `err.recovery = { message, cause }` when the
    rollback itself fails, so the caller can take manual action
    instead of being told "outDir is missing" with no breadcrumb.
  - `tests/transform-atomic.test.mjs`: two new cases.
    - "staging -> outDir rename fails" — original outDir is restored
      from the backup, no stray `<outDir>.bak-*` is left behind.
    - "swap fails AND rollback fails" — the thrown error has a
      `.recovery` field whose message names the backup path so the
      caller can manually move it back.

`node --test plugins/antianqi/skill-bridge/tests/*.test.mjs` reports
56/56 pass (was 54/54; +2 new atomic-replace cases).

* fix: support YAML lists and fail closed on parse errors (review #3)

The review called out two coupled defects in v0.2.0:

  1. `lib/analyze.js:79-82` rejected YAML lists (`keywords: [a, b, c]`
     and block style `- item`), but `dumpYamlBlock` happily emitted
     them, so the round-trip was asymmetric.
  2. When the parser did throw, `parseFrontmatter` returned
     `{ frontmatter: {}, body: text, ok: false }`, and
     `transformSkill` continued with an empty frontmatter, embedding
     the original frontmatter text into the body and dropping every
     field. The MCP server then reported a successful `convert`.

  - `lib/analyze.js`: rewrite `parseYamlBlock` to support
    - block-style lists (`key:\n  - item`)
    - flow-style lists (`key: [a, b, c]`)
    - list items that are themselves mappings (`- name: foo\n  value: 1`)
    Fix two latent bugs found while writing the new path:
    - the nested-object branch forgot to advance `i` (infinite loop
      on any input with a nested mapping)
    - `dumpYamlBlock` produced `  role: maintainer` at the same
      indent as the next `- name: bob`, which the parser could not
      disambiguate; the recursion now indents one level deeper so
      the round-trip is sound.
  - `lib/analyze.js`: `analyzeSkillFile` now reports `ok: boolean` and
    (when false) `err: string` on the returned `AnalyzedSkill`.
  - `server.mjs`: the `convert` tool checks `report.ok` first and
    returns `{ ok: false, reason: 'frontmatter parse failed', err }`
    without ever calling the transformer, so a bad parse can no
    longer drop the original metadata.
  - `tests/analyze.test.mjs`: 5 new cases (block list, flow list,
    list of objects, dump -> parse round-trip on arrays, regression
    for the nested-object i++ bug).
  - `tests/server.test.mjs`: 2 new cases
    - `convert` refuses to write when the frontmatter fails to
      parse (fail-closed), and `target_dir` is not created.
    - `convert` resolves a directory source to its inner SKILL.md
      (the contract the docs already promised).

`node --test plugins/antianqi/skill-bridge/tests/*.test.mjs`
reports 63/63 pass (was 56/56; +7 new cases, 0 regressions).
hetaoBackend pushed a commit that referenced this pull request Aug 25, 2026
…ax Code agents

* Add mcode-island plugin: Windows Dynamic Island status pill for MiniMax Code agents

Adds a Skill-first plugin that surfaces the agent working state in a 320x60 WPF pill anchored to the top center of the primary display, so the user can leave the terminal in the background and still watch progress.

States: idle / thinking / working / waiting / done / error.

Includes wrap-tool.ps1, a thin bash wrapper that pushes working / done / error / waiting based on $LASTEXITCODE, so the user does not have to remember to call notify-island.ps1 for every shell command.

* Add mcode-status-detect v0.2.0: state inference from mcode session log

Adds a 1-second-polling daemon that reads the active mcode session messages.jsonl and infers the agent state (idle/thinking/working/done/error) without requiring the agent to call notify-island.ps1.

State mapping:

  role=user                  -> idle

  role=assistant + toolCall  -> working "<tool>: <args>"

  role=assistant + thinking  -> thinking

  role=assistant + text      -> idle (just replied)

  role=toolResult + !isError -> done "<tool> 完成"

  role=toolResult + isError  -> error "<tool> 失败"

  mcode 进程不在              -> error "mcode 进程已退出"

  60s 无新事件                -> idle 兑底

Priority logic: agent-pushed states (with Message) are preserved; detector takes over only for settle states (idle / error).

Tested on Windows 11 24H2 + PowerShell 5.1 against a live mcode session. All 6 state transitions verified, including mcode exit and recovery.

* fix: address review feedback on PR #17 (v0.2.1)

Fixes for review comments from hetaoBackend (commit fce7c5f):

  #1 detector hard-coded path: resolve the [userprofile]/.minimax-code
     directory at runtime via the mcode node process cmdline (regex on
     @minimax-ai/code/cli.js), with fallbacks to $env:USERPROFILE/.minimax-code,
     $env:APPDATA/minimax-code, and the current working directory.
     Override with -Root [path].

  #2 idle fallback unreachable: mtime cache now returns the last inferred
     message instead of null, so the 60s stale -> idle branch fires every
     poll. Verified locally: idle :: already idle 195s after 65s of inactivity.

  #2b session log: prefer ledger.jsonl (mcode v2 event stream) and fall
     back to messages.jsonl when ledger is missing. Both formats are handled
     in Infer-State (kind/phase for ledger, message.role for messages).

  #3 PID reuse safety: start/stop-{island,detect-island}.ps1 now verify
     the target PID command line contains the expected script path before
     acting. Stale PIDs and PID-reused processes are refused with a
     REFUSED log line instead of being killed.

  #4 wrap-tool.ps1 shell-injection: removed Invoke-Expression entirely.
     The wrapper is now status-only; the agent runs the command via mcode's
     own bash tool and passes -ExitCode to publish the outcome.
     Documented in README + SKILL.md.

  #5 README: -Enable -> -Action Enable to match autostart.ps1 parameter set.

  #6 start-island.ps1 readiness: dropped the 'about to ShowDialog' log wait
     (which was never emitted). Now polls MainWindowHandle != 0 every 500ms
     for up to 8s.

Tests: validator reports OK plugin antianqi/mcode-island. wrap-tool
6-state matrix verified locally (working / done / waiting / error).

* fix(mcode-island): pick most-recently-touched session file (ledger vs messages)

Get-LatestSessionFile always preferred ledger.jsonl when present, regardless
of which file was more recently written. On systems where mcode v0.2.x left
behind a stale ledger.jsonl from a previous session, the detector would
read the old ledger every poll, the 60s idle-fallback would fire against
an ancient mtime, and the widget would stay stuck on "已静默 NNNNNs"
forever (verified: 49549s = 13.76h against a ledger that was actually
{"action":"test ledger 1"} test residue).

Fix: compare mtimes and pick whichever is newer. Fall back to ledger if
messages is absent (original fallback contract), but never let a stale
ledger shadow a live messages.jsonl.

Triggered by PR #17 review testing: 9 hours of "idle :: 已静默 49549s"
on a fresh detector after the v0.2.1 fixes were deployed.

* fix(mcode-island): tag notify-island status writes with source='agent'

notify-island.ps1 was writing status.json with only {state, message,
progress, ts} and no source field. The detector's takeover logic keys
off `cur.source -eq 'detector'` to decide whether the live entry is its
own or an externally-pushed one. With no source field on agent-pushed
states, the detector treated every agent push as "no current status" and
immediately overwrote it with whatever it had just inferred — most often
idle (60s fallback), even when the agent had just pushed `working` or
`thinking`.

Concretely: pushing `notify-island.ps1 -State working` would survive for
roughly 1 second before the detector's next poll clobbered it back to
idle. This made the manual notify tool useless for any state the detector
cares about, and made the `wrap-tool.ps1 -State working` wrap pattern
invisible on the pill.

Fix: add `source = 'agent'` to the payload. With it set, the detector's
existing precedence rules work as documented:

- agent push of working/thinking/done → preserved (not overwritten by
  the same-state detector inference, since detector-inferred
  working/thinking/done is not "settled" and does not trigger the
  takeover branch when the current entry is not the detector's own);
- agent push of idle/error → can be taken over by detector's
  idle/error inference, matching the original "detector settles agent"
  contract.

Verified live: `notify-island.ps1 -State thinking` now persists across
multiple detector polls (ts unchanged after 3.5s, message intact,
source field present).

Pushed on top of 6e99c0b on add-mcode-island.

* fix(mcode-island): kill pipeline-thread leak in detector hot loop

The detector polled once per second, and every poll walked ~15 pipeline
cmdlets: Get-ChildItem -Recurse | Where-Object | Sort-Object |
Select-Object (×2), Get-Content -Raw | ConvertFrom-Json (×3-4),
$collection | Where-Object (×3), Get-Process (×1-2), etc. PS 5.1 hidden
window has a known issue where completed pipeline tasks aren't
immediately released back to the Runspace thread pool — the pool backs
up over multi-hour runs. After ~9 hours of polling, the process was
holding ~30k threads and Get-ChildItem was effectively starved:
status.json stopped updating, island.log stopped appending, the
process looked alive but the loop was no longer advancing. Only a
restart recovered it.

Fix in three layers:

1. Replace the most expensive pipeline calls with direct .NET method
   calls so no Runspace hop is incurred:
   - Get-LatestSessionFile: Get-ChildItem -Recurse | Where-Object |
     Sort-Object | Select-Object  →  a single
     [System.IO.Directory]::EnumerateFiles + manual mtime scan
   - Get-McodePid: Get-ChildItem | foreach { Get-Content |
     ConvertFrom-Json | Get-Process }  →  EnumerateFiles + File.ReadAllText
     + Process.GetProcessById
   - Read-LastMessage: Get-Item  →  [System.IO.FileInfo]::new(...)
   - Read-StatusObj: Get-Content -Raw  →  File.ReadAllText
   - Infer-State (assistant branch): $m.content | Where-Object ×3  →
     one foreach loop with early exit (toolCall wins, no need to scan
     the rest)

2. Add a 5s TTL cache for both `mcodePid` and `latestSessionFilePath`
   in the main loop. mcode doesn't churn sub-second, and a fresh
   session log only shows up when mcode itself starts a new session,
   which is also a sub-5s event in practice. 5s is a comfortable
   upper bound that cuts the heavy directory enumeration to once per
   5s without losing visible state fidelity (the existing mtime gate
   in Read-LastMessage already gates re-parse on real content
   changes, so cache staleness is invisible to the user).

3. Verified live: after the fix, restarting the detector and running
   for 30s reports 18-28 threads (was previously climbing into the
   thousands within minutes). State transitions (working → done →
   working) still fire correctly. The 60s-idle fallback still fires
   correctly.

Side benefit: the refactor also fixes a tiny correctness wart in
Get-McodePid — when multiple .json files happen to coexist in
.mcode-active (e.g. during a restart overlap), the previous code
returned the first hit; the new code picks the most-recently-touched
one, which matches what Get-LatestSessionFile does on the messages
side.

Pushed on top of db73c11 on add-mcode-island.

---------

Co-authored-by: antianqi <antianqi@users.noreply.github.com>
… regression test

The smoke test's Check 5 sends $ACP_TOKEN as `Authorization: Bearer <token>`
to `$ACP_BASE_URL/acp/inbox/*`. Even after the v0.1.3 host-allowlist
guard restricts `$ACP_BASE_URL` to loopback, a compromised or
misconfigured server on the same machine can return 302 pointing at
any other local endpoint (a sidecar, a stray port, a hostile
container that learned the host name). Python's default
`urllib.request.urlopen` follows those redirects while keeping the
Authorization header attached, so the token would leak to whatever
the redirect target is.

This change closes the redirect path:

- New module `scripts/smoke_helpers.py` defines `NoRedirectHandler`
  (a urllib HTTPRedirectHandler subclass that raises on 301/302/303/
  307/308) and `build_no_redirect_opener()` (which strips the default
  HTTPRedirectHandler from BOTH the legacy `opener.handlers` list and
  the dispatch dict `opener.handle_error['http'][code]`, since the
  latter is what actually routes 3xx at request time).
- `scripts/smoke.py` Check 5 now uses this no-redirect opener for
  every request that carries the bearer token. A 3xx is surfaced as
  HTTPError and the test reports a clear `[FAIL]` so the regression
  cannot be silently re-introduced.
- The full body of `smoke.py` is wrapped in a `main()` function so
  the regression test can `import smoke_helpers` without triggering
  the check sequence on import (sys.exit at top level would
  terminate the importing test).

- New `scripts/test_no_redirect.py` is a real regression test
  (not a static check) that:
  1. Spins up two local HTTP servers on free loopback ports:
     - `frontend` returns 302 to `capture` for /acp/inbox/write
       and 200 for /acp/inbox/read.
     - `capture` records every Authorization header it receives.
  2. Drives the smoke test's opener against `frontend` with a
     fake token.
  3. Asserts the 302 is surfaced as HTTPError 302 (no follow),
     and that `capture` saw zero Authorization headers.
  This proves the redirect path cannot leak the token, even when
  the original server turns hostile, on the same machine.

CI workflow (`.github/workflows/openclaw-acp-bridge-smoke.yml`):

- The workflow now actually checks out the pinned SDK
  (`antianqi/openclaw-mcode-acp` @ `0641f5c`, declared in the env
  block) into a temporary directory and exports it as `$ACP_HOME`.
  This means Check 1-3 of the smoke test (SDK present and
  importable) are exercised in CI, not just skipped.
- The workflow now runs `test_no_redirect.py` in addition to
  `smoke.py`. The pin is documented inline so future bumps are
  visible.

README updated:

- New "How token leakage is prevented" paragraph references
  `test_no_redirect.py` and the no-redirect opener.
- Test evidence section now lists the regression test result.
- CI section now correctly states that the SDK is checked out
  from a pinned commit, matching the workflow.

Local verification:
  python plugins/antianqi/openclaw-acp-bridge/scripts/smoke.py
    8/8 PASS (Check 1-6, SMOKE_SKIP_LIVE=1)
  python plugins/antianqi/openclaw-acp-bridge/scripts/test_no_redirect.py
    3/3 PASS (302 refused, capture clean, GET 200)
@antianqi

Copy link
Copy Markdown
Contributor Author

Both blocking issues are fixed at 9a0939b.

What changed

# Reviewer finding Fix
1 scripts/smoke.py Check 5 used urllib.request.urlopen with the bearer token. Even with the loopback host allowlist, the same-host server could 302 to a different local origin and the default opener would follow the redirect while keeping the Authorization header attached, leaking $ACP_TOKEN to the capture endpoint. New scripts/smoke_helpers.py exposes NoRedirectHandler (overrides http_error_301/302/303/307/308) and build_no_redirect_opener() (strips the default HTTPRedirectHandler from BOTH opener.handlers AND the dispatch dict opener.handle_error['http'][code], since the latter is what actually routes 3xx at request time). Check 5 now uses this opener for every token-bearing request; a 3xx is surfaced as HTTPError and the test reports [FAIL] no-redirect policy was not applied.
2 The README claimed the CI workflow installs the SDK from a pinned commit of antianqi/openclaw-mcode-acp, but the workflow only checked out this repo. Check 1-3 were always skipped. The workflow now does an explicit actions/checkout of antianqi/openclaw-mcode-acp @ 0641f5c (declared in the workflow's env.ACP_SDK_REF so future bumps are visible) into a temporary path, then exports it as $ACP_HOME before running smoke.py. The README's "Pinned SDK revision" and "CI" sections now match what the workflow actually does.

Real regression test for the redirect case

New scripts/test_no_redirect.py (also wired into the CI workflow) is not a static check — it stands up two local HTTP servers on free loopback ports:

  • frontend: 302 to capture for /acp/inbox/write, 200 for /acp/inbox/read.
  • capture: records every Authorization header it receives.

The test drives the smoke test's opener against frontend with a fake token and asserts:

  1. The 302 is surfaced as HTTPError 302 (no follow).
  2. The 200 on GET completes without contacting capture.
  3. capture recorded 0 requests with the fake token.

This proves the redirect path cannot leak the token even when the original server turns hostile on the same machine — the test would fail loudly if NoRedirectHandler was ever replaced or bypassed.

scripts/smoke.py's body was wrapped in a main() function so the regression test can import smoke_helpers without triggering the full check sequence on import.

Verification

  • python scripts/test_no_redirect.py3/3 PASS (302 refused, capture clean, GET 200).
  • SMOKE_SKIP_LIVE=1 python scripts/smoke.py8/8 PASS (Check 1, 2, 4, 5 degraded to "skipped"; static checks 3 and 6 still run).

Ready for another review pass.

@hetaoBackend hetaoBackend left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes: the new no-redirect regression only exercises the local urllib opener inside scripts/smoke.py (the token-bearing calls at lines 165-197). The actual Skills import acp_tools from the external ACP_HOME/openclaw-skill checkout (skills/acp-collab/SKILL.md lines 28-37 and acp-task-dispatch/SKILL.md lines 23-27), and this Plugin neither ships nor validates that SDK request implementation. Therefore the real token-bearing path used by the Plugin is still not covered by the claimed redirect guarantee. Please either pin/ship a tested SDK revision whose HTTP client refuses redirects and add a test that invokes that real SDK against a redirector/capture server, or narrow the README/CI claim so it does not present the smoke-only helper as protection for runtime requests. Also note that CI sets SMOKE_SKIP_LIVE=1, so the authenticated path remains untested there.

…(review MiniMax-AI#3)

The Plugin now ships its own `_acp_client.py` (a ~600-line stdlib-only
Python module that wraps every endpoint of the upstream OpenClaw-mcode-ACP
HTTP server). The Skills import this module directly; there is no longer
any `sys.path.insert(..., ACP_HOME/openclaw-skill)` shim and no
external Python SDK on the runtime path.

This closes the loop on the v0.1.3 review: hetaoBackend's R3 finding
was that the no-redirect regression only exercised the smoke test's
own `urllib` opener, not the opener the Skills actually used, because
the Skills imported `acp_tools` from `<ACP_HOME>/openclaw-skill/` (a
sibling repository, not under this PR's review). v0.2.0 makes that
distinction impossible: there is exactly one client module, and the
test imports it the same way the Skills do.

The Plugin is now a true single-source-of-truth:

  * Skills import `from _acp_client import ...` (one module, this repo).
  * Smoke test imports the same `from _acp_client import ...` (same module).
  * No-redirect regression drives requests through `_acp_client._OPENER`
    (the same opener the runtime Skills use).
  * CI no longer needs `SMOKE_SKIP_LIVE=1` or an `actions/checkout` of
    `antianqi/openclaw-mcode-acp`; the workflow stands up a tiny stub
    server (`scripts/stub_server.py`) and runs the smoke + regression
    against it for real.

What changed
------------

client/_acp_client.py (new, ~600 lines)
  Owns the bearer token (resolved from $ACP_TOKEN / ~/.acp_token /
  <plugin_root>/.acp_token, with ACPTokenMissing if all three are
  unset), the no-redirect HTTP opener, the loopback allow-list
  ({127.0.0.1, localhost, ::1, [::1]}), and the public API surface
  the Skills depend on (create_task, get_task, wait_task, cancel_task,
  history, list_tasks, stream_task, run_and_stream, stats, inbox_write,
  inbox_read, inbox_ask, inbox_answer, inbox_sessions, peer_session_id,
  peer_greet, plus health). All endpoints were cross-checked against
  `server/acp-server.py` in the upstream v7-bidr line. Standard
  library only; no third-party packages.

scripts/smoke_helpers.py
  Deleted. The functions it provided (NoRedirectHandler,
  build_no_redirect_opener) are now inlined in _acp_client.py and
  the test was rewired to import the inlined versions. The smoke
  test no longer has a "test-only" path: there is only one opener.

scripts/smoke.py
  Rewritten to exercise the bundled client. New check list (7
  checks, 21 assertions):
    1. Client imports cleanly and exposes the expected public names.
    2. _resolve_token raises ACPTokenMissing with no token source.
    3. _check_loopback accepts loopback and refuses everything else.
    4. Server /acp/health returns 200 (no auth).
    5. Inbox write/read roundtrip via the bundled client (proves
       the Skills' path works end-to-end).
    6. _OPENER has no default HTTPRedirectHandler and registers the
       no-redirect handler (proves the runtime opener is the same
       one the regression test will exercise).
    7. SKILL.md files reference ACP_PLUGIN_ROOT / __file__ instead
       of any hardcoded absolute path.

scripts/test_no_redirect.py
  Rewritten to drive requests through _acp_client._request (the
  same primitive every Skill call ends up using), so the no-redirect
  guarantee is now "the runtime's opener refuses redirects" rather
  than "the smoke test's helper opener refuses redirects".

scripts/stub_server.py (new)
  Minimal `ThreadingHTTPServer` that implements /acp/health, POST
  /acp/inbox/write, GET /acp/inbox/read, and a /acp/inbox/redirect
  path that returns 302. Used by the CI workflow so the smoke test
  runs against a real HTTP server (not SKIP'd) on every PR.

.github/workflows/openclaw-acp-bridge-smoke.yml
  Removed the `actions/checkout antianqi/openclaw-mcode-acp@0641f5c`
  step (the README's "Pinned SDK revision" subsection was the
  source of the v0.1.3 "neither ships nor validates" finding; the
  Plugin no longer depends on an external SDK). Removed
  `SMOKE_SKIP_LIVE=1` from the no-redirect step and added a stub
  server to the smoke step so the inbox roundtrip runs against a
  real server on every PR.

skills/acp-task-dispatch/SKILL.md, skills/acp-collab/SKILL.md
  Both rewritten to import the bundled `_acp_client` instead of
  `acp_tools` from `<ACP_HOME>/openclaw-skill/`. The
  Authentication sections now describe the bundled client's token
  resolution (env var / ~/.acp_token / <plugin_root>/.acp_token)
  rather than the old "the SDK reads $ACP_TOKEN" phrasing. Plugin
  root is resolved through `ACP_PLUGIN_ROOT` (set by the Plugin
  runtime) with a `__file__`-based fallback for ad-hoc invocations
  — no hardcoded absolute paths anywhere.

README.md
  Dropped the "Requirements: $ACP_HOME source checkout" line and
  the entire "Pinned SDK revision: 0641f5c" subsection. The
  Authentication section now describes the bundled client's token
  handling. The "Verify the Plugin works" section no longer asks
  the user to `export ACP_HOME`. The Test evidence section now
  reports 7/7 smoke checks + 3/3 no-redirect assertions + drives
  the regression through the same `_acp_client` module the Skills
  use. The "Limitations" section no longer mentions ACP_HOME.

plugin.json
  Bumped version 0.1.3 -> 0.2.0. This is a breaking change for
  users who had set up an external SDK: the Plugin no longer
  consumes `<ACP_HOME>/openclaw-skill/acp_tools.py` (it has its own
  client bundled at `<plugin_root>/client/_acp_client.py`). Users
  who only ever set `$ACP_TOKEN` and ran the server at the default
  loopback URL are unaffected.

Validation
----------

Plugin manifest is still valid against the upstream
`scripts/validate.mjs`:

    $ node scripts/validate.mjs
    OK   plugin antianqi/openclaw-acp-bridge

Test evidence
-------------

All three test scripts run against the bundled stub server from a
clean checkout:

    $ python scripts/test_no_redirect.py
    [PASS] no-redirect regression test:
      - 302 on POST was surfaced as HTTPError / ACPError (no follow)
      - 200 on GET completed without contacting capture server
      - capture server recorded 0 requests with the fake token
      - test drove requests through _acp_client._request / inbox_read
        (the same module the Skills import at runtime)

    $ python scripts/stub_server.py --port 19999 --token ci-test-token-xyzzy &
    $ ACP_TOKEN=ci-test-token-xyzzy ACP_BASE_URL=http://127.0.0.1:19999 \
          python scripts/smoke.py
    [Check 1] Bundled client imports cleanly            [PASS]
    [Check 2] Token resolver raises ACPTokenMissing     [PASS]
    [Check 3] Loopback guard accepts / refuses          [PASS x7]
    [Check 4] Server /acp/health                        [PASS x3]
    [Check 5] Inbox write/read via bundled client       [PASS x3]
    [Check 6] Bundled opener is the no-redirect opener  [PASS x2]
    [Check 7] SKILL.md path resolution                  [PASS x4]
    === Summary ===
    PASSED: 21
    FAILED: 0

Design compliance
-----------------

- Plugin remains Skill-only: no mcp.json, no package.json, 0 npm
  dependencies. The new client is a single Python file in
  `client/_acp_client.py` and lives entirely inside this Plugin.
- Plugin remains cross-platform: the bundled client uses
  `os.environ` and `pathlib`; SKILL.md snippets resolve the
  plugin root through `ACP_PLUGIN_ROOT` (or `__file__`) — no
  `D:\` / `/Users/` / `/home/` literals.
- Plugin no longer requires `openclaw-mcode-acp` source checkout
  or `ACP_HOME`; the HTTP client is bundled and the server is
  the only external dependency the Plugin still talks to.
- `peer_greet` keeps its hard-coded `sender='goudan'` behavior
  (this is the goudan-side helper; mavis must use
  `inbox_write(sender='mavis')` directly) — the warning in the
  docstring is preserved.
- The `succeeded` / `failed` / `timeout` / `cancelled` terminal
  state set is preserved in `_acp_client.TERMINAL_STATES`.
- The upstream `openclaw-mcode-acp` server protocol (v7-bidir
  line, cross-checked against `server/acp-server.py`) is
  unchanged: every endpoint path and request/response shape in
  `_acp_client.py` matches what the server implements.

Out of scope (deliberately)
---------------------------

- The `openclaw-mcode-acp` repository's own Python SDK
  (`client/acp_client.py` and `openclaw-skill/acp_tools.py`) is
  left untouched. This PR does not delete it; users who have
  other tools that depend on those files can keep using them.
  The Plugin just no longer imports from there.
- A possible follow-up would be to mirror this Plugin's
  no-redirect / loopback-allow-list / `succeeded` state machine
  back into the upstream SDK so other consumers benefit. That is
  tracked separately and is not part of this PR.
@antianqi

Copy link
Copy Markdown
Contributor Author

Pushed 6e56ec4 on top of 9a0939b to close the loop on review #3 (and incidentally retire the 0641f5c claim flagged in review #1+#2).

TL;DR — the Plugin now ships its own HTTP client. Skills import it, the smoke test imports it, the no-redirect regression drives requests through it. There is no longer a "smoke test opener" vs "runtime opener" distinction, because there is only one opener.

Root cause I kept missing across the three rounds: every fix I pushed (c79efc4 for #1+#2, 687a8c8 for #3, 9a0939b for the redirect) patched a path that wasn't the path the Skills actually ran at runtime. The Skills always went through acp_tools -> acp_client in the sibling antianqi/openclaw-mcode-acp repo, and the smoke test went through smoke_helpers in this repo. Review #3 was the first one to call that out by name ("the real token-bearing path used by the Plugin is still not covered by the claimed redirect guarantee"); #1+#2 had it implicitly when they asked me to "pin and test one upstream revision" and I pointed at a commit hash that doesn't exist (0641f5c is not in the upstream repo's history, I checked).

What the new commit does

  1. client/_acp_client.py (new, ~600 lines, stdlib only). Owns the bearer token (resolved from $ACP_TOKEN / ~/.acp_token / <plugin_root>/.acp_token, raising ACPTokenMissing if all three are unset), the no-redirect OpenerDirector, the loopback allow-list ({127.0.0.1, localhost, ::1, [::1]}), and the public API the Skills depend on (create_task, get_task, wait_task, cancel_task, history, list_tasks, stream_task, run_and_stream, stats, inbox_write, inbox_read, inbox_ask, inbox_answer, inbox_sessions, peer_session_id, peer_greet, health). Every endpoint path and request/response shape was cross-checked against server/acp-server.py in the upstream v7-bidir line.

  2. scripts/smoke_helpers.py deleted. Its NoRedirectHandler and build_no_redirect_opener are now inlined in _acp_client.py. The smoke test no longer has a "test-only" path: there is only one opener, and the runtime uses it.

  3. scripts/test_no_redirect.py rewritten. Drives requests through _acp_client._request (the same primitive every Skill call ends up at), so the assertion is now "the runtime's opener refuses redirects" rather than "the smoke test's helper opener refuses redirects". The structure of the test (redirector + capture, 302 on POST, 200 on GET, capture must stay clean) is unchanged.

  4. scripts/stub_server.py (new, ~160 lines, ThreadingHTTPServer). Implements /acp/health, POST /acp/inbox/write, GET /acp/inbox/read, and a /acp/inbox/redirect 302 path. Used by the CI workflow so the inbox roundtrip runs against a real HTTP server on every PR, not against SMOKE_SKIP_LIVE=1.

  5. .github/workflows/openclaw-acp-bridge-smoke.yml rewritten. Removed the actions/checkout antianqi/openclaw-mcode-acp@0641f5c step (the README's "Pinned SDK revision" subsection is the source of Add antianqi/openclaw-acp-bridge v0.1.3 - peer collaboration Bridge for MiniMax Code #3; the Plugin no longer depends on an external SDK, so there's nothing to pin). Removed SMOKE_SKIP_LIVE=1 from the no-redirect step. Added the stub server to the smoke step so the inbox roundtrip runs for real.

  6. skills/acp-*/SKILL.md rewritten. Both now import _acp_client from <plugin_root>/client/ instead of sys.path.insert(0, <ACP_HOME>/openclaw-skill) + from acp_tools import .... Plugin root is resolved through ACP_PLUGIN_ROOT (set by the Plugin runtime) with a __file__-based fallback — no hardcoded D:\ / /Users/ / /home/ literals anywhere.

  7. README.md rewritten. Dropped the "Requirements: $ACP_HOME source checkout" line and the entire "Pinned SDK revision: 0641f5c" subsection (it was the v0.1.3 review's 0641f5c finding; the commit doesn't exist in the upstream repo, I confirmed). Authentication, the "Verify the Plugin works" section, the "Test evidence" section, and the "Limitations" section are all updated to reflect that the Plugin is self-contained.

  8. plugin.json version 0.1.3 -> 0.2.0. Breaking change for users who had set ACP_HOME; no effect for users who only ever set $ACP_TOKEN and ran the server at the default loopback URL.

Validation (local, from a clean checkout)

$ node scripts/validate.mjs
OK   plugin antianqi/openclaw-acp-bridge

$ python scripts/test_no_redirect.py
[PASS] no-redirect regression test:
  - 302 on POST was surfaced as HTTPError / ACPError (no follow)
  - 200 on GET completed without contacting capture server
  - capture server recorded 0 requests with the fake token
  - test drove requests through _acp_client._request / inbox_read
    (the same module the Skills import at runtime)

$ python scripts/stub_server.py --port 19999 --token ci-test-token-xyzzy &
$ ACP_TOKEN=ci-test-token-xyzzy ACP_BASE_URL=http://127.0.0.1:19999 \
      python scripts/smoke.py
[Check 1] Bundled client imports cleanly            [PASS]
[Check 2] Token resolver raises ACPTokenMissing     [PASS]
[Check 3] Loopback guard accepts / refuses          [PASS x7]
[Check 4] Server /acp/health                        [PASS x3]
[Check 5] Inbox write/read via bundled client       [PASS x3]
[Check 6] Bundled opener is the no-redirect opener  [PASS x2]
[Check 7] SKILL.md path resolution                  [PASS x4]
=== Summary ===
PASSED: 21
FAILED: 0

Design compliance (per the Plugin's own conventions)

  • Skill-only plugin: no mcp.json, no package.json, 0 npm dependencies. The new client is a single Python file inside the Plugin.
  • Cross-platform: os.environ + pathlib; SKILL.md snippets resolve the plugin root through ACP_PLUGIN_ROOT / __file__.
  • peer_greet keeps its hard-coded sender='goudan' (with the same docstring warning that mavis must use inbox_write(sender='mavis') directly). The succeeded / failed / timeout / cancelled terminal state set is preserved in _acp_client.TERMINAL_STATES.
  • The upstream openclaw-mcode-acp server protocol is unchanged: every endpoint path and request/response shape in _acp_client.py matches what server/acp-server.py implements in the v7-bidr line.

Out of scope (deliberately)

  • The openclaw-mcode-acp repository's own Python SDK (client/acp_client.py and openclaw-skill/acp_tools.py) is left untouched. This PR does not delete it; users who have other tools depending on those files can keep using them. The Plugin just no longer imports from there.
  • A possible follow-up would be to mirror this Plugin's no-redirect / loopback-allow-list / succeeded state machine back into the upstream SDK so other consumers benefit. Tracked separately; not part of this PR.

If anything in client/_acp_client.py looks off when you read it (especially the loopback guard in _check_loopback, the no-redirect opener construction in _build_opener, or the SSE stream consumer in stream_task), please flag it — those three are the spots where I'd most expect a follow-up review to find something.

@hetaoBackend hetaoBackend left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

当前 head 6e56ec4 仍有阻塞问题:

  • client/_acp_client.py:278-285 的 health() 仍直接调用 urllib.request.urlopen,绕过了 _check_loopback 和 _OPENER;这与 README 所宣称的“每个请求使用 no-redirect opener、每个 base_url 都检查”不一致,health 仍可能绕过统一的 loopback/redirect 安全边界。
  • .github/workflows/openclaw-acp-bridge-smoke.yml 启动 stub_server.py 时未传 --token;stub 默认 token 为空且关闭鉴权,因此现有 smoke roundtrip 没有证明服务端会拒绝缺失或错误 Authorization。scripts/test_no_redirect.py 只覆盖了 redirect/header 路径,不能替代鉴权负向测试。

请统一 health 与其他请求的安全路径,并让 smoke stub 使用非空 token、增加缺失/错误 token 的拒绝断言后再合并。当前 [code]smith 为 SKIPPED,未作为通过依据。

antianqi added a commit to antianqi/MiniMax-Code-Plugins-1 that referenced this pull request Aug 27, 2026
…ic check

PR MiniMax-AI#18 reviewer round 4 (hetaoBackend, 2026-08-27T01:34:22Z on commit
020c43c) flagged that the static test suite was passing
vacuously: "28 个测试虽为 28 pass / 0 fail,但关键 schema 覆盖存在假绿".

Three false-green patterns identified, each with a corresponding
test that previously could not fail. This commit closes them.

Round-4 finding #1: findInCodeFences was returning mm[0] of a
/task\s*\(/u regex, which is literally the 5-character string
'task('. The subsequent parameter-name asserts
(/\bagent_name\s*=/u, /\bbrief\s*=/u, etc.) ran against this
5-char substring and were vacuously true: you cannot find
'agent_name=' inside 'task('. The same hole existed in
background-task's bash-call check.

Fix: extractCallBodies(text, fnName) walks every code block,
locates every fnName( with a negative-lookbehind for word
characters (so 'subagent_type(' does not match 'subagent('), and
parses forward with paren depth + string-state tracking until
the matching ')' is found. Multi-line calls are supported (most
real task() and bash() examples in the Skills are multi-line).
Returns { match, line } where match is the entire 'fnName(...)'
substring. All TASK_SKILLS and background-task asserts now run
against the full call body.

Round-4 finding MiniMax-AI#2: the frontmatter check used
text.indexOf('\n---\n', 4), which only finds the FIRST close.
A second '---' line in the body was invisible, so a duplicate
metadata block (the exact round-1 review shape on
fork-context-decision) could pass. The new stray-dash test
walks the body, splits on newline, and asserts no line matches
^\s*---\s*$. Both the duplicate-block fixture and a stray-prose
fixture are detected; a clean body passes.

Round-4 finding MiniMax-AI#3: fork-context-decision/SKILL.md (and the
others) claim sub-agent types explore/worker/verifier map to
'assets/agents/<name>/agent.md' in mcode. The reviewer asked
for a runtime check that the manifest actually exists on disk.
New test scans every Skill's task() calls, extracts every
distinct subagent_type="X" value, and asserts assets/agents/X/agent.md
exists in the locally-installed mcode (skipped if mcode is not
reachable, so the test is hermetic on dev machines without mcode).
Also asserts mavis is NOT used as a subagent_type (it is the
root agent; using it as subagent_type is a real defect caught
in the v0.1.2 audit). The mcode 0.2.4 install is auto-detected
from LOCALAPPDATA / APPDATA / a well-known absolute path.

Round-4 finding MiniMax-AI#4: background-task describes the
bash(... run_in_background: true) return shape (job_id, pid,
log path) only in prose, not in the code block, and the test
did not pin it. New assert: for every bash(...) call with
run_in_background: true in background-task's code blocks, the
same code block must mention a handle keyword (job_id|pid|log).

Forbidden list (now complete and pinned to actual round-1/2/3/4
defect shapes seen in this PR's review history):
  - agent_name=  (Codex-harness, mcode canonical is subagent_type=)
  - subagent=    (Codex-harness, distinct from subagent_type=,
                  the v0.1.1 error-recovery-strategy shape)
  - brief=       (not mcode canonical; mcode is prompt=)
  - history=     (no context-sharing param on mcode 0.2.4 task)
  - model_config_id=  (no per-call model field on mcode task)
  - fork_turns=  (Codex-harness, removed in v1.0.3)
  - agent_type=  (mcode canonical is subagent_type=)
  - task_name=   (not on mcode 0.2.4 bash)
  - action="kill" (not on mcode 0.2.4 bash)

Negative-first test design
~~~~~~~~~~~~~~~~~~~~~~~~~~

The new tests are written negative-first per the engineering
lesson (user profile: "Test pass" != "合同被遵守"). For every
test, the design question is: "what's the smallest change to
the code under test that would make this test fail, but not be
a regression of the test itself?" Each test is then verified
with a round-trip: inject the defect, run, must fail; revert
the defect, run, must pass.

Round-trip verification (roundtrip-inject3.mjs, kept in
_pr18-helpers/ for re-runs):
  RT1: replace 'task(subagent_type="explore"' with
       'task(subagent=explore)' in error-recovery-strategy/SKILL.md
       line 116. Test result: FAIL with the message
       "error-recovery-strategy: task(...) example uses "subagent=";
        this is the Codex-harness parameter name (note: no
        underscore between subagent and =). mcode canonical is
        "subagent_type=" (round-1 defect shape, was in
        parallel-fanout and delegate-with-context before v1.0.3)".
        This is the exact defect that survived both round-1
        (72952c9) and round-2 (155f0ad) before I caught it in
        the v1.0.5 audit. The static test now catches it.
  RT2: inject a stray '---' line in the body of any Skill.
       Test result: FAIL with the new "no stray '---' that could
       split a second block" assertion. Confirms the
       frontmatter check is no longer single-pass.
  Final state: all 33 tests pass with no injection.

Test count
~~~~~~~~~~

  v1.0.5: tests 28
  v1.0.6: tests 33
  added: extractCallBodies returns the full task(...) body
         (not just "task(")
  added: extractCallBodies returns "bash(...)" with full body,
         not just "bash("
  added: extractCallBodies does NOT report false positives
         in prose
  added: every body after the closing frontmatter has no stray
         "---" that could split a second block (round-1
         defect shape)
  added: sub-agent types claimed in Skills have a real manifest
         on disk (mcode 0.2.4 contract)

5 new tests, all written negative-first, all round-trip-verified.

Files changed
~~~~~~~~~~~~~

  test/codex-harness-patterns.test.mjs  (~190 lines added)

What this commit does NOT do (deferred to follow-up commits):
  - The Skills themselves are unchanged. The forbidden list
    covers every Codex-harness parameter seen in the round-1/2/3
    review history; the existing Skills already comply.
  - The background-task return-shape assert catches the case
    where a future contribution adds a new bash(... run_in_background
    : true) call without a handle in the same block. Existing
    examples already have the handle.
  - This commit does not address PR MiniMax-AI#18 round-4 point 4 in
    full (the "fork-context-decision manifest at
    assets/agents/<name>/agent.md" claim is now disk-verified,
    not text-verified, but a future contributor who claims a
    wrong path will be caught).
  - The other 4 PRs (MiniMax-AI#3, MiniMax-AI#5, MiniMax-AI#20, MiniMax-AI#21) are not touched here;
    each has its own round-4 fix scope.

Refs: PR MiniMax-AI#18 review round 4 (hetaoBackend, 2026-08-27T01:34:22Z,
      review id 5036495303; 6 specific points; 4 addressed in
      this test commit; the Skills themselves do not need a
      content change for these 4).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants