a2acalling 0.6.73 → 0.6.75
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.a2a-manifest.json +2 -2
- package/.c8rc.json +16 -0
- package/.node-version +1 -0
- package/.serena/project.yml +126 -0
- package/ARCHITECTURE.md +40 -16
- package/CONVENTIONS.md +39 -6
- package/biome.json +27 -0
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +146 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/src/index.html +131 -0
- package/coverage/src/index.js.html +313 -0
- package/coverage/src/lib/agent-card.js.html +418 -0
- package/coverage/src/lib/call-monitor.js.html +700 -0
- package/coverage/src/lib/callbook.js.html +1183 -0
- package/coverage/src/lib/claude-subagent.js.html +2173 -0
- package/coverage/src/lib/client.js.html +2134 -0
- package/coverage/src/lib/config.js.html +1525 -0
- package/coverage/src/lib/conversation-driver.js.html +1909 -0
- package/coverage/src/lib/conversations.js.html +2575 -0
- package/coverage/src/lib/crypto.js.html +424 -0
- package/coverage/src/lib/dashboard-events.js.html +724 -0
- package/coverage/src/lib/disclosure.js.html +2461 -0
- package/coverage/src/lib/external-ip.js.html +718 -0
- package/coverage/src/lib/index.html +506 -0
- package/coverage/src/lib/invite-host.js.html +754 -0
- package/coverage/src/lib/local-request.js.html +292 -0
- package/coverage/src/lib/logger.js.html +2116 -0
- package/coverage/src/lib/openclaw-integration.js.html +1102 -0
- package/coverage/src/lib/pid-file.js.html +394 -0
- package/coverage/src/lib/port-scanner.js.html +334 -0
- package/coverage/src/lib/prompt-template.js.html +1150 -0
- package/coverage/src/lib/runtime-adapter.js.html +2188 -0
- package/coverage/src/lib/summarizer.js.html +553 -0
- package/coverage/src/lib/summary-formatter.js.html +589 -0
- package/coverage/src/lib/summary-prompt.js.html +694 -0
- package/coverage/src/lib/tokens.js.html +2689 -0
- package/coverage/src/lib/turn-timeout.js.html +241 -0
- package/coverage/src/lib/update-checker.js.html +364 -0
- package/coverage/src/lib/update-manager.js.html +1024 -0
- package/coverage/src/routes/a2a.js.html +3724 -0
- package/coverage/src/routes/callbook.js.html +511 -0
- package/coverage/src/routes/dashboard.js.html +4819 -0
- package/coverage/src/routes/index.html +146 -0
- package/coverage/src/server.js.html +3622 -0
- package/coverage/tmp/coverage-1605378-1772576706365-0.json +1 -0
- package/coverage/tmp/coverage-1605384-1772576607459-0.json +1 -0
- package/coverage/tmp/coverage-1605410-1772576631155-0.json +1 -0
- package/coverage/tmp/coverage-1606942-1772576636869-0.json +1 -0
- package/coverage/tmp/coverage-1607004-1772576637454-0.json +1 -0
- package/coverage/tmp/coverage-1607044-1772576637876-0.json +1 -0
- package/coverage/tmp/coverage-1607096-1772576638356-0.json +1 -0
- package/coverage/tmp/coverage-1607145-1772576638777-0.json +1 -0
- package/coverage/tmp/coverage-1607201-1772576639277-0.json +1 -0
- package/coverage/tmp/coverage-1607247-1772576639755-0.json +1 -0
- package/coverage/tmp/coverage-1607317-1772576640083-0.json +1 -0
- package/coverage/tmp/coverage-1607381-1772576640465-0.json +1 -0
- package/coverage/tmp/coverage-1607446-1772576640868-0.json +1 -0
- package/coverage/tmp/coverage-1607501-1772576641662-0.json +1 -0
- package/coverage/tmp/coverage-1607534-1772576641565-0.json +1 -0
- package/coverage/tmp/coverage-1607627-1772576641871-0.json +1 -0
- package/coverage/tmp/coverage-1607665-1772576642172-0.json +1 -0
- package/coverage/tmp/coverage-1607714-1772576642577-0.json +1 -0
- package/coverage/tmp/coverage-1607788-1772576643466-0.json +1 -0
- package/coverage/tmp/coverage-1607924-1772576644678-0.json +1 -0
- package/coverage/tmp/coverage-1607978-1772576645154-0.json +1 -0
- package/coverage/tmp/coverage-1608035-1772576645564-0.json +1 -0
- package/coverage/tmp/coverage-1608106-1772576645967-0.json +1 -0
- package/coverage/tmp/coverage-1608179-1772576648656-0.json +1 -0
- package/coverage/tmp/coverage-1608196-1772576647367-0.json +1 -0
- package/coverage/tmp/coverage-1608217-1772576648557-0.json +1 -0
- package/coverage/tmp/coverage-1608256-1772576651378-0.json +1 -0
- package/coverage/tmp/coverage-1608265-1772576650058-0.json +1 -0
- package/coverage/tmp/coverage-1608289-1772576651358-0.json +1 -0
- package/coverage/tmp/coverage-1608591-1772576660465-0.json +1 -0
- package/coverage/tmp/coverage-1608648-1772576659272-0.json +1 -0
- package/coverage/tmp/coverage-1608665-1772576660374-0.json +1 -0
- package/coverage/tmp/coverage-1608677-1772576661268-0.json +1 -0
- package/coverage/tmp/coverage-1608684-1772576663968-0.json +1 -0
- package/coverage/tmp/coverage-1608692-1772576662575-0.json +1 -0
- package/coverage/tmp/coverage-1608701-1772576663873-0.json +1 -0
- package/coverage/tmp/coverage-1608718-1772576666674-0.json +1 -0
- package/coverage/tmp/coverage-1608725-1772576665463-0.json +1 -0
- package/coverage/tmp/coverage-1608738-1772576666577-0.json +1 -0
- package/coverage/tmp/coverage-1608753-1772576669664-0.json +1 -0
- package/coverage/tmp/coverage-1608763-1772576668275-0.json +1 -0
- package/coverage/tmp/coverage-1608771-1772576669563-0.json +1 -0
- package/coverage/tmp/coverage-1608828-1772576676574-0.json +1 -0
- package/coverage/tmp/coverage-1609244-1772576675272-0.json +1 -0
- package/coverage/tmp/coverage-1609342-1772576676478-0.json +1 -0
- package/coverage/tmp/coverage-1609450-1772576686954-0.json +1 -0
- package/coverage/tmp/coverage-1609841-1772576685466-0.json +1 -0
- package/coverage/tmp/coverage-1609925-1772576686855-0.json +1 -0
- package/coverage/tmp/coverage-1610399-1772576692469-0.json +1 -0
- package/coverage/tmp/coverage-1611283-1772576703062-0.json +1 -0
- package/coverage/tmp/coverage-1611294-1772576703755-0.json +1 -0
- package/docs/assessments/2026-02-27-google-a2a-protocol-assessment.md +292 -0
- package/docs/plans/2026-03-01-a2a-68-openclaw-integration-tests.md +676 -0
- package/docs/plans/2026-03-01-a2a-77-invoke-security-tests.md +661 -0
- package/docs/plans/2026-03-03-a2a-91-macos-packaging-plan.md +144 -0
- package/docs/signing-setup.md +49 -0
- package/eslint.config.js +16 -0
- package/knip.json +17 -0
- package/native/macos/certs/appldevcert.cer +0 -0
- package/native/macos/src-tauri/binaries/.gitkeep +0 -0
- package/native/macos/src-tauri/capabilities/default.json +11 -1
- package/native/macos/src-tauri/entitlements.plist +14 -0
- package/native/macos/src-tauri/src/discovery.rs +14 -3
- package/native/macos/src-tauri/src/health.rs +4 -0
- package/native/macos/src-tauri/src/lib.rs +52 -11
- package/native/macos/src-tauri/src/server.rs +262 -26
- package/native/macos/src-tauri/tauri.conf.json +13 -4
- package/package.json +16 -2
- package/pkg.config.json +14 -0
- package/scripts/build-standalone.sh +106 -0
- package/scripts/install-openclaw.js +3 -5
- package/scripts/smoke-test-standalone.sh +101 -0
- package/scripts/sync-version.sh +28 -0
- package/scripts/verify-app-bundle.sh +34 -0
- package/src/lib/agent-card.js +111 -0
- package/src/lib/client.js +290 -49
- package/src/lib/conversations.js +2 -0
- package/src/lib/local-request.js +69 -0
- package/src/lib/logger.js +2 -0
- package/src/lib/runtime-adapter.js +41 -1
- package/src/routes/a2a.js +393 -66
- package/src/routes/dashboard.js +1 -27
- package/src/server.js +19 -0
- package/.maestro/inbox/release-workflow-spam.md +0 -25
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# A2A-97: verify packaged app has sidecar + expected metadata before release upload.
|
|
5
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
6
|
+
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
7
|
+
APP_PATH="${1:-$PROJECT_DIR/native/macos/src-tauri/target/universal-apple-darwin/release/bundle/macos/A2A Callbook.app}"
|
|
8
|
+
|
|
9
|
+
fail() {
|
|
10
|
+
echo "App bundle verification failed: $1" >&2
|
|
11
|
+
if [[ -d "$APP_PATH/Contents/MacOS" ]]; then
|
|
12
|
+
echo "---- Contents/MacOS ----" >&2
|
|
13
|
+
ls -la "$APP_PATH/Contents/MacOS" >&2 || true
|
|
14
|
+
fi
|
|
15
|
+
exit 1
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
[[ -d "$APP_PATH" ]] || fail "missing app bundle at $APP_PATH"
|
|
19
|
+
[[ -f "$APP_PATH/Contents/Info.plist" ]] || fail "missing Info.plist"
|
|
20
|
+
[[ -f "$APP_PATH/Contents/Resources/icon.icns" ]] || fail "missing icon.icns"
|
|
21
|
+
|
|
22
|
+
if ! ls "$APP_PATH/Contents/MacOS"/a2a-server-* >/dev/null 2>&1; then
|
|
23
|
+
fail "missing bundled a2a-server sidecar binary"
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
EXPECTED_VERSION="$(node -p "require('$PROJECT_DIR/package.json').version")"
|
|
27
|
+
ACTUAL_VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP_PATH/Contents/Info.plist" 2>/dev/null || true)"
|
|
28
|
+
[[ -n "$ACTUAL_VERSION" ]] || fail "unable to read CFBundleShortVersionString"
|
|
29
|
+
|
|
30
|
+
if [[ "$ACTUAL_VERSION" != "$EXPECTED_VERSION" ]]; then
|
|
31
|
+
fail "bundle version mismatch (expected $EXPECTED_VERSION, got $ACTUAL_VERSION)"
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
echo "App bundle verification passed for $APP_PATH"
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google A2A Agent Card Builder
|
|
3
|
+
*
|
|
4
|
+
* Assembles a standards-compliant Agent Card from existing config,
|
|
5
|
+
* disclosure manifest, and crypto identity. The card is served at
|
|
6
|
+
* GET /.well-known/a2a-agent-card and mirrored at GET /api/a2a/agent-card.
|
|
7
|
+
*
|
|
8
|
+
* Reference: A2A-75 assessment, A2A-76 implementation ticket.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const crypto = require('node:crypto');
|
|
12
|
+
const { fingerprint } = require('./crypto');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build a Google A2A-compliant Agent Card.
|
|
16
|
+
*
|
|
17
|
+
* @param {object} opts
|
|
18
|
+
* @param {object} opts.config - Result of A2AConfig.getAgent()
|
|
19
|
+
* @param {object} opts.manifest - Result of getTopicsForTier('public')
|
|
20
|
+
* @param {string|null} opts.publicKey - Base64-encoded Ed25519 public key (or null)
|
|
21
|
+
* @param {string} opts.serverUrl - Externally-reachable base URL (e.g. "https://host.com")
|
|
22
|
+
* @param {string} opts.version - Package version string
|
|
23
|
+
* @returns {object} Agent Card JSON
|
|
24
|
+
*/
|
|
25
|
+
function buildAgentCard({ config, manifest, publicKey, serverUrl, version }) {
|
|
26
|
+
const agentName = config?.name || 'a2a-agent';
|
|
27
|
+
const agentDescription = config?.description || '';
|
|
28
|
+
const ownerName = config?.owner || '';
|
|
29
|
+
|
|
30
|
+
// Agent ID: Ed25519 fingerprint if available, else deterministic hash of name + hostname
|
|
31
|
+
const id = publicKey
|
|
32
|
+
? fingerprint(publicKey)
|
|
33
|
+
: crypto.createHash('sha256')
|
|
34
|
+
.update(`${agentName}:${config?.hostname || 'localhost'}`)
|
|
35
|
+
.digest('hex')
|
|
36
|
+
.match(/.{2}/g)
|
|
37
|
+
.join(':');
|
|
38
|
+
|
|
39
|
+
// Map public-tier disclosure topics → Agent Card skills
|
|
40
|
+
const topics = (manifest && Array.isArray(manifest.topics)) ? manifest.topics : [];
|
|
41
|
+
const skills = topics
|
|
42
|
+
.filter(t => t?.topic)
|
|
43
|
+
.map(t => ({
|
|
44
|
+
id: t.topic.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''),
|
|
45
|
+
name: t.topic,
|
|
46
|
+
description: t.description || ''
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
// Normalize server URL (strip trailing slash)
|
|
50
|
+
const baseUrl = (serverUrl || '').replace(/\/+$/, '');
|
|
51
|
+
|
|
52
|
+
const card = {
|
|
53
|
+
id,
|
|
54
|
+
name: agentName,
|
|
55
|
+
version: version || '0.0.0',
|
|
56
|
+
provider: ownerName ? { name: ownerName } : undefined,
|
|
57
|
+
description: agentDescription || undefined,
|
|
58
|
+
capabilities: {
|
|
59
|
+
streaming: false,
|
|
60
|
+
pushNotifications: false,
|
|
61
|
+
extendedAgentCard: false
|
|
62
|
+
},
|
|
63
|
+
skills,
|
|
64
|
+
interfaces: [
|
|
65
|
+
{
|
|
66
|
+
type: 'rest',
|
|
67
|
+
url: `${baseUrl}/api/a2a/`,
|
|
68
|
+
version: '0.3'
|
|
69
|
+
}
|
|
70
|
+
],
|
|
71
|
+
securitySchemes: {
|
|
72
|
+
bearerAuth: {
|
|
73
|
+
type: 'http',
|
|
74
|
+
scheme: 'bearer',
|
|
75
|
+
description: 'A2A federation token (fed_xxx)'
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
security: [{ bearerAuth: [] }],
|
|
79
|
+
extensions: [
|
|
80
|
+
{
|
|
81
|
+
uri: 'https://openclaw.dev/a2a/extensions/trust-tiers',
|
|
82
|
+
version: '1.0.0',
|
|
83
|
+
required: false,
|
|
84
|
+
data: {
|
|
85
|
+
tiers: ['public', 'friends', 'family'],
|
|
86
|
+
default_tier: 'public',
|
|
87
|
+
disclosure_levels: ['public', 'minimal', 'none'],
|
|
88
|
+
default_disclosure: 'minimal',
|
|
89
|
+
supports_topics: true,
|
|
90
|
+
supports_goals: true,
|
|
91
|
+
owner_notifications: true,
|
|
92
|
+
max_calls_enforced: true
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
]
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Include signature identity only when a keypair exists
|
|
99
|
+
if (publicKey) {
|
|
100
|
+
card.signature = {
|
|
101
|
+
algorithm: 'ed25519',
|
|
102
|
+
publicKey,
|
|
103
|
+
fingerprint: fingerprint(publicKey)
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Strip undefined values for clean JSON
|
|
108
|
+
return JSON.parse(JSON.stringify(card));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { buildAgentCard };
|
package/src/lib/client.js
CHANGED
|
@@ -19,16 +19,198 @@ const RETRYABLE_CODES = ['ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ENOTFOUND', 'EA
|
|
|
19
19
|
// A2A-54: exponential backoff — first retry is immediate, then 1s, then 2s
|
|
20
20
|
const RETRY_DELAYS = [0, 1000, 2000];
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
22
|
+
// A2A-80: Agent Card cache — module-level Map with TTL and prune-on-access
|
|
23
|
+
// Each entry: { card: object|null, cachedAt: number }
|
|
24
|
+
// null card = negative cache (failed fetch)
|
|
25
|
+
const _agentCardCache = new Map();
|
|
26
|
+
|
|
27
|
+
function _readPositiveIntEnv(name, defaultVal) {
|
|
28
|
+
const raw = process.env[name];
|
|
29
|
+
if (!raw) return defaultVal;
|
|
30
|
+
const n = Number.parseInt(raw, 10);
|
|
31
|
+
return Number.isFinite(n) && n > 0 ? n : defaultVal;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const AGENT_CARD_TTL_MS = _readPositiveIntEnv('A2A_AGENT_CARD_TTL_MS', 300000);
|
|
35
|
+
const AGENT_CARD_MAX_ENTRIES = _readPositiveIntEnv('A2A_AGENT_CARD_MAX_ENTRIES', 200);
|
|
36
|
+
const AGENT_CARD_FETCH_TIMEOUT_MS = 3000;
|
|
37
|
+
|
|
38
|
+
// A2A-80: Prune-on-access eviction (pattern from A2A-69, NOT imported)
|
|
39
|
+
function _pruneAgentCardCache() {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
for (const [key, entry] of _agentCardCache.entries()) {
|
|
42
|
+
if (now - entry.cachedAt > AGENT_CARD_TTL_MS) {
|
|
43
|
+
_agentCardCache.delete(key);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (_agentCardCache.size <= AGENT_CARD_MAX_ENTRIES) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const oldest = Array.from(_agentCardCache.entries())
|
|
52
|
+
.sort((a, b) => a[1].cachedAt - b[1].cachedAt);
|
|
53
|
+
const toDelete = _agentCardCache.size - AGENT_CARD_MAX_ENTRIES;
|
|
54
|
+
for (let i = 0; i < toDelete; i++) {
|
|
55
|
+
_agentCardCache.delete(oldest[i][0]);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A2A-80: Cache key is always hostname:port
|
|
60
|
+
function _agentCardCacheKey(host) {
|
|
61
|
+
const parsed = splitHostPort(host);
|
|
62
|
+
const hostname = parsed.hostname;
|
|
63
|
+
const port = Number.isFinite(parsed.port) ? parsed.port : 80;
|
|
64
|
+
return `${hostname}:${port}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// A2A-80: Validate Agent Card — needs non-empty interfaces[] with { type: 'rest' }
|
|
68
|
+
function _parseAgentCard(json) {
|
|
69
|
+
if (!json || typeof json !== 'object') return null;
|
|
70
|
+
if (!Array.isArray(json.interfaces) || json.interfaces.length === 0) return null;
|
|
71
|
+
|
|
72
|
+
const restInterface = json.interfaces.find(
|
|
73
|
+
iface => iface && iface.type === 'rest'
|
|
74
|
+
);
|
|
75
|
+
if (!restInterface) return null;
|
|
76
|
+
|
|
77
|
+
return json;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A2A-80: Fetch Agent Card (GET /.well-known/a2a-agent-card, 3s timeout, cached with TTL).
|
|
81
|
+
// TODO: Concurrent call() to same uncached host may duplicate Agent Card fetches.
|
|
82
|
+
function fetchRemoteAgentCard(host) {
|
|
83
|
+
_pruneAgentCardCache();
|
|
84
|
+
|
|
85
|
+
const cacheKey = _agentCardCacheKey(host);
|
|
86
|
+
const cached = _agentCardCache.get(cacheKey);
|
|
87
|
+
if (cached) {
|
|
88
|
+
return Promise.resolve(cached.card);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const { protocol, hostname, port } = resolveProtocolAndPort(host);
|
|
92
|
+
|
|
93
|
+
return new Promise((resolve) => {
|
|
94
|
+
const req = protocol.request({
|
|
95
|
+
hostname,
|
|
96
|
+
port,
|
|
97
|
+
path: '/.well-known/a2a-agent-card',
|
|
98
|
+
method: 'GET',
|
|
99
|
+
timeout: AGENT_CARD_FETCH_TIMEOUT_MS
|
|
100
|
+
}, (res) => {
|
|
101
|
+
let data = '';
|
|
102
|
+
let bytes = 0;
|
|
103
|
+
res.on('data', (chunk) => {
|
|
104
|
+
bytes += chunk.length;
|
|
105
|
+
if (bytes > MAX_RESPONSE_BYTES) {
|
|
106
|
+
res.destroy();
|
|
107
|
+
_agentCardCache.set(cacheKey, { card: null, cachedAt: Date.now() });
|
|
108
|
+
resolve(null);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
data += chunk;
|
|
112
|
+
});
|
|
113
|
+
res.on('end', () => {
|
|
114
|
+
if (res.statusCode !== 200) {
|
|
115
|
+
_agentCardCache.set(cacheKey, { card: null, cachedAt: Date.now() });
|
|
116
|
+
resolve(null);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const json = JSON.parse(data);
|
|
121
|
+
const card = _parseAgentCard(json);
|
|
122
|
+
_agentCardCache.set(cacheKey, { card, cachedAt: Date.now() });
|
|
123
|
+
resolve(card);
|
|
124
|
+
} catch {
|
|
125
|
+
_agentCardCache.set(cacheKey, { card: null, cachedAt: Date.now() });
|
|
126
|
+
resolve(null);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
req.on('error', () => {
|
|
132
|
+
_agentCardCache.set(cacheKey, { card: null, cachedAt: Date.now() });
|
|
133
|
+
resolve(null);
|
|
134
|
+
});
|
|
135
|
+
req.on('timeout', () => {
|
|
136
|
+
req.destroy();
|
|
137
|
+
_agentCardCache.set(cacheKey, { card: null, cachedAt: Date.now() });
|
|
138
|
+
resolve(null);
|
|
139
|
+
});
|
|
140
|
+
req.end();
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// A2A-80: Build Google A2A message/send body (ref: a2a.js translateInternalToGoogle)
|
|
145
|
+
function _translateToGoogleRequest(message, conversationId, options = {}, caller = {}) {
|
|
146
|
+
return {
|
|
147
|
+
message: {
|
|
148
|
+
role: 'user',
|
|
149
|
+
parts: [{ content: { text: message } }],
|
|
150
|
+
...(conversationId ? { context_id: conversationId } : {})
|
|
151
|
+
},
|
|
152
|
+
metadata: {
|
|
153
|
+
caller_name: String(caller.name || '').slice(0, 100),
|
|
154
|
+
caller_owner: String(caller.owner || '').slice(0, 100),
|
|
155
|
+
caller_instance: String(caller.instance || '').slice(0, 200)
|
|
156
|
+
},
|
|
157
|
+
configuration: {
|
|
158
|
+
timeout_seconds: options.timeoutSeconds || 60,
|
|
159
|
+
blocking: true
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// A2A-80: Translate Google A2A Task response to internal format (ref: a2a.js translateGoogleToInternal)
|
|
165
|
+
function _translateGoogleResponse(taskResponse) {
|
|
166
|
+
const task = taskResponse?.task;
|
|
167
|
+
if (!task || !task.status) {
|
|
168
|
+
throw new A2AError('google_a2a_error', 'Invalid Google A2A response: missing task or status');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const parts = task.status.message?.parts || [];
|
|
172
|
+
const textParts = [];
|
|
173
|
+
for (const part of parts) {
|
|
174
|
+
if (part?.content && typeof part.content.text === 'string') {
|
|
175
|
+
textParts.push(part.content.text);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const response = textParts.join('\n');
|
|
180
|
+
const state = task.status.state;
|
|
181
|
+
const canContinue = state === 'input-required';
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
response,
|
|
185
|
+
conversation_id: task.context_id || null,
|
|
186
|
+
can_continue: canContinue
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// A2A-80: Resolve message:send URL from Agent Card REST interface (trailing slash stripped)
|
|
191
|
+
function _resolveGoogleA2AUrl(agentCard, host) {
|
|
192
|
+
const restInterface = agentCard.interfaces.find(
|
|
193
|
+
iface => iface && iface.type === 'rest'
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
if (restInterface && restInterface.url) {
|
|
197
|
+
const baseUrl = restInterface.url.replace(/\/+$/, '');
|
|
198
|
+
return `${baseUrl}/message:send`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Fallback: build from host
|
|
202
|
+
const { hostname, port } = splitHostPort(host);
|
|
203
|
+
const effectivePort = Number.isFinite(port) ? port : 80;
|
|
204
|
+
const isLocalhost = hostname === 'localhost' ||
|
|
205
|
+
hostname === '127.0.0.1' ||
|
|
206
|
+
hostname === '::1' ||
|
|
207
|
+
hostname.startsWith('127.');
|
|
208
|
+
const scheme = (isLocalhost || effectivePort === 80 || (Number.isFinite(port) && port !== 443))
|
|
209
|
+
? 'http' : 'https';
|
|
210
|
+
return `${scheme}://${hostname}:${effectivePort}/message:send`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// A2A-54: Retry on transient network errors only (not HTTP 4xx/5xx)
|
|
32
214
|
async function withRetry(fn, options = {}) {
|
|
33
215
|
const delays = options.delays || RETRY_DELAYS;
|
|
34
216
|
const maxAttempts = delays.length + 1;
|
|
@@ -113,16 +295,7 @@ function resolveProtocolAndPort(host) {
|
|
|
113
295
|
return { protocol, hostname, port };
|
|
114
296
|
}
|
|
115
297
|
|
|
116
|
-
|
|
117
|
-
* A2A-54: Create a size-capped response handler.
|
|
118
|
-
* Tracks accumulated bytes and destroys the socket if the cap is exceeded,
|
|
119
|
-
* preventing OOM from malicious or misconfigured remote agents.
|
|
120
|
-
*
|
|
121
|
-
* @param {http.IncomingMessage} res - the response stream
|
|
122
|
-
* @param {Function} resolve - promise resolve
|
|
123
|
-
* @param {Function} reject - promise reject
|
|
124
|
-
* @param {Function} onComplete - called with (data, statusCode) when response ends within cap
|
|
125
|
-
*/
|
|
298
|
+
// A2A-54: Size-capped response handler — destroys socket if cap exceeded
|
|
126
299
|
function handleSizeCappedResponse(res, resolve, reject, onComplete) {
|
|
127
300
|
let data = '';
|
|
128
301
|
let bytes = 0;
|
|
@@ -159,10 +332,7 @@ class A2AClient {
|
|
|
159
332
|
this._retryDelays = options._retryDelays || RETRY_DELAYS;
|
|
160
333
|
}
|
|
161
334
|
|
|
162
|
-
|
|
163
|
-
* A2A-52: Build signature headers if keypair is available.
|
|
164
|
-
* Shared helper used by both call() and end().
|
|
165
|
-
*/
|
|
335
|
+
// A2A-52: Build signature headers if keypair available
|
|
166
336
|
_signHeaders(method, endpoint, body) {
|
|
167
337
|
if (!this.privateKey || !this.publicKey) return {};
|
|
168
338
|
return signRequest({
|
|
@@ -174,9 +344,6 @@ class A2AClient {
|
|
|
174
344
|
});
|
|
175
345
|
}
|
|
176
346
|
|
|
177
|
-
/**
|
|
178
|
-
* Parse an a2a:// URL
|
|
179
|
-
*/
|
|
180
347
|
static parseInvite(inviteUrl) {
|
|
181
348
|
const match = inviteUrl.match(/^a2a:\/\/([^/]+)\/(.+)$/);
|
|
182
349
|
if (!match) {
|
|
@@ -185,14 +352,70 @@ class A2AClient {
|
|
|
185
352
|
return { host: match[1], token: match[2] };
|
|
186
353
|
}
|
|
187
354
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
355
|
+
// A2A-80: Send message via Google A2A protocol (message/send format)
|
|
356
|
+
_callGoogleA2A(host, token, body, agentCard) {
|
|
357
|
+
const url = _resolveGoogleA2AUrl(agentCard, host);
|
|
358
|
+
// Parse the URL to extract components for http/https request
|
|
359
|
+
const parsed = new URL(url);
|
|
360
|
+
const proto = parsed.protocol === 'https:' ? https : http;
|
|
361
|
+
const path = parsed.pathname;
|
|
362
|
+
|
|
363
|
+
// A2A-52: attach signature headers when keypair available
|
|
364
|
+
const sigHeaders = this._signHeaders('POST', path, body);
|
|
365
|
+
|
|
366
|
+
const makeRequest = () => new Promise((resolve, reject) => {
|
|
367
|
+
const req = proto.request({
|
|
368
|
+
hostname: parsed.hostname,
|
|
369
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
370
|
+
path,
|
|
371
|
+
method: 'POST',
|
|
372
|
+
headers: {
|
|
373
|
+
'Authorization': `Bearer ${token}`,
|
|
374
|
+
'Content-Type': 'application/json',
|
|
375
|
+
'Content-Length': Buffer.byteLength(body),
|
|
376
|
+
...sigHeaders
|
|
377
|
+
},
|
|
378
|
+
timeout: this.timeout
|
|
379
|
+
}, (res) => {
|
|
380
|
+
handleSizeCappedResponse(res, resolve, reject, (data, statusCode) => {
|
|
381
|
+
try {
|
|
382
|
+
const json = JSON.parse(data);
|
|
383
|
+
if (statusCode >= 400) {
|
|
384
|
+
// A2A-80: map Google A2A error format to A2AError
|
|
385
|
+
const errObj = json.error || {};
|
|
386
|
+
const code = errObj.code || json.error || 'google_a2a_error';
|
|
387
|
+
const message = errObj.message || json.message || data;
|
|
388
|
+
reject(new A2AError(String(code), message, statusCode));
|
|
389
|
+
} else {
|
|
390
|
+
resolve(_translateGoogleResponse(json));
|
|
391
|
+
}
|
|
392
|
+
} catch (e) {
|
|
393
|
+
if (e instanceof A2AError) {
|
|
394
|
+
reject(e);
|
|
395
|
+
} else {
|
|
396
|
+
reject(new A2AError('parse_error', `Failed to parse Google A2A response: ${data}`, statusCode));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
req.on('error', (e) => {
|
|
403
|
+
reject(new A2AError('network_error', e.code ? `${e.code}: ${e.message}` : e.message));
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
req.on('timeout', () => {
|
|
407
|
+
req.destroy();
|
|
408
|
+
reject(new A2AError('timeout', 'Request timed out'));
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
req.write(body);
|
|
412
|
+
req.end();
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
return withRetry(makeRequest, { delays: this._retryDelays });
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Call a remote agent — auto-detects Google A2A via Agent Card
|
|
196
419
|
async call(endpoint, message, options = {}) {
|
|
197
420
|
let host, token;
|
|
198
421
|
|
|
@@ -204,6 +427,18 @@ class A2AClient {
|
|
|
204
427
|
|
|
205
428
|
const { conversationId, context, timeoutSeconds } = options;
|
|
206
429
|
|
|
430
|
+
// A2A-80: check Agent Card to decide Google A2A vs proprietary format
|
|
431
|
+
const agentCard = await fetchRemoteAgentCard(host);
|
|
432
|
+
|
|
433
|
+
if (agentCard) {
|
|
434
|
+
// Google A2A format path
|
|
435
|
+
const googleBody = JSON.stringify(
|
|
436
|
+
_translateToGoogleRequest(message, conversationId, { timeoutSeconds }, this.caller)
|
|
437
|
+
);
|
|
438
|
+
return this._callGoogleA2A(host, token, googleBody, agentCard);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Proprietary format path (unchanged)
|
|
207
442
|
const body = JSON.stringify({
|
|
208
443
|
message,
|
|
209
444
|
conversation_id: conversationId,
|
|
@@ -262,13 +497,7 @@ class A2AClient {
|
|
|
262
497
|
return withRetry(makeRequest, { delays: this._retryDelays });
|
|
263
498
|
}
|
|
264
499
|
|
|
265
|
-
|
|
266
|
-
* Explicitly end a remote conversation and trigger call conclusion
|
|
267
|
-
*
|
|
268
|
-
* @param {string|object} endpoint - a2a:// URL or {host, token}
|
|
269
|
-
* @param {string} conversationId - Conversation ID to conclude
|
|
270
|
-
* @returns {Promise<object>} End response from remote agent
|
|
271
|
-
*/
|
|
500
|
+
// End a remote conversation — no-op for Google A2A remotes
|
|
272
501
|
async end(endpoint, conversationId) {
|
|
273
502
|
if (!conversationId) {
|
|
274
503
|
throw new A2AError('missing_conversation_id', 'conversationId is required');
|
|
@@ -282,6 +511,17 @@ class A2AClient {
|
|
|
282
511
|
({ host, token } = endpoint);
|
|
283
512
|
}
|
|
284
513
|
|
|
514
|
+
// A2A-80: Google A2A remotes don't have an end endpoint — return synthetic response
|
|
515
|
+
const agentCard = await fetchRemoteAgentCard(host);
|
|
516
|
+
if (agentCard) {
|
|
517
|
+
logger.info('Skipping end() for Google A2A remote', {
|
|
518
|
+
event: 'google_a2a_end_skipped',
|
|
519
|
+
data: { conversationId, host }
|
|
520
|
+
});
|
|
521
|
+
return { ended: true, summary: null };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Proprietary format path (unchanged)
|
|
285
525
|
const body = JSON.stringify({
|
|
286
526
|
conversation_id: conversationId
|
|
287
527
|
});
|
|
@@ -336,9 +576,6 @@ class A2AClient {
|
|
|
336
576
|
return withRetry(makeRequest, { delays: this._retryDelays });
|
|
337
577
|
}
|
|
338
578
|
|
|
339
|
-
/**
|
|
340
|
-
* Check if a remote agent is available
|
|
341
|
-
*/
|
|
342
579
|
async ping(endpoint) {
|
|
343
580
|
let host;
|
|
344
581
|
|
|
@@ -378,9 +615,6 @@ class A2AClient {
|
|
|
378
615
|
});
|
|
379
616
|
}
|
|
380
617
|
|
|
381
|
-
/**
|
|
382
|
-
* Get A2A status of a remote
|
|
383
|
-
*/
|
|
384
618
|
async status(endpoint) {
|
|
385
619
|
let host;
|
|
386
620
|
|
|
@@ -431,6 +665,7 @@ class A2AError extends Error {
|
|
|
431
665
|
}
|
|
432
666
|
|
|
433
667
|
// A2A-54: export internals for testing (splitHostPort, resolveProtocolAndPort, constants)
|
|
668
|
+
// A2A-80: export Agent Card cache and helpers for testing
|
|
434
669
|
module.exports = {
|
|
435
670
|
A2AClient,
|
|
436
671
|
A2AError,
|
|
@@ -438,5 +673,11 @@ module.exports = {
|
|
|
438
673
|
_resolveProtocolAndPort: resolveProtocolAndPort,
|
|
439
674
|
_MAX_RESPONSE_BYTES: MAX_RESPONSE_BYTES,
|
|
440
675
|
_RETRYABLE_CODES: RETRYABLE_CODES,
|
|
441
|
-
_RETRY_DELAYS: RETRY_DELAYS
|
|
676
|
+
_RETRY_DELAYS: RETRY_DELAYS,
|
|
677
|
+
_agentCardCache,
|
|
678
|
+
_parseAgentCard,
|
|
679
|
+
_translateToGoogleRequest,
|
|
680
|
+
_translateGoogleResponse,
|
|
681
|
+
_resolveGoogleA2AUrl,
|
|
682
|
+
fetchRemoteAgentCard
|
|
442
683
|
};
|
package/src/lib/conversations.js
CHANGED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local Request Detection Utilities
|
|
3
|
+
*
|
|
4
|
+
* A2A-73: Extracted from dashboard.js and a2a.js to provide a single,
|
|
5
|
+
* proxy-aware implementation of local request detection. The previous
|
|
6
|
+
* isLoopbackAddress(req.ip) check was insufficient behind reverse proxies
|
|
7
|
+
* because Express (without trust proxy) reports the proxy's IP, not the
|
|
8
|
+
* real client IP.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Check if an IP address is a loopback address.
|
|
15
|
+
* Handles IPv4, IPv6, and IPv4-mapped IPv6 formats.
|
|
16
|
+
*/
|
|
17
|
+
function isLoopbackAddress(ip) {
|
|
18
|
+
if (!ip) return false;
|
|
19
|
+
if (ip === '::1' || ip === '127.0.0.1' || ip === '::ffff:127.0.0.1') {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
// Full 127.0.0.0/8 range is loopback in IPv4
|
|
23
|
+
if (ip.startsWith('127.')) return true;
|
|
24
|
+
return ip.startsWith('::ffff:127.');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Determine if a request is a direct local connection (not proxied).
|
|
29
|
+
*
|
|
30
|
+
* A2A-73: This is the security-critical check. A request is only considered
|
|
31
|
+
* "direct local" if ALL of these conditions hold:
|
|
32
|
+
* 1. Socket remote address is loopback (the TCP connection is local)
|
|
33
|
+
* 2. Host header targets localhost (not a public hostname)
|
|
34
|
+
* 3. No proxy-forwarding headers are present (rules out nginx/CDN traffic)
|
|
35
|
+
*
|
|
36
|
+
* Without condition 3, any request through a reverse proxy would pass
|
|
37
|
+
* because the proxy connects from 127.0.0.1 to the backend.
|
|
38
|
+
*/
|
|
39
|
+
function isDirectLocalRequest(req) {
|
|
40
|
+
const ip = (req && req.socket && req.socket.remoteAddress) ? req.socket.remoteAddress : req.ip;
|
|
41
|
+
if (!isLoopbackAddress(ip)) return false;
|
|
42
|
+
|
|
43
|
+
const rawHost = String(req.headers.host || '').toLowerCase();
|
|
44
|
+
// Strip port suffix to get the bare hostname for exact matching.
|
|
45
|
+
// This prevents DNS rebinding via e.g. localhost.evil.com or 127.0.0.1.nip.io.
|
|
46
|
+
// Negative lookbehind avoids stripping `:1` from bare IPv6 `::1`.
|
|
47
|
+
const hostname = rawHost.replace(/(?<!:):\d+$/, '');
|
|
48
|
+
const isLocalHost = hostname === 'localhost' ||
|
|
49
|
+
hostname === '127.0.0.1' ||
|
|
50
|
+
hostname === '[::1]' ||
|
|
51
|
+
hostname === '::1';
|
|
52
|
+
if (!isLocalHost) return false;
|
|
53
|
+
|
|
54
|
+
// A2A-73: Reject requests with any proxy-forwarding header. These indicate
|
|
55
|
+
// the request was relayed by nginx, a CDN, or another reverse proxy —
|
|
56
|
+
// even though the socket address is loopback (proxy → backend is local).
|
|
57
|
+
const forwarded = req.headers['x-forwarded-for'] ||
|
|
58
|
+
req.headers['x-forwarded-proto'] ||
|
|
59
|
+
req.headers['x-forwarded-host'] ||
|
|
60
|
+
req.headers['cf-connecting-ip'] ||
|
|
61
|
+
req.headers['x-forwarded-by'] ||
|
|
62
|
+
req.headers['x-real-ip'] ||
|
|
63
|
+
req.headers['forwarded'];
|
|
64
|
+
if (forwarded) return false;
|
|
65
|
+
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { isLoopbackAddress, isDirectLocalRequest };
|