Skip to content

feat: agentmask + service discovery infrastructure#952

Open
FUDCo wants to merge 37 commits into
mainfrom
chip/agentmask
Open

feat: agentmask + service discovery infrastructure#952
FUDCo wants to merge 37 commits into
mainfrom
chip/agentmask

Conversation

@FUDCo
Copy link
Copy Markdown
Contributor

@FUDCo FUDCo commented May 15, 2026

Summary

  • Lands the (current state of) Erik's rekm/agentmask branch as base (agentmask vats, OpenClaw MetaMask vendor plugin, capability schema discovery) onto main
  • Adds service-discovery type vocabulary, service matcher, sample services (Echo, RandomNumber), and a bridge that wires an LLM into the matcher's ranker
  • Adds openclaw service discovery plugin with skill, tools, and validation plan
  • Plus assorted relay/daemon/kernel-cli improvements and fixes

Important caveat: this requires using the updated version of the MetaMask browser extension (Erik's updates to put the ocap kernel into it and then some further stuff I needed to add on top of that) in the chip/agentmask branch of the metamask-extension repo. While the changes this PR represents are being proposed for merging into our main branch, we don't really have that option for the browser extension since it's not our repo to mess with. (I suppose we could merge it with some negotiation with the MetaMake team, but since it's highly experimental I'm pretty sure they wouldn't be too keen on that idea. I believe Erik's plan was to eventually have it in under a flag, but I don't thing that's yet an option.)

Test plan

  • Follow the procedure in packages/agentmask/openclaw-plugin-discovery/VALIDATION.md end-to-end (matcher daemon and LLM service consumer on VPS, sample services + MetaMask browser extension (providing MetaMask services) on laptop, OpenClaw agent driving discovery and contact)
  • Verify (peerId, providerTag) re-registration dedup by reloading the MetaMask extension and confirming the matcher evicts the prior provider entry
  • Verify cold-start path: full reset of all parts, then exercise discovery / find-services / call-service

Note

Medium Risk
Medium risk because it changes ocap CLI process management (new global --home, daemon start interlocks, relay address announcements), which can affect developer workflows and daemon lifecycle behavior. Most other additions are new plugins/docs with limited blast radius.

Overview
Introduces a new @ocap/agentmask package that ships two installable OpenClaw plugins: MetaMask capability vending/usage (metamask_* tools) and service discovery via a matcher (discovery_* + service_* tools), including shared daemon-CLI invocation helpers, session state tracking, skills, and Vitest coverage.

Hardens and extends the ocap CLI: adds a global --home flag (overriding $OCAP_HOME per invocation), adds relay start --public-ip (and $LIBP2P_RELAY_PUBLIC_IP) to announce a reachable VPS address, and prevents accidental daemon orphaning by checking daemon.pid/liveness before spawning or starting a new daemon; also updates config/sandbox defaults (.yarnrc.yml proxy wiring, Claude sandbox allowances) and adds/updates changelogs and operational docs.

Reviewed by Cursor Bugbot for commit e046f8d. Bugbot is set up for automated code reviews on this repo. Configure here.

rekmarks and others added 30 commits May 14, 2026 17:48
Add the @ocap/agentmask package with an OpenClaw plugin that lets an LLM
agent request and use wallet capabilities from a MetaMask capability vendor
via the OCAP kernel daemon. The plugin provides three tools:

- metamask_request_capability: redeem OCAP URL and request capabilities
- metamask_call_capability: call methods on obtained capabilities
- metamask_list_capabilities: list capabilities obtained in the session

Also includes docs migrated from the extension repo (capability-vendor,
demo-two-way-comms, kernel-guide) and updates demo-two-way-comms with
parallel Manual CLI / OpenClaw agent instructions for the away side.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…edemption

The agent can now ask the user for their OCAP URL during conversation
and redeem it on the fly via the new metamask_obtain_vendor tool, rather
than requiring the URL to be pre-configured in .env or plugin config.

- Add metamask_obtain_vendor tool (accepts URL, redeems, stores vendor kref)
- Move ocapUrl into mutable PluginState (seeded from config, overridable)
- ensureVendor() directs the agent to metamask_obtain_vendor when no URL set
- Update SKILL.md workflow and demo doc OpenClaw section

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ion__

After obtaining a capability from the vendor, request_capability now
calls __getDescription__ on it to retrieve method schemas (name, args,
return type). The full schema is shown in the tool response and stored
in plugin state. list_capabilities also shows method names.

Discovery is best-effort — if the capability is not discoverable, the
tool still works without method listings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@socket-security
Copy link
Copy Markdown

socket-security Bot commented May 15, 2026

Warning

MetaMask internal reviewing guidelines:

  • Do not ignore-all
  • Each alert has instructions on how to review if you don't know what it means. If lost, ask your Security Liaison or the supply-chain group
  • Copy-paste ignore lines for specific packages or a group of one kind with a note on what research you did to deem it safe.
    @SocketSecurity ignore npm/PACKAGE@VERSION
Action Severity Alert  (click "▶" to expand/collapse)
Warn Low
Potential code anomaly (AI signal): npm @emnapi/core is 100.0% likely to have a medium risk anomaly

Notes: No explicit evidence of overt malware (network exfiltration, credential theft, backdoors, or filesystem/process activity) appears in this fragment. However, the module contains high-sensitivity dynamic execution capabilities: napi_run_script performs eval-like execution of a JavaScript string obtained from WebAssembly, and emnapiCreateFunction can use the Function constructor for wrapper generation. Combined with wasm-driven indirect callback dispatch and reflective object mutation, this runtime is security-sensitive and should only be used with fully trusted WebAssembly and tightly controlled inputs.

Confidence: 1.00

Severity: 0.60

From: ?npm/rolldown@1.0.0-rc.17npm/eslint-plugin-import-x@4.10.6npm/eslint-import-resolver-typescript@4.3.4npm/@emnapi/core@1.10.0

ℹ Read more on: This package | This alert | What is an AI-detected potential code anomaly?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: An AI system found a low-risk anomaly in this package. It may still be fine to use, but you should check that it is safe before proceeding.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/@emnapi/core@1.10.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn Low
Potential code anomaly (AI signal): npm @emnapi/core is 100.0% likely to have a medium risk anomaly

Notes: Primary concern is direct dynamic code execution. napi_run_script uses eval() on a string originating from wasm-provided input, and ee uses new Function(...) to construct wrapper functions. If the wasm module or its inputs are attacker-controlled, this provides JavaScript code execution in the host context. Aside from these dynamic execution sinks, the remaining code mainly performs wasm memory/table management and worker async orchestration typical of such runtimes, with no clear hardcoded exfiltration or backdoor behavior in this fragment.

Confidence: 1.00

Severity: 0.60

From: ?npm/rolldown@1.0.0-rc.17npm/eslint-plugin-import-x@4.10.6npm/eslint-import-resolver-typescript@4.3.4npm/@emnapi/core@1.10.0

ℹ Read more on: This package | This alert | What is an AI-detected potential code anomaly?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: An AI system found a low-risk anomaly in this package. It may still be fine to use, but you should check that it is safe before proceeding.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/@emnapi/core@1.10.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn Low
Potential code anomaly (AI signal): npm @emnapi/core is 100.0% likely to have a medium risk anomaly

Notes: This module appears to be a legitimate wasm-to-JS/Node-API bridge/runtime, but it contains high-impact dynamic execution capabilities: napi_run_script uses eval() on a string originating from the WASM/handle side, and the binding layer can generate functions via new Function(). It also performs indirect host callback invocation based on runtime handles selected by worker/work-queue control. No explicit exfiltration/backdoor behavior is visible in the provided fragment, so malware likelihood is low, but security risk is moderate-to-high due to host-context code execution if the WASM module or its inputs are not fully trusted.

Confidence: 1.00

Severity: 0.60

From: ?npm/rolldown@1.0.0-rc.17npm/eslint-plugin-import-x@4.10.6npm/eslint-import-resolver-typescript@4.3.4npm/@emnapi/core@1.10.0

ℹ Read more on: This package | This alert | What is an AI-detected potential code anomaly?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: An AI system found a low-risk anomaly in this package. It may still be fine to use, but you should check that it is safe before proceeding.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/@emnapi/core@1.10.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn Low
Potential code anomaly (AI signal): npm @rolldown/binding-wasm32-wasi is 100.0% likely to have a medium risk anomaly

Notes: This loader establishes a Node.js WASI/worker environment that: 1) passes the entire host process.env into the WASI instance (exposing all environment variables, including secrets, to loaded modules); 2) preopens the filesystem root (granting broad file read/write access under the host’s root directory); and 3) implements importScripts via synchronous fs.readFileSync + eval (allowing any local JS file to be executed in the loader context). If an untrusted or compromised WASM module or script is provided, it can read sensitive environment variables, access or modify arbitrary files, and execute arbitrary JavaScript—posing a moderate security risk. Recommended mitigations: restrict WASI preopens to a minimal directory, limit or sanitize environment variables passed into WASI, and replace or sandbox the eval-based importScripts mechanism.

