feat(fetch): x402 Payment Required handling + first unified-tool tests (#5)

* feat(fetch): x402 Payment Required handling + first unified-tool tests

Adds HTTP 402 (x402) parsing to the unified fetch tool so AI agents can
surface payment quotes from x402-gated endpoints and replay with X-PAYMENT.

- request / fetch / head detect status === 402 and return a structured
  payment_required envelope (parsed accepts[], x402-version, www-authenticate)
- new optional 'payment' input: pre-encoded base64 string sent verbatim, or
  a JSON object that is base64-encoded into X-PAYMENT before retry
- adds the first Jest test file for the HIP-0300 unified surface
  (test/tools/unified/fetch.test.ts) covering 200, 402, payment object, and
  payment string paths against an in-process http.createServer fixture

Also fixes test/setup.ts so the existing test suite actually runs:
- imports 'jest' from @jest/globals (jest is not a global under ESM)
- ESM-safe __dirname via fileURLToPath(import.meta.url)
Net effect: 169 previously-unrunnable tests can now start; 7/7 new fetch
tests pass; remaining failures are pre-existing test bugs unrelated to this
PR (CommonJS require() in ESM mode, missing mocks, etc.).

No new dependencies, no breaking changes to fetch's existing semantics.

* test: anchor setup.ts on process.cwd() to fix Node 18/20 ESM CI

`test/setup.ts` used `fileURLToPath(import.meta.url)` to derive its own
directory, but `ts-jest`'s handling of `import.meta` inside
`setupFilesAfterEnv` is fragile across Node versions — every test suite
failed on Node 18.x and 20.x with TS1343 ("\'import.meta\' meta-property
is only allowed when \'--module\' is es2020/...\"), even though the
root tsconfig and ts-jest's transform tsconfig both set
`module: ESNext`.

Tests always run from the project root, so `path.join(process.cwd(),
'test', ...)` is a stable anchor that doesn't depend on how the setup
file is loaded.

Local: 15/16 suites pass after the fix (2 unrelated shell.test.ts timing
flakes remain).

* fix(ci): suppress TS2307 on optional jsautogui dynamic import

The Node 20 CI job was failing two test suites — test/config/
configuration.test.ts and test/integration/mcp-server.test.ts — with:

    src/autogui/adapters/jsautogui.ts:37:37 - error TS2307: Cannot find
    module 'jsautogui' or its corresponding type declarations.

while the actual jest run itself reported 326/326 tests passing in the
suites it could load. Root cause: jsautogui is in optionalDependencies
and ships native bindings that npm may skip on some platforms. When
the package (and its bundled .d.ts) isn't present, ts-jest's transform
pass fails the whole suite at type-check time even though the runtime
code path is already guarded by try/catch + AutoGUINotAvailableError.

Adds a localised `// @ts-ignore` on the single dynamic-import line,
with a comment explaining why. The runtime behaviour is unchanged —
if jsautogui isn't installed, the catch arm still throws
AutoGUINotAvailableError as before. Confirmed locally that the
previously-failing test/config/configuration.test.ts now passes both
with jsautogui present and with node_modules/jsautogui removed
(simulating the Ubuntu CI install).

No code logic changed; package.json and package-lock.json untouched.

---------

Co-authored-by: Abhishek Krishna <[email protected]>
This commit is contained in:
Abhishek Krishna
2026-05-19 11:33:09 -07:00
committed by GitHub
co-authored by Abhishek Krishna
parent ce4f845528
commit 89bf02f5f7
4 changed files with 239 additions and 8 deletions
+8 -2
View File
@@ -33,9 +33,15 @@ export class JSAutoGUIAdapter extends BaseAutoGUIAdapter {
protected async initializeImplementation(): Promise<void> {
try {
// Dynamic import since jsautogui is an optional dependency
// Dynamic import since jsautogui is an optional dependency. The package
// ships native bindings and may not be installed (it is listed under
// optionalDependencies in package.json). ts-jest type-checks setup files
// and would emit TS2307 here when jsautogui is absent or its types are
// missing — suppress that since the runtime failure is already handled
// by the surrounding try/catch which throws AutoGUINotAvailableError.
// @ts-ignore optional native dependency, may not be installed
this.jsautogui = await import('jsautogui');
// Try to get version if available
try {
this.version = this.jsautogui.version || this.jsautogui.default?.version || 'unknown';
+54 -3
View File
@@ -21,6 +21,47 @@ function fail(code: string, message: string) {
return { content: [{ type: 'text' as const, text: JSON.stringify({ ok: false, data: null, error: { code, message }, meta: { tool: 'fetch' } }, null, 2) }], isError: true };
}
/**
* Parse an HTTP 402 response into the x402 payment-required envelope.
* Best-effort: surfaces whatever the server returned (JSON `accepts` array
* per x402.org spec, or raw `WWW-Authenticate`/`X-Accept-Payment` headers
* for older variants) so the calling agent can decide how to fund the call.
*/
async function parsePaymentRequired(resp: Response) {
const headers = Object.fromEntries(resp.headers.entries());
let body: any = null;
try {
const ct = resp.headers.get('content-type') || '';
body = ct.includes('json') ? await resp.json() : await resp.text();
} catch {
body = null;
}
const accepts = body && typeof body === 'object' && Array.isArray(body.accepts)
? body.accepts
: null;
return {
status: 402,
payment_required: {
x402_version: headers['x402-version'] || (body && body.x402Version) || null,
accepts,
www_authenticate: headers['www-authenticate'] || null,
raw_body: accepts ? null : body,
},
headers,
};
}
function paymentHeader(payment: any): Record<string, string> {
if (!payment) return {};
// x402 spec uses base64-encoded JSON in the X-PAYMENT header. Accept either
// a pre-encoded string (`payment` is a string) or a JSON object we encode.
if (typeof payment === 'string') return { 'X-PAYMENT': payment };
if (typeof payment === 'object') {
return { 'X-PAYMENT': Buffer.from(JSON.stringify(payment), 'utf8').toString('base64') };
}
return {};
}
export const fetchTool: Tool = {
name: 'fetch',
description: 'Network operations: request, fetch, head, download, open, search, crawl',
@@ -37,6 +78,7 @@ export const fetchTool: Tool = {
query: { type: 'string', description: 'Search query' },
depth: { type: 'number', description: 'Crawl depth', default: 2 },
limit: { type: 'number', description: 'Max results/pages', default: 10 },
payment: { description: 'x402 payment payload — base64 string sent as-is via X-PAYMENT, or a JSON object that will be base64-encoded' },
},
required: ['action']
},
@@ -46,11 +88,14 @@ export const fetchTool: Tool = {
case 'request': {
const opts: RequestInit = {
method: args.method || 'GET',
headers: args.headers || {},
headers: { ...(args.headers || {}), ...paymentHeader(args.payment) },
signal: AbortSignal.timeout(args.timeout || 30000),
};
if (args.body) opts.body = args.body;
const resp = await fetch(args.url, opts);
if (resp.status === 402) {
return envelope(await parsePaymentRequired(resp), 'request');
}
const contentType = resp.headers.get('content-type') || '';
let data: any;
if (contentType.includes('json')) {
@@ -68,11 +113,14 @@ export const fetchTool: Tool = {
case 'fetch': {
const opts: RequestInit = {
method: args.method || 'GET',
headers: args.headers || {},
headers: { ...(args.headers || {}), ...paymentHeader(args.payment) },
signal: AbortSignal.timeout(args.timeout || 30000),
};
if (args.body) opts.body = args.body;
const resp = await fetch(args.url, opts);
if (resp.status === 402) {
return envelope(await parsePaymentRequired(resp), 'fetch');
}
const contentType = resp.headers.get('content-type') || '';
let data: any;
if (contentType.includes('json')) {
@@ -90,9 +138,12 @@ export const fetchTool: Tool = {
case 'head': {
const resp = await fetch(args.url, {
method: 'HEAD',
headers: args.headers || {},
headers: { ...(args.headers || {}), ...paymentHeader(args.payment) },
signal: AbortSignal.timeout(args.timeout || 30000),
});
if (resp.status === 402) {
return envelope(await parsePaymentRequired(resp), 'head');
}
return envelope({
status: resp.status,
headers: Object.fromEntries(resp.headers.entries()),
+9 -3
View File
@@ -4,19 +4,25 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import { beforeAll, afterAll, beforeEach, afterEach } from '@jest/globals';
import { beforeAll, afterAll, beforeEach, afterEach, jest } from '@jest/globals';
// Anchor on the project root rather than this file's directory so we don't
// need `import.meta.url` here — ts-jest's handling of `import.meta` in
// `setupFilesAfterEnv` differs across Node versions and breaks Node 18/20 CI.
// Tests always run from the project root, so `process.cwd()` is stable.
const TEST_ROOT = path.join(process.cwd(), 'test');
// Global test timeout
jest.setTimeout(10000);
// Test fixtures directory
export const TEST_FIXTURES_DIR = path.join(__dirname, 'fixtures');
export const TEST_FIXTURES_DIR = path.join(TEST_ROOT, 'fixtures');
// Each suite gets a unique temp directory to prevent parallel suite interference.
// setupFilesAfterEnv creates a fresh module instance per suite, so this value is
// unique per suite even though it's module-scoped.
const suiteId = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
export const TEST_TEMP_DIR = path.join(__dirname, 'temp', suiteId);
export const TEST_TEMP_DIR = path.join(TEST_ROOT, 'temp', suiteId);
// Create test directories
beforeAll(async () => {
+168
View File
@@ -0,0 +1,168 @@
/**
* Tests for the unified `fetch` tool (HIP-0300).
*
* Covers the standard 200 path plus the new x402 (HTTP 402 Payment Required)
* surfacing — the calling agent should receive a structured `payment_required`
* envelope on 402, and supplying a `payment` argument should re-issue the
* request with the `X-PAYMENT` header.
*/
import { describe, test, expect, beforeAll, afterAll } from '@jest/globals';
import * as http from 'http';
import { AddressInfo } from 'net';
import { fetchTool } from '../../../src/tools/unified/fetch.js';
interface RecordedRequest {
method: string;
url: string;
headers: http.IncomingHttpHeaders;
body: string;
}
let server: http.Server;
let baseUrl: string;
const recorded: RecordedRequest[] = [];
beforeAll(async () => {
server = http.createServer((req, res) => {
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
recorded.push({
method: req.method || '',
url: req.url || '',
headers: req.headers,
body,
});
// /paid — gates on X-PAYMENT, otherwise 402 with x402 envelope.
if (req.url?.startsWith('/paid')) {
if (req.headers['x-payment']) {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: true, paid: true }));
return;
}
const envelope = {
x402Version: 1,
accepts: [{
scheme: 'exact',
network: 'base-sepolia',
maxAmountRequired: '1000',
asset: '0x036CbD53842c5426634e7929541eC2318f3dCF7e',
payTo: '0x0000000000000000000000000000000000000001',
resource: 'https://example.test/paid',
description: 'kcolbchain test resource',
mimeType: 'application/json',
}],
};
res.writeHead(402, {
'content-type': 'application/json',
'x402-version': '1',
'www-authenticate': 'x402',
});
res.end(JSON.stringify(envelope));
return;
}
// /ok — plain 200 JSON.
if (req.url?.startsWith('/ok')) {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ hello: 'world' }));
return;
}
res.writeHead(404);
res.end();
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const addr = server.address() as AddressInfo;
baseUrl = `http://127.0.0.1:${addr.port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
function parseResult(result: any) {
expect(result.content).toHaveLength(1);
return JSON.parse(result.content[0].text);
}
describe('fetchTool', () => {
test('has expected metadata', () => {
expect(fetchTool.name).toBe('fetch');
expect(fetchTool.inputSchema.required).toContain('action');
});
test('request returns body for 200 JSON', async () => {
const result = await fetchTool.handler({ action: 'request', url: `${baseUrl}/ok` });
const parsed = parseResult(result);
expect(parsed.ok).toBe(true);
expect(parsed.data.status).toBe(200);
expect(parsed.data.body).toEqual({ hello: 'world' });
});
test('head returns headers for 200', async () => {
const result = await fetchTool.handler({ action: 'head', url: `${baseUrl}/ok` });
const parsed = parseResult(result);
expect(parsed.ok).toBe(true);
expect(parsed.data.status).toBe(200);
});
test('request surfaces x402 payment_required envelope on 402', async () => {
const result = await fetchTool.handler({ action: 'request', url: `${baseUrl}/paid` });
const parsed = parseResult(result);
expect(parsed.ok).toBe(true);
expect(parsed.data.status).toBe(402);
expect(parsed.data.payment_required).toBeDefined();
expect(parsed.data.payment_required.x402_version).toBe('1');
expect(parsed.data.payment_required.www_authenticate).toBe('x402');
expect(parsed.data.payment_required.accepts).toHaveLength(1);
expect(parsed.data.payment_required.accepts[0].network).toBe('base-sepolia');
});
test('fetch surfaces x402 payment_required envelope on 402', async () => {
const result = await fetchTool.handler({ action: 'fetch', url: `${baseUrl}/paid` });
const parsed = parseResult(result);
expect(parsed.ok).toBe(true);
expect(parsed.data.status).toBe(402);
expect(parsed.data.payment_required.accepts[0].asset)
.toBe('0x036CbD53842c5426634e7929541eC2318f3dCF7e');
});
test('payment object is base64-encoded into X-PAYMENT and unlocks the resource', async () => {
const before = recorded.length;
const payment = { scheme: 'exact', network: 'base-sepolia', signature: '0xdeadbeef' };
const result = await fetchTool.handler({
action: 'request',
url: `${baseUrl}/paid`,
payment,
});
const parsed = parseResult(result);
expect(parsed.ok).toBe(true);
expect(parsed.data.status).toBe(200);
expect(parsed.data.body).toEqual({ ok: true, paid: true });
const last = recorded[recorded.length - 1];
const sent = last.headers['x-payment'] as string | undefined;
expect(sent).toBeDefined();
const decoded = JSON.parse(Buffer.from(sent!, 'base64').toString('utf8'));
expect(decoded).toEqual(payment);
expect(recorded.length).toBeGreaterThan(before);
});
test('payment string is forwarded verbatim as X-PAYMENT', async () => {
const presigned = 'eyJzaWduYXR1cmUiOiJ0ZXN0In0=';
const result = await fetchTool.handler({
action: 'request',
url: `${baseUrl}/paid`,
payment: presigned,
});
const parsed = parseResult(result);
expect(parsed.ok).toBe(true);
expect(parsed.data.status).toBe(200);
const last = recorded[recorded.length - 1];
expect(last.headers['x-payment']).toBe(presigned);
});
});