Skip to content
Merged
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
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: CI

on:
pull_request:
branches: [main]

jobs:
test:
name: Build & Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'

- run: npm ci
- run: npm run build
- run: npm test
10 changes: 9 additions & 1 deletion .releaserc.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,15 @@
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/changelog",
"@semantic-release/npm",
"@semantic-release/github"
"@semantic-release/github",
[
"@semantic-release/git",
{
"assets": ["package.json", "package-lock.json", "CHANGELOG.md"],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}
]
]
}
12 changes: 11 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "nullpath-mcp",
"version": "1.2.0",
"version": "1.4.1",
"description": "Connect to nullpath's AI agent marketplace via MCP. Discover and pay agents with x402 micropayments.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down Expand Up @@ -44,6 +44,16 @@
"typescript": "^5.0.0",
"vitest": "^2.0.0"
},
"files": [
"dist/index.js",
"dist/index.d.ts",
"dist/index.d.ts.map",
"dist/index.js.map",
"dist/lib/**",
"!dist/__tests__",
"README.md",
"LICENSE"
],
"engines": {
"node": ">=18.0.0"
}
Expand Down
235 changes: 233 additions & 2 deletions src/__tests__/awal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
awalPay,
AwalPaymentError,
USE_AWAL_ENV,
getAwalVersion,
} from '../lib/awal.js';