Confidence: 1.00

Severity: 0.60

From: ?npm/rolldown@1.0.0-rc.17npm/@rolldown/binding-wasm32-wasi@1.0.0-rc.17

ℹ Read more on: This package | This alert | What is an AI-detected potential code anomaly?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: An AI system found a low-risk anomaly in this package. It may still be fine to use, but you should check that it is safe before proceeding.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/@rolldown/binding-wasm32-wasi@1.0.0-rc.17. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn Low
Potential code anomaly (AI signal): npm @rolldown/binding-wasm32-wasi is 100.0% likely to have a medium risk anomaly

Notes: A JS loader bootstraps a WASI-enabled WebAssembly module and forwards the full host process.env into the WASI environment and worker contexts while preopening the host filesystem root. This design enables an untrusted or tampered WASM binary to read environment variables and access numerous files, potentially exfiltrating data through any available host or network channel. Treat the module as high-risk unless the WASM artifact is from a trusted source; mitigate by restricting preopens to specific directories, avoiding full process.env exposure, and validating the integrity of the WASM binary.

Confidence: 1.00

Severity: 0.60

From: ?npm/rolldown@1.0.0-rc.17npm/@rolldown/binding-wasm32-wasi@1.0.0-rc.17

ℹ Read more on: This package | This alert | What is an AI-detected potential code anomaly?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: An AI system found a low-risk anomaly in this package. It may still be fine to use, but you should check that it is safe before proceeding.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/@rolldown/binding-wasm32-wasi@1.0.0-rc.17. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn Low
Potential code anomaly (AI signal): npm rolldown is 100.0% likely to have a medium risk anomaly

