Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1020,6 +1020,7 @@ const plugin = {
healthManager?.stop();
auditLogger.close();
client.destroy();
executor.close();

// Stop task cleanup timer
if (cleanupTimer) {
Expand Down
29 changes: 15 additions & 14 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"test": "node --import tsx --test tests/*.test.ts"
},
"dependencies": {
"@a2a-js/sdk": "^0.3.13",
"@a2a-js/sdk": "^1.0.1",
"@bufbuild/protobuf": "^2.11.0",
"@grpc/grpc-js": "^1.14.3",
"express": "^4.21.2",
Expand Down
97 changes: 97 additions & 0 deletions scripts/verify-e2e.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import express from "express";
import { TaskState, Role } from "@a2a-js/sdk";
import type { AgentExecutionEvent, AgentExecutor, ExecutionEventBus, RequestContext } from "@a2a-js/sdk/server";
import { DefaultRequestHandler, InMemoryTaskStore } from "@a2a-js/sdk/server";
import { jsonRpcHandler, UserBuilder } from "@a2a-js/sdk/server/express";
import { readFileSync } from "node:fs";
import { A2AClient } from "../src/client.js";

const PORT = 18999;
const base = `http://127.0.0.1:${PORT}`;

function makeCardV1() {
return {
name: "Verify Echo", description: "local verification", version: "1.0.0",
supportedInterfaces: [
{ url: `${base}/`, protocolBinding: "JSONRPC", tenant: "", protocolVersion: "1.0" },
{ url: `${base}/`, protocolBinding: "JSONRPC", tenant: "", protocolVersion: "0.3" },
],
capabilities: {}, securitySchemes: {}, securityRequirements: [],
defaultInputModes: [], defaultOutputModes: [], skills: [], signatures: [],
};
}
const cardV03 = { protocolVersion: "0.3.0", name: "Verify Echo", url: `${base}/`, skills: [] };

function extractText(ctx: any): string {
for (const p of ctx.userMessage?.parts ?? []) {
if (p?.content?.$case === "text") return String(p.content.value);
if (typeof p?.text === "string") return p.text;
}
return "";
}

const executor: AgentExecutor = {
async execute(ctx: RequestContext, bus: ExecutionEventBus) {
const task = {
id: ctx.taskId, contextId: ctx.contextId,
status: {
state: TaskState.TASK_STATE_COMPLETED,
timestamp: new Date().toISOString(),
message: {
messageId: `m-${ctx.taskId}`, contextId: ctx.contextId, taskId: ctx.taskId,
role: Role.ROLE_AGENT,
parts: [{ content: { $case: "text", value: `pong:${extractText(ctx)}` }, metadata: undefined, filename: "", mediaType: "text/plain" }],
metadata: undefined, extensions: [], referenceTaskIds: [],
},
},
artifacts: [], history: [], metadata: undefined,
} as never;
bus.publish({ kind: "task", data: task } as unknown as AgentExecutionEvent);
bus.finished();
},
async cancelTask(_id: string, _bus: ExecutionEventBus) {},
};

const handler = new DefaultRequestHandler(makeCardV1() as never, new InMemoryTaskStore(), executor);
const app = express();
app.use(express.json());
app.get("/card-v1", (_q, r) => r.json(makeCardV1()));
app.get("/card-v03", (_q, r) => r.json(cardV03));
app.use(jsonRpcHandler({ requestHandler: handler, userBuilder: UserBuilder.noAuthentication, legacyCompat: { enabled: true } }));
app.listen(PORT, "127.0.0.1");
await new Promise((r) => setTimeout(r, 400));

const client = new A2AClient();
let failures = 0;
async function check(name: string, peer: any) {
try {
const res: any = await client.sendMessage(peer, { text: "ping" }, {});
const ok = !!res.ok;
console.log(`${name}: ${ok ? "PASS" : "FAIL"}${ok ? "" : " " + JSON.stringify(res.response ?? {}).slice(0, 220)}`);
if (!ok) failures++;
} catch (e: any) {
console.log(`${name}: THREW ${e.message}`);
failures++;
}
}

await check("A.v1-outbound-vs-real-sdk-server", { name: "v1-peer", agentCardUrl: `${base}/card-v1` });
await check("B.legacy-v03-outbound-vs-real-sdk-server", { name: "v03-peer", agentCardUrl: `${base}/card-v03` });

const peers = JSON.parse(readFileSync("/home/praxis/.openclaw/a2a-peers.json", "utf8"));
const praxTok = peers.prax?.token ?? "";
if (!praxTok) {
console.log("C.prax-live: SKIP (no token)");
} else {
const res: any = await Promise.race([
client.sendMessage(
{ name: "praxai", agentCardUrl: "http://192.168.4.209:18800/.well-known/agent-card.json", auth: { type: "bearer", token: praxTok } } as never,
{ text: "connectivity probe from Axiom migration verification (fix/95-sdk1-dual-stack)" },
{},
),
new Promise((r) => setTimeout(() => r({ ok: null }), 90_000)),
]);
console.log(`C.prax-live-real-v03-server: ${res.ok === null ? "PENDING>90s" : res.ok ? "PASS" : "FAIL"}${res.ok === false ? " " + JSON.stringify(res.response ?? {}).slice(0, 220) : ""}`);
if (res.ok === false) failures++;
}
process.exit(failures ? 1 : 0);
75 changes: 46 additions & 29 deletions src/agent-card.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,36 @@
import type { AgentCard, AgentSkill } from "@a2a-js/sdk";
import type { AgentCard, AgentSkill, SecurityScheme } from "@a2a-js/sdk";

import type { GatewayConfig } from "./types.js";

function toSkill(entry: string | { id?: string; name: string; description?: string }, index: number): AgentSkill {
if (typeof entry === "string") {
return {
id: `skill-${index + 1}`,
name: entry,
description: entry,
tags: [],
};
}
const base =
typeof entry === "string"
? { id: `skill-${index + 1}`, name: entry, description: entry }
: {
id: entry.id || `skill-${index + 1}`,
name: entry.name,
description: entry.description || entry.name,
};

return {
id: entry.id || `skill-${index + 1}`,
name: entry.name,
description: entry.description || entry.name,
id: base.id,
name: base.name,
description: base.description,
tags: [],
examples: [],
inputModes: [],
outputModes: [],
securityRequirements: [],
};
}

/**
* Build a v1 AgentCard.
*
* Dual-stack note (#95): the first supportedInterface is preferred by
* conforming clients. We advertise JSONRPC twice — v1.0 and v0.3 — so
* legacy peers can negotiate down without a second card fetch.
*/
export function buildAgentCard(config: GatewayConfig): AgentCard {
const agentCard = config.agentCard || ({} as GatewayConfig["agentCard"]);
const server = config.server || { host: "0.0.0.0", port: 18800 };
Expand All @@ -28,17 +39,22 @@ export function buildAgentCard(config: GatewayConfig): AgentCard {
const useDerivedGrpcUrl = agentCard.grpcProxy === true;
const fallbackHost = server.host === "0.0.0.0" ? "localhost" : server.host;
const fallbackUrl = `http://${fallbackHost}:${server.port}/a2a/jsonrpc`;
const jsonRpcUrl = configuredUrl || fallbackUrl;
const restUrl = `${new URL(jsonRpcUrl).origin}/a2a/rest`;

const securitySchemes: AgentCard["securitySchemes"] = {};
const security: AgentCard["security"] = [];
const securityRequirements: AgentCard["securityRequirements"] = [];

const security_ = config.security || { inboundAuth: "none", token: "" };
if (security_.inboundAuth === "bearer") {
securitySchemes["bearer"] = {
type: "http",
scheme: "bearer",
const scheme: SecurityScheme = {
scheme: {
$case: "httpAuthSecurityScheme",
value: { description: "", scheme: "Bearer", bearerFormat: "" },
},
};
security.push({ bearer: [] });
securitySchemes["bearer"] = scheme;
securityRequirements.push({ schemes: { bearer: { list: [] } } });
}

const grpcPort = server.port + 1;
Expand All @@ -50,26 +66,27 @@ export function buildAgentCard(config: GatewayConfig): AgentCard {
: `${grpcHost}:${grpcPort}`;

return {
protocolVersion: "0.3.0",
version: "1.0.0",
name: agentCard.name || "OpenClaw A2A Gateway",
description: agentCard.description || "A2A bridge for OpenClaw agents",
url: configuredUrl || fallbackUrl,
skills: (agentCard.skills || []).map((entry, index) => toSkill(entry, index)),
version: "1.0.0",
supportedInterfaces: [
{ url: jsonRpcUrl, protocolBinding: "JSONRPC", tenant: "", protocolVersion: "1.0" },
{ url: jsonRpcUrl, protocolBinding: "JSONRPC", tenant: "", protocolVersion: "0.3" },
{ url: restUrl, protocolBinding: "HTTP+JSON", tenant: "", protocolVersion: "1.0" },
{ url: grpcProxy, protocolBinding: "GRPC", tenant: "", protocolVersion: "1.0" },
],
provider: undefined,
documentationUrl: undefined,
capabilities: {
streaming: true,
pushNotifications: false,
stateTransitionHistory: false,
extensions: [],
},
securitySchemes,
security,
supportsAuthenticatedExtendedCard: false,
securityRequirements,
defaultInputModes: ["text"],
defaultOutputModes: ["text"],
additionalInterfaces: [
{ url: configuredUrl || fallbackUrl, transport: "JSONRPC" },
{ url: `${new URL(configuredUrl || fallbackUrl).origin}/a2a/rest`, transport: "HTTP+JSON" },
{ url: grpcProxy, transport: "GRPC" },
],
skills: (agentCard.skills || []).map((entry, index) => toSkill(entry, index)),
signatures: [],
};
}
Loading