describe('awal', () => {
Expand All @@ -33,6 +34,9 @@ describe('awal', () => {

afterEach(() => {
delete process.env[USE_AWAL_ENV];
delete process.env.NULLPATH_ALLOW_HTTP;
delete process.env.NULLPATH_MAX_PAYMENT;
delete process.env.NULLPATH_WALLET_KEY;
});

describe('isAwalForced', () => {
Expand Down Expand Up @@ -143,7 +147,7 @@ describe('awal', () => {
expect(mockExecFileAsync).toHaveBeenCalled();
const [command, args] = mockExecFileAsync.mock.calls[0];
expect(command).toBe('npx');
expect(args).toContain('awal@latest');
expect(args).toContain('awal@2.0.3');
expect(args).toContain('x402');
expect(args).toContain('pay');
expect(args).toContain('https://example.com/api');
Expand Down Expand Up @@ -354,7 +358,7 @@ describe('awal', () => {
// Verify we called with 'npx' as command and array of args
expect(mockExecFileAsync).toHaveBeenCalledWith(
'npx',
expect.arrayContaining(['awal@latest', 'x402', 'pay']),
expect.arrayContaining(['awal@2.0.3', 'x402', 'pay']),
expect.any(Object)
);
});
Expand Down Expand Up @@ -383,4 +387,231 @@ describe('awal', () => {
expect(mockExecFileAsync).toHaveBeenCalledTimes(2);
});
});

describe('getAwalVersion', () => {
it('returns the pinned awal version', () => {
expect(getAwalVersion()).toBe('2.0.3');
});
});

describe('URL validation', () => {
it('allows HTTPS URLs', async () => {
mockExecFileAsync.mockResolvedValueOnce({
stdout: JSON.stringify({ success: true, body: { result: 'ok' } }),
stderr: '',
});

const result = await awalPay('https://nullpath.com/api/v1/execute');
expect(result.success).toBe(true);
});

it('rejects HTTP URLs by default', async () => {
await expect(awalPay('http://example.com/api')).rejects.toThrow(AwalPaymentError);
await expect(awalPay('http://example.com/api')).rejects.toThrow('Only HTTPS URLs are allowed');
});

it('rejects file:// URLs', async () => {
await expect(awalPay('file:///etc/passwd')).rejects.toThrow(AwalPaymentError);
});

it('rejects javascript: URLs', async () => {
await expect(awalPay('javascript:alert(1)')).rejects.toThrow(AwalPaymentError);
});

it('rejects invalid URLs', async () => {
await expect(awalPay('not-a-url')).rejects.toThrow('Invalid URL');
});

it('allows http://localhost when NULLPATH_ALLOW_HTTP=true', async () => {
process.env.NULLPATH_ALLOW_HTTP = 'true';
mockExecFileAsync.mockResolvedValueOnce({
stdout: JSON.stringify({ success: true }),
stderr: '',
});

const result = await awalPay('http://localhost:8787/api/v1/execute');
expect(result.success).toBe(true);
});

it('allows http://127.0.0.1 when NULLPATH_ALLOW_HTTP=true', async () => {
process.env.NULLPATH_ALLOW_HTTP = 'true';
mockExecFileAsync.mockResolvedValueOnce({
stdout: JSON.stringify({ success: true }),
stderr: '',
});

const result = await awalPay('http://127.0.0.1:8787/api/v1/execute');
expect(result.success).toBe(true);
});

it('rejects http://evil.com even with NULLPATH_ALLOW_HTTP=true', async () => {
process.env.NULLPATH_ALLOW_HTTP = 'true';
await expect(awalPay('http://evil.com/api')).rejects.toThrow('Only HTTPS URLs are allowed');
});
});

describe('header validation', () => {
it('allows valid headers', async () => {
mockExecFileAsync.mockResolvedValueOnce({
stdout: JSON.stringify({ success: true }),
stderr: '',
});

const result = await awalPay('https://example.com', {
headers: { 'Content-Type': 'application/json', 'X-Custom_Header': 'value' },
});
expect(result.success).toBe(true);
});

it('rejects header names with special characters', async () => {
await expect(
awalPay('https://example.com', { headers: { 'Bad Header!': 'value' } })
).rejects.toThrow('Invalid header name');
});

it('rejects header values with newlines', async () => {
await expect(
awalPay('https://example.com', { headers: { 'X-Test': 'value\r\nInjected: header' } })
).rejects.toThrow('Header value contains newline');
});
});

describe('HTTP method validation', () => {
it('allows valid HTTP methods', async () => {
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']) {
mockExecFileAsync.mockResolvedValueOnce({
stdout: JSON.stringify({ success: true }),
stderr: '',
});

const result = await awalPay('https://example.com', { method });
expect(result.success).toBe(true);
}
});

it('rejects invalid HTTP methods', async () => {
await expect(
awalPay('https://example.com', { method: 'HACK' })
).rejects.toThrow('Invalid HTTP method');
});

it('is case-insensitive', async () => {
mockExecFileAsync.mockResolvedValueOnce({
stdout: JSON.stringify({ success: true }),
stderr: '',
});

const result = await awalPay('https://example.com', { method: 'post' });
expect(result.success).toBe(true);
});
});

describe('body size limit', () => {
it('allows normal-sized bodies', async () => {
mockExecFileAsync.mockResolvedValueOnce({
stdout: JSON.stringify({ success: true }),
stderr: '',
});

const result = await awalPay('https://example.com', {
method: 'POST',
body: JSON.stringify({ text: 'hello world' }),
});
expect(result.success).toBe(true);
});

it('rejects bodies exceeding 1MB', async () => {
const largeBody = 'x'.repeat(1_048_577); // 1MB + 1 byte
await expect(
awalPay('https://example.com', { method: 'POST', body: largeBody })
).rejects.toThrow('body exceeds');
});
});

describe('env sanitization', () => {
it('does not pass NULLPATH_WALLET_KEY to child process', async () => {
process.env.NULLPATH_WALLET_KEY = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef';
mockExecFileAsync.mockResolvedValueOnce({
stdout: JSON.stringify({ success: true }),
stderr: '',
});

await awalPay('https://example.com');

const envPassed = mockExecFileAsync.mock.calls[0][2].env;
expect(envPassed.NULLPATH_WALLET_KEY).toBeUndefined();
expect(envPassed.NO_COLOR).toBe('1');
});

it('strips common secret env var patterns', async () => {
process.env.AWS_SECRET_ACCESS_KEY = 'aws-secret';
process.env.GH_TOKEN = 'ghp_fake';
process.env.DATABASE_PASSWORD = 'dbpass';
process.env.MY_API_KEY = 'key123';
process.env.SAFE_VARIABLE = 'safe';
mockExecFileAsync.mockResolvedValueOnce({
stdout: JSON.stringify({ success: true }),
stderr: '',
});

await awalPay('https://example.com');

const envPassed = mockExecFileAsync.mock.calls[0][2].env;
expect(envPassed.AWS_SECRET_ACCESS_KEY).toBeUndefined();
expect(envPassed.GH_TOKEN).toBeUndefined();
expect(envPassed.DATABASE_PASSWORD).toBeUndefined();
expect(envPassed.MY_API_KEY).toBeUndefined();
expect(envPassed.SAFE_VARIABLE).toBe('safe');

delete process.env.AWS_SECRET_ACCESS_KEY;
delete process.env.GH_TOKEN;
delete process.env.DATABASE_PASSWORD;
delete process.env.MY_API_KEY;
delete process.env.SAFE_VARIABLE;
});
});

describe('max payment cap', () => {
it('passes --max-amount with default value', async () => {
mockExecFileAsync.mockResolvedValueOnce({
stdout: JSON.stringify({ success: true }),
stderr: '',
});

await awalPay('https://example.com');

const args = mockExecFileAsync.mock.calls[0][1];
expect(args).toContain('--max-amount');
const maxIdx = args.indexOf('--max-amount');
expect(args[maxIdx + 1]).toBe('10000000'); // $10 default
});

it('uses NULLPATH_MAX_PAYMENT env var when set', async () => {
process.env.NULLPATH_MAX_PAYMENT = '5000000'; // $5
mockExecFileAsync.mockResolvedValueOnce({
stdout: JSON.stringify({ success: true }),
stderr: '',
});

await awalPay('https://example.com');

const args = mockExecFileAsync.mock.calls[0][1];
const maxIdx = args.indexOf('--max-amount');
expect(args[maxIdx + 1]).toBe('5000000');
});

it('falls back to default for invalid NULLPATH_MAX_PAYMENT', async () => {
process.env.NULLPATH_MAX_PAYMENT = 'not-a-number';
mockExecFileAsync.mockResolvedValueOnce({
stdout: JSON.stringify({ success: true }),
stderr: '',
});

await awalPay('https://example.com');

const args = mockExecFileAsync.mock.calls[0][1];
const maxIdx = args.indexOf('--max-amount');
expect(args[maxIdx + 1]).toBe('10000000'); // Falls back to default
});
});
});
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@ async function main() {
const server = new Server(
{
name: 'nullpath-mcp',
version: '1.2.0',
version: '1.4.1',
},
{
capabilities: {
Expand Down
Loading