Notes: No direct signs of classic malware behavior (e.g., exfiltration/persistence/backdoors) are visible in this JS wrapper. However, it has meaningful security/supply-chain risk characteristics: it can load and execute native code from an environment-controlled path (NAPI_RS_NATIVE_LIBRARY_PATH), and in WebContainer it may execute pnpm to install a binding at runtime before requiring it. The actual maliciousness probability is therefore low-to-moderate for this wrapper, but the execution impact is high if the environment or package supply chain is compromised.

Confidence: 1.00

Severity: 0.60

From: packages/kernel-utils/package.jsonnpm/rolldown@1.0.0-rc.17

ℹ Read more on: This package | This alert | What is an AI-detected potential code anomaly?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: An AI system found a low-risk anomaly in this package. It may still be fine to use, but you should check that it is safe before proceeding.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/rolldown@1.0.0-rc.17. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn Low
Potential code anomaly (AI signal): npm undici is 100.0% likely to have a medium risk anomaly

Notes: The code performs an in-place re-encoding of a local file (undici-fetch.js) and overwrites it with latin1-encoded data. There is no evidence of exfiltration, backdoors, or network activity. However, the lack of validation, error handling, and the fact that it can corrupt or permanently alter a source file constitutes a nontrivial risk. In a supply-chain or extension context, such a script could be misused to tamper with code. It is not inherently malicious by itself but is risky and should be restricted or audited before typical usage in a build or runtime environment.

Confidence: 1.00

Severity: 0.60

From: ?npm/jsdom@29.1.1npm/undici@7.25.0

ℹ Read more on: This package | This alert | What is an AI-detected potential code anomaly?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: An AI system found a low-risk anomaly in this package. It may still be fine to use, but you should check that it is safe before proceeding.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/undici@7.25.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 15, 2026

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 70.6%
⬇️ -0.82%
8525 / 12075
🔵 Statements 70.41%
⬇️ -0.84%
8664 / 12305
🔵 Functions 71.58%
⬇️ -0.67%
2051 / 2865
🔵 Branches 64.18%
⬇️ -0.89%
3443 / 5364
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/agentmask/src/index.ts 100% 100% 100% 100%
packages/kernel-cli/src/app.ts 0%
🟰 ±0%
0%
🟰 ±0%
0%
🟰 ±0%
0%
🟰 ±0%
36-465
packages/kernel-cli/src/commands/daemon-entry.ts 0%
🟰 ±0%
0%
🟰 ±0%
0%
🟰 ±0%
0%
🟰 ±0%
13-141
packages/kernel-cli/src/commands/daemon-spawn.ts 0%
🟰 ±0%
0%
🟰 ±0%
0%
🟰 ±0%
0%
🟰 ±0%
10-84
packages/kernel-cli/src/commands/relay.ts 90.12%
⬆️ +3.68%
98.03%
⬆️ +1.37%
63.63%
⬆️ +16.97%
93.5%
⬆️ +2.43%
139, 145-151, 202, 209
packages/kernel-node-runtime/src/daemon/rpc-socket-server.ts 0%
🟰 ±0%
0%
🟰 ±0%
0%
🟰 ±0%
0%
🟰 ±0%
43-270
packages/kernel-utils/src/libp2p-relay.ts 0%
🟰 ±0%
0%
🟰 ±0%
0%
🟰 ±0%
0%
🟰 ±0%
19-220
packages/kernel-utils/src/nodejs/index.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/kernel-utils/src/nodejs/libp2p-relay-home.ts 100% 100% 100% 100%
packages/llm-bridge/src/conversation.ts 96.66% 78.57% 100% 96.66% 142
packages/llm-bridge/src/index.ts 0% 0% 0% 0% 36-85
packages/llm-bridge/src/openclaw-client.ts 94.44% 100% 75% 100% 76
packages/llm-bridge/src/protocol.ts 0% 100% 100% 0% 26-86
packages/llm-bridge/src/run-bridge.ts 0% 0% 0% 0% 53-184
packages/ocap-kernel/src/index.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/ocap-kernel/src/remotes/kernel/OcapURLManager.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/ocap-kernel/src/vats/VatManager.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/sample-services/src/cluster-config.ts 100% 100% 100% 100%
packages/sample-services/src/index.ts 100% 100% 100% 100%
packages/sample-services/src/echo-service/index.ts 0% 0% 0% 0% 18-76
packages/sample-services/src/echo-service/service.ts 0% 0% 0% 0% 4-24
packages/sample-services/src/random-number-service/index.ts 0% 0% 0% 0% 21-79
packages/sample-services/src/random-number-service/service.ts 0% 0% 0% 0% 4-56
packages/sample-services/src/vat-lib/contact-endpoint.ts 0% 0% 0% 0% 60-101
packages/sample-services/src/vat-lib/describe.ts 0% 100% 0% 0% 21-25
packages/sample-services/src/vat-lib/index.ts 100% 100% 100% 100%
packages/sample-services/src/vat-lib/matcher-registration.ts 0% 0% 0% 0% 39-70
packages/sample-services/src/vat-lib/registration-token.ts 0% 0% 0% 0% 24-37
packages/service-discovery-types/src/contact.ts 100% 100% 100% 100%
packages/service-discovery-types/src/index.ts 100% 100% 100% 100%
packages/service-discovery-types/src/matcher.ts 100% 100% 100% 100%
packages/service-discovery-types/src/method-schema-convert.ts 94.11% 86.36% 100% 94.11% 54-55
packages/service-discovery-types/src/service-description.ts 100% 100% 100% 100%
packages/service-matcher/src/cluster-config.ts 100% 100% 100% 100%
packages/service-matcher/src/index.ts 100% 100% 100% 100%
packages/service-matcher/src/matcher-vat/index.ts 87.83% 67.24% 90.62% 88.96% 125-127, 151, 182-184, 238-246, 275, 282, 307, 312-314, 344-346, 365-367, 385, 407, 591, 601, 624-626
Generated in workflow #4418 for commit e046f8d by the Vitest Coverage Report Action

@FUDCo FUDCo marked this pull request as ready for review May 15, 2026 06:48
@FUDCo FUDCo requested a review from a team as a code owner May 15, 2026 06:48
Comment thread packages/agentmask/openclaw-plugin-discovery/index.ts
Comment thread packages/agentmask/openclaw-plugin-metamask/daemon.ts
FUDCo added 2 commits May 15, 2026 11:45
Pre-redemption of the configured matcher URL was fire-and-forget, so a
tool call landing during the redemption window would see a misleading
'no matcher connection' error from requireMatcher. Park the pending
promise in state.matcherPending and have requireMatcher await it,
surfacing the underlying failure when redemption rejects.
… discovery/daemon.ts

Both OpenClaw plugins kept a near-identical copy of the daemon caller;
the discovery version added optional ocapHome support, so a future fix in
one copy would drift from the other. Sync the two files (metamask now
accepts the same optional ocapHome param, even though it does not use it
today) and add a header note instructing future editors to keep them in
sync. Plugins remain self-contained so 'openclaw plugins install -l' still
works on either directory in isolation.
Comment thread discovery-plan.md Outdated
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e046f8d. Configure here.

pluginValue: pluginConfig?.timeoutMs,
envVar: 'OCAP_TIMEOUT_MS',
parse: Number,
}) ?? DEFAULT_TIMEOUT_MS;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

NaN timeout from non-numeric env var bypasses default

Medium Severity

When OCAP_TIMEOUT_MS contains a non-numeric string (e.g. "abc"), parse: Number produces NaN. The nullish coalescing ?? DEFAULT_TIMEOUT_MS doesn't catch NaN (only null/undefined), so timeoutMs becomes NaN. setTimeout(fn, NaN) fires immediately (0 ms), causing every daemon call to be instantly killed with SIGKILL and a confusing "timed out after NaNms" error.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e046f8d. Configure here.

Comment on lines +95 to +124
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function tersePeer(peer: any): string {
const peerId = peer.toString();
if (tersePeers.has(peerId)) {
return tersePeers.get(peerId) as string;
}
tersePeerIdx += 1;
const terse = `<PEER-${tersePeerIdx}>`;
tersePeers.set(peerId, terse);
logger.log(`[PEER] ${terse} = ${peerId}`);
return terse;
}

/**
* Produce a more legible multiaddr string
*
* @param multiaddr - The multiaddr we care about.
* @returns a terser form of `multiaddr`.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function terseAddr(multiaddr: any): string {
const raw = multiaddr.toString();
const slash = raw.lastIndexOf('/') + 1;
if (slash <= 0) {
return raw;
}
const prefix = raw.substring(0, slash);
const suffix = raw.substring(slash);
return `${prefix}${tersePeer(suffix)}`;
}
Copy link
Copy Markdown
Contributor

@sirtimid sirtimid May 21, 2026

Choose a reason for hiding this comment

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

Suggested change
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function tersePeer(peer: any): string {
const peerId = peer.toString();
if (tersePeers.has(peerId)) {
return tersePeers.get(peerId) as string;
}
tersePeerIdx += 1;
const terse = `<PEER-${tersePeerIdx}>`;
tersePeers.set(peerId, terse);
logger.log(`[PEER] ${terse} = ${peerId}`);
return terse;
}
/**
* Produce a more legible multiaddr string
*
* @param multiaddr - The multiaddr we care about.
* @returns a terser form of `multiaddr`.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function terseAddr(multiaddr: any): string {
const raw = multiaddr.toString();
const slash = raw.lastIndexOf('/') + 1;
if (slash <= 0) {
return raw;
}
const prefix = raw.substring(0, slash);
const suffix = raw.substring(slash);
return `${prefix}${tersePeer(suffix)}`;
}
function tersePeer(peer: PeerId): string {
const peerId = peer.toString();
if (tersePeers.has(peerId)) {
return tersePeers.get(peerId) as string;
}
tersePeerIdx += 1;
const terse = `<PEER-${tersePeerIdx}>`;
tersePeers.set(peerId, terse);
logger.log(`[PEER] ${terse} = ${peerId}`);
return terse;
}
/**
* Produce a more legible multiaddr string
*
* @param multiaddr - The multiaddr we care about.
* @returns a terser form of `multiaddr`.
*/
function terseAddr(multiaddr: Multiaddr): string {
const raw = multiaddr.toString();
const slash = raw.lastIndexOf('/') + 1;
if (slash <= 0) {
return raw;
}
const prefix = raw.substring(0, slash);
const suffix = raw.substring(slash);
if (!isPeerId(suffix)) {
return raw;
}
return `${prefix}${tersePeer(suffix)}`;
}

So we can avoid the any here

Copy link
Copy Markdown
Contributor

@sirtimid sirtimid left a comment

Choose a reason for hiding this comment

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

A batch of type-design and error-handling suggestions surfaced by a multi-agent review pass. Each is non-blocking but cheap to address now.

Comment on lines +119 to +122
export type ContactResponse =
| PublicContactResponse
| PermissionedContactResponse
| ValidatedClientContactResponse;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggestion: make ContactResponse a discriminated union.

As written, this is a non-discriminated union: the PublicContactResponse = ServicePoint arm collides syntactically with the two object-shaped arms (both have [key]: ...), so consumers must structurally probe ('credentialSubmissionPoint' in response) to narrow. A ServicePoint that happens to define a method named credentialSpec would be misclassified.

Cheap fix now while in-tree consumers can be counted on one hand:

export type ContactResponse =
  | { kind: 'public'; service: ServicePoint }
  | ({ kind: 'permissioned' } & PermissionedContactResponse)
  | ({ kind: 'validatedClient' } & ValidatedClientContactResponse);

This matches the pattern already used by TypeSpec in service-description.ts and by Request/Reply in llm-bridge/src/protocol.ts, and lets consumers switch (response.kind) exhaustively. The downstream change is ~2 lines in sample-services/src/vat-lib/contact-endpoint.ts (return { kind: 'public', service }) and matching destructuring in the discovery plugin's initiate-contact.ts.

Comment on lines +43 to +55
export type PluginState = {
matcher: MatcherEntry | undefined;
/**
* Pending pre-redemption of a configured matcher URL. Set during
* `register()` when `matcherUrl` was supplied via config or env; cleared
* on either resolution or rejection. `requireMatcher` awaits it so a
* tool call issued before the pre-redemption settles does not see a
* misleading "no matcher connection" error.
*/
matcherPending: Promise<MatcherEntry> | undefined;
contacts: Map<string, ContactEntry>;
services: Map<string, ServiceEntry>;
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggestion: model the matcher slot as a discriminated 3-state union.

matcher: MatcherEntry | undefined + matcherPending: Promise<MatcherEntry> | undefined permits the contradictory state where both are set — a tool could in principle observe a resolved matcher and a still-pending pre-redemption. The matcher slot is naturally a 3-state value:

export type MatcherSlot =
  | { status: 'absent' }
  | { status: 'pending'; promise: Promise<MatcherEntry> }
  | { status: 'resolved'; entry: MatcherEntry };

export type PluginState = {
  matcher: MatcherSlot;
  contacts: Map<string, ContactEntry>;
  services: Map<string, ServiceEntry>;
};

requireMatcher then becomes a clean switch on state.matcher.status. Three or four call sites to update. Particularly valuable here because the e359bf49b fix ("await pre-redemption before reporting no matcher") was exactly about racing on these two fields.

Comment on lines +54 to +58
async ingest(request: IngestRequest): Promise<void> {
persistent.push({ role: 'user', content: formatIngest(request) });
const reply = await client.chat(persistent);
persistent.push({ role: 'assistant', content: reply });
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: persistent history corrupted on ingest failure.

If client.chat rejects (gateway 5xx, token error, transient network), the user turn is left in persistent with no paired assistant — every subsequent ingest will send a malformed conversation (multi-user-turns-in-a-row) that violates Anthropic/OpenAI conversational conventions and may degrade ranker behavior.

The matcher already rolls back its own registry entry on bridge ingest failure (matcher-vat/index.ts commitRegistration); the bridge's persistent log should match. Two options:

async ingest(request: IngestRequest): Promise<void> {
  const before = persistent.length;
  persistent.push({ role: 'user', content: formatIngest(request) });
  try {
    const reply = await client.chat(persistent);
    persistent.push({ role: 'assistant', content: reply });
  } catch (error) {
    persistent.length = before;
    throw error;
  }
},

Or — even simpler — build the user message locally, only push both after the reply resolves:

const userMsg = { role: 'user' as const, content: formatIngest(request) };
const reply = await client.chat([...persistent, userMsg]);
persistent.push(userMsg, { role: 'assistant', content: reply });

`Use 'ocap daemon stop' first.`,
);
}
// Stale socket file from a previous run — clean up.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggestion (applies to the pre-existing try { await unlink(socketPath); } catch {} just below): narrow the catch to ENOENT.

The comment says "file may not exist" but the bare catch {} covers EPERM, EACCES, EBUSY, and EISDIR (someone replaced the socket with a directory) too. When any of these hit, the subsequent server.listen(socketPath) will fail with an opaque EADDRINUSE, leaving the operator wondering why the brand-new daemon-liveness interlock didn't help.

try {
  await unlink(socketPath);
} catch (error) {
  if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
    throw error;
  }
}

Weakening this catch is what lets the rest of the new isSocketLive interlock land safely.

Comment on lines +536 to +555
for (const entry of ranked) {
const registered = registry.get(entry.id);
if (!registered) {
// The bridge cited an id we no longer have — could happen if
// the LLM hallucinates one, or if a service was unregistered
// between ingest and query. Skip and log; don't bubble up.
log(`bridge cited unknown id ${entry.id}; skipping`);
continue;
}
matches.push(
harden({
description: registered.description,
rationale: entry.rationale,
}),
);
}
log(
`findServices("${query.description.slice(0, 80)}") → ${matches.length} match(es)`,
);
return matches;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggestion: distinguish "all-cited-ids-unknown" from "no matches".

A common failure mode of LLM rankers is to hallucinate plausible-looking IDs — especially after a context-reset or prompt-injection bug. If every id in the bridge reply is unknown, this loop currently returns [], which the consumer (discovery_find_services tool) reports to the agent as "No matching services found." indistinguishable from a legitimate zero-match query.

Track the drop count and surface it when it's suspicious:

let dropped = 0;
for (const entry of ranked) {
  const registered = registry.get(entry.id);
  if (!registered) {
    dropped += 1;
    log(`bridge cited unknown id ${entry.id}; skipping`);
    continue;
  }
  matches.push(harden({ description: registered.description, rationale: entry.rationale }));
}
if (ranked.length > 0 && matches.length === 0) {
  throw new Error(
    `matcher: LLM bridge cited only unknown ids (${dropped}/${ranked.length}); ` +
    'registry may be out of sync or ranker is hallucinating.',
  );
}

This turns a silent correctness bug (the exact class the matcher is most exposed to) into a loud one.

Comment on lines +25 to +44
export const ServiceQueryStruct: Struct<ServiceQuery> = object({
description: string(),
});

/**
* A candidate service returned by a matcher query.
*/
export type ServiceMatch = {
description: ServiceDescription;
/** Matcher's natural-language rationale for this match. */
rationale?: string;
};

export const ServiceMatchStruct: Struct<ServiceMatch> = object({
description: ServiceDescriptionStruct,
rationale: exactOptional(string()),
});

export const ServiceMatchListStruct: Struct<ServiceMatch[]> =
array(ServiceMatchStruct);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggestion: add _AssertSameShape drift guards for ServiceQuery and ServiceMatch.

Struct<ServiceQuery> = object({...}) enforces "struct fits type" but not the reverse — if someone adds a field to the type and forgets the struct (or vice-versa), the drift goes unnoticed at compile time. service-description.ts already establishes the two-way drift trap; copy the pattern here for 5 lines of zero-runtime-cost insurance:

type _AssertSameShape<A, B> = [A, B] extends [B, A] ? true : never;
type _AssertServiceQuery = _AssertSameShape<Infer<typeof ServiceQueryStruct>, ServiceQuery>;
type _AssertServiceMatch = _AssertSameShape<Infer<typeof ServiceMatchStruct>, ServiceMatch>;

ServiceMatchList is already Infer-derived so it's safe.

Comment on lines +9 to +24
/**
* The vat-facing shape of the `ocapURLIssuerService` kernel-service
* endowment: accepts a remotable and returns an OCAP URL that resolves to
* that remotable when redeemed elsewhere.
*/
export type OcapURLIssuerService = {
issue: (obj: unknown) => Promise<string>;
};

/**
* The vat-facing shape of the `ocapURLRedemptionService` kernel-service
* endowment: accepts an OCAP URL and returns the referenced remotable.
*/
export type OcapURLRedemptionService = {
redeem: (url: string) => Promise<unknown>;
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggestion: also use these new types as the return types of getIssuerService / getRedemptionService / getServices.

The newly-exported types here aren't actually used by the manager's public API — getIssuerService(): object and getRedemptionService(): object still return bare object, and getServices() types both services as { name: string; service: object } (pre-existing code, not in this diff). Two-line tweak that propagates the named types out:

getServices(): {
  issuerService: { name: string; service: OcapURLIssuerService };
  redemptionService: { name: string; service: OcapURLRedemptionService };
} { ... }

getIssuerService(): OcapURLIssuerService {
  return this.#ocapURLIssuerService as OcapURLIssuerService;
}

getRedemptionService(): OcapURLRedemptionService {
  return this.#ocapURLRedemptionService as OcapURLRedemptionService;
}

The #-private fields stay typed as object (because Far(...) returns unknown-ish), but the accessor surface gets the named contract — which seems to be the point of adding these types in the first place.

'resetState',
]);

const configSchema: PluginConfigSchema = {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggestion: replace hand-rolled safeParse with a superstruct wrapper (both plugins).

The project's CLAUDE.md says "Use @metamask/superstruct for runtime type checking and to define object types," but both plugin index.ts files implement configSchema.safeParse by hand: typeof-checking each known field and walking KNOWN_KEYS manually. The external PluginConfigSchema shape ({ safeParse, jsonSchema }) is what the OpenClaw SDK needs — the implementation can be a thin wrapper:

import { object, string, exactOptional, number, is, type Struct } from '@metamask/superstruct';

const PluginConfigStruct = object({
  cliPath: exactOptional(string()),
  matcherUrl: exactOptional(string()),
  ocapHome: exactOptional(string()),
  timeoutMs: exactOptional(number()),
});

const configSchema: PluginConfigSchema = {
  safeParse(value: unknown) {
    return is(value, PluginConfigStruct)
      ? { success: true, data: value }
      : { success: false, error: 'invalid plugin config' };
  },
  jsonSchema: { /* unchanged */ },
};

Halves the code, eliminates the KNOWN_KEYS allow-list drift hazard, and matches the rest of the codebase. Same pattern applies in openclaw-plugin-metamask/index.ts:41.

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.

3 participants