@nullsquare/agent-authority 0.4.0
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/CONTRIBUTING.md +93 -0
- package/LICENSE +201 -0
- package/README.md +390 -0
- package/ROADMAP.md +149 -0
- package/SECURITY.md +116 -0
- package/docs/account-connections.md +173 -0
- package/docs/announcement-draft.md +13 -0
- package/docs/architecture.md +106 -0
- package/docs/assets/agent-authority-cover.svg +41 -0
- package/docs/clear-path.md +53 -0
- package/docs/cli.md +130 -0
- package/docs/evidence.md +143 -0
- package/docs/harness-bridge-mode.md +136 -0
- package/docs/harness-integration.md +223 -0
- package/docs/integration-contract.md +132 -0
- package/docs/integrations/vercel-ai-sdk.md +161 -0
- package/docs/launch-checklist.md +29 -0
- package/docs/npm-release.md +19 -0
- package/docs/openclaw-integration.md +97 -0
- package/docs/package-consumer-validation.md +18 -0
- package/docs/release-candidate-status.md +3 -0
- package/docs/release-guardrails.md +8 -0
- package/docs/release-notes-v0.4.md +26 -0
- package/docs/release-scope.md +3 -0
- package/docs/ship-criteria.md +3 -0
- package/docs/task-leases.md +253 -0
- package/docs/validation.md +124 -0
- package/examples/demo.js +19 -0
- package/examples/direct-guard.js +50 -0
- package/examples/harness-managed-connectors.js +72 -0
- package/examples/live-github-derived-mutation.js +208 -0
- package/examples/live-github-task-lease.js +80 -0
- package/examples/mission.json +20 -0
- package/examples/missions/chatgpt-web-validation.json +33 -0
- package/examples/openclaw-tool-wrapper.js +49 -0
- package/examples/task-lease-demo.js +98 -0
- package/examples/validation-mcp-upstream.js +112 -0
- package/package.json +80 -0
- package/src/agent-auth.js +135 -0
- package/src/approvals.js +157 -0
- package/src/cli.js +335 -0
- package/src/connections.js +203 -0
- package/src/execution.js +174 -0
- package/src/guard.js +79 -0
- package/src/harness-bridge.js +131 -0
- package/src/idempotency.js +118 -0
- package/src/index.js +291 -0
- package/src/integrations/ai-sdk.js +59 -0
- package/src/keys.js +15 -0
- package/src/mcp-gateway.js +142 -0
- package/src/mcp-remote.js +102 -0
- package/src/mcp-server.js +102 -0
- package/src/providers/github.js +149 -0
- package/src/runtime-env.js +53 -0
- package/src/sdk.js +75 -0
- package/src/server.js +146 -0
- package/src/storage.js +213 -0
- package/src/task-lease.js +266 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
|
2
|
+
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
|
|
3
|
+
import { MissionMcpGateway } from './mcp-gateway.js';
|
|
4
|
+
|
|
5
|
+
export class RemoteMcpUpstream {
|
|
6
|
+
constructor({ url, name = 'agent-authority-upstream', fetchImpl } = {}) {
|
|
7
|
+
if (!url) throw new Error('upstream MCP URL is required');
|
|
8
|
+
this.url = new URL(url);
|
|
9
|
+
this.name = name;
|
|
10
|
+
this.fetchImpl = fetchImpl;
|
|
11
|
+
this.client = null;
|
|
12
|
+
this.connecting = null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async ensureClient() {
|
|
16
|
+
if (this.client) return this.client;
|
|
17
|
+
if (this.connecting) return this.connecting;
|
|
18
|
+
|
|
19
|
+
this.connecting = (async () => {
|
|
20
|
+
const client = new Client(
|
|
21
|
+
{ name: this.name, version: '0.3.0' },
|
|
22
|
+
{ versionNegotiation: { mode: 'auto' } }
|
|
23
|
+
);
|
|
24
|
+
const transport = new StreamableHTTPClientTransport(
|
|
25
|
+
this.url,
|
|
26
|
+
this.fetchImpl ? { fetch: this.fetchImpl } : undefined
|
|
27
|
+
);
|
|
28
|
+
await client.connect(transport);
|
|
29
|
+
this.client = client;
|
|
30
|
+
return client;
|
|
31
|
+
})();
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
return await this.connecting;
|
|
35
|
+
} finally {
|
|
36
|
+
this.connecting = null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async listTools(params = undefined) {
|
|
41
|
+
return (await this.ensureClient()).listTools(params);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async callTool(params) {
|
|
45
|
+
return (await this.ensureClient()).callTool(params);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async close() {
|
|
49
|
+
const client = this.client;
|
|
50
|
+
this.client = null;
|
|
51
|
+
if (client) await client.close();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createMcpGatewayHandler({
|
|
56
|
+
mission,
|
|
57
|
+
runtime,
|
|
58
|
+
upstream,
|
|
59
|
+
upstreamUrl,
|
|
60
|
+
service = 'mcp:upstream',
|
|
61
|
+
readOnly = true
|
|
62
|
+
} = {}) {
|
|
63
|
+
const resolvedUpstream = upstream || new RemoteMcpUpstream({ url: upstreamUrl });
|
|
64
|
+
const gateway = new MissionMcpGateway({
|
|
65
|
+
mission,
|
|
66
|
+
runtime,
|
|
67
|
+
upstream: resolvedUpstream,
|
|
68
|
+
service,
|
|
69
|
+
readOnly
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const handler = createMcpHandler(() => {
|
|
73
|
+
const server = new McpServer(
|
|
74
|
+
{
|
|
75
|
+
name: 'agent-authority-gateway',
|
|
76
|
+
version: '0.3.0',
|
|
77
|
+
description: 'Mission-aware policy gateway for MCP tools'
|
|
78
|
+
},
|
|
79
|
+
{ capabilities: { tools: {} } }
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
server.server.setRequestHandler('tools/list', async (request) => {
|
|
83
|
+
return gateway.listTools(request.params);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
server.server.setRequestHandler('tools/call', async (request) => {
|
|
87
|
+
return gateway.callTool(request.params);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return server;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
handler,
|
|
95
|
+
gateway,
|
|
96
|
+
upstream: resolvedUpstream,
|
|
97
|
+
async close() {
|
|
98
|
+
await handler.close();
|
|
99
|
+
if (typeof resolvedUpstream.close === 'function') await resolvedUpstream.close();
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { toNodeHandler } from '@modelcontextprotocol/node';
|
|
3
|
+
import { createMcpGatewayHandler } from './mcp-remote.js';
|
|
4
|
+
|
|
5
|
+
function isLoopback(host) {
|
|
6
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function hostnameFromHostHeader(value = '') {
|
|
10
|
+
if (value.startsWith('[')) return value.slice(1, value.indexOf(']'));
|
|
11
|
+
return value.split(':')[0];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function sendJson(res, status, value) {
|
|
15
|
+
const body = JSON.stringify(value);
|
|
16
|
+
res.writeHead(status, {
|
|
17
|
+
'content-type': 'application/json; charset=utf-8',
|
|
18
|
+
'cache-control': 'no-store',
|
|
19
|
+
'x-content-type-options': 'nosniff'
|
|
20
|
+
});
|
|
21
|
+
res.end(body);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Start the first safe Agent Authority MCP proxy surface.
|
|
26
|
+
*
|
|
27
|
+
* v0.3 intentionally binds loopback only and defaults to read-only MCP tools.
|
|
28
|
+
* Remote web hosts should reach it through a trusted tunnel. Public binding is
|
|
29
|
+
* deferred until OAuth protected-resource mode is implemented.
|
|
30
|
+
*/
|
|
31
|
+
export function createMcpProxyServer({
|
|
32
|
+
mission,
|
|
33
|
+
runtime,
|
|
34
|
+
upstream,
|
|
35
|
+
upstreamUrl,
|
|
36
|
+
service = 'mcp:upstream',
|
|
37
|
+
host = '127.0.0.1',
|
|
38
|
+
port = 8790
|
|
39
|
+
} = {}) {
|
|
40
|
+
if (!isLoopback(host)) {
|
|
41
|
+
throw new Error('public MCP binding is not supported yet; bind loopback and use a trusted MCP tunnel');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const gateway = createMcpGatewayHandler({
|
|
45
|
+
mission,
|
|
46
|
+
runtime,
|
|
47
|
+
upstream,
|
|
48
|
+
upstreamUrl,
|
|
49
|
+
service,
|
|
50
|
+
readOnly: true
|
|
51
|
+
});
|
|
52
|
+
const nodeHandler = toNodeHandler(gateway.handler);
|
|
53
|
+
|
|
54
|
+
const server = http.createServer(async (req, res) => {
|
|
55
|
+
try {
|
|
56
|
+
const requestHost = hostnameFromHostHeader(req.headers.host || '');
|
|
57
|
+
if (requestHost && !isLoopback(requestHost)) {
|
|
58
|
+
return sendJson(res, 403, { error: 'host_not_allowed' });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const url = new URL(req.url || '/', `http://${req.headers.host || `${host}:${port}`}`);
|
|
62
|
+
if (req.method === 'GET' && url.pathname === '/health') {
|
|
63
|
+
return sendJson(res, 200, {
|
|
64
|
+
ok: true,
|
|
65
|
+
service: 'agent-authority-mcp-gateway',
|
|
66
|
+
mode: 'read-only',
|
|
67
|
+
upstream: upstreamUrl || 'injected'
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
if (url.pathname !== '/mcp') return sendJson(res, 404, { error: 'not_found' });
|
|
71
|
+
return nodeHandler(req, res);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
return sendJson(res, 500, { error: error.message });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
server,
|
|
79
|
+
gateway,
|
|
80
|
+
host,
|
|
81
|
+
port: Number(port),
|
|
82
|
+
async close() {
|
|
83
|
+
await new Promise((resolve, reject) => {
|
|
84
|
+
if (!server.listening) return resolve();
|
|
85
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
86
|
+
});
|
|
87
|
+
await gateway.close();
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function startMcpProxyServer(options = {}) {
|
|
93
|
+
const instance = createMcpProxyServer(options);
|
|
94
|
+
await new Promise((resolve, reject) => {
|
|
95
|
+
instance.server.once('error', reject);
|
|
96
|
+
instance.server.listen(instance.port, instance.host, () => {
|
|
97
|
+
instance.server.off('error', reject);
|
|
98
|
+
resolve();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
return instance;
|
|
102
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { brokeredProviderAdapter } from '../connections.js';
|
|
2
|
+
|
|
3
|
+
const MUTATING_ACTIONS = new Set(['issue.create', 'pull_request.create', 'repo.contents.write']);
|
|
4
|
+
|
|
5
|
+
function required(value, name) {
|
|
6
|
+
if (value === undefined || value === null || value === '') throw new Error(`${name} is required`);
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function repoParts(context = {}) {
|
|
11
|
+
const repository = required(context.repository, 'context.repository');
|
|
12
|
+
const [owner, repo, ...extra] = String(repository).split('/');
|
|
13
|
+
if (!owner || !repo || extra.length) throw new Error('context.repository must be owner/repo');
|
|
14
|
+
return { owner, repo };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function encodedPath(path) {
|
|
18
|
+
return String(path)
|
|
19
|
+
.split('/')
|
|
20
|
+
.filter(Boolean)
|
|
21
|
+
.map(encodeURIComponent)
|
|
22
|
+
.join('/');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function buildOperation(request) {
|
|
26
|
+
const context = request.context || {};
|
|
27
|
+
const { owner, repo } = repoParts(context);
|
|
28
|
+
const root = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
|
|
29
|
+
|
|
30
|
+
switch (request.action) {
|
|
31
|
+
case 'repo.read':
|
|
32
|
+
return { method: 'GET', path: root };
|
|
33
|
+
|
|
34
|
+
case 'repo.contents.read': {
|
|
35
|
+
const path = required(context.path, 'context.path');
|
|
36
|
+
const query = context.ref ? `?ref=${encodeURIComponent(context.ref)}` : '';
|
|
37
|
+
return { method: 'GET', path: `${root}/contents/${encodedPath(path)}${query}` };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
case 'issue.create':
|
|
41
|
+
return {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
path: `${root}/issues`,
|
|
44
|
+
body: {
|
|
45
|
+
title: required(context.title, 'context.title'),
|
|
46
|
+
body: context.body || undefined,
|
|
47
|
+
labels: context.labels || undefined,
|
|
48
|
+
assignees: context.assignees || undefined
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
case 'pull_request.create':
|
|
53
|
+
return {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
path: `${root}/pulls`,
|
|
56
|
+
body: {
|
|
57
|
+
title: required(context.title, 'context.title'),
|
|
58
|
+
head: required(context.head, 'context.head'),
|
|
59
|
+
base: required(context.base, 'context.base'),
|
|
60
|
+
body: context.body || undefined,
|
|
61
|
+
draft: Boolean(context.draft)
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
case 'repo.contents.write': {
|
|
66
|
+
const path = required(context.path, 'context.path');
|
|
67
|
+
const content = required(context.content_base64, 'context.content_base64');
|
|
68
|
+
return {
|
|
69
|
+
method: 'PUT',
|
|
70
|
+
path: `${root}/contents/${encodedPath(path)}`,
|
|
71
|
+
body: {
|
|
72
|
+
message: required(context.message, 'context.message'),
|
|
73
|
+
content,
|
|
74
|
+
sha: context.sha || undefined,
|
|
75
|
+
branch: context.branch || undefined
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
default: {
|
|
81
|
+
const error = new Error(`GitHub action ${request.action} has no provider operation mapping`);
|
|
82
|
+
error.code = 'unsupported_action';
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function sanitizeBody(body) {
|
|
89
|
+
if (!body || typeof body !== 'object') return body;
|
|
90
|
+
const clone = structuredClone(body);
|
|
91
|
+
for (const key of ['token', 'access_token', 'refresh_token', 'authorization']) {
|
|
92
|
+
if (key in clone) clone[key] = '[redacted]';
|
|
93
|
+
}
|
|
94
|
+
return clone;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function createGitHubProviderAdapter({ broker, fetchImpl = globalThis.fetch, baseUrl = 'https://api.github.com' } = {}) {
|
|
98
|
+
if (!broker) throw new Error('credential broker is required');
|
|
99
|
+
if (typeof fetchImpl !== 'function') throw new Error('fetch implementation is required');
|
|
100
|
+
|
|
101
|
+
const adapter = brokeredProviderAdapter({
|
|
102
|
+
kind: 'github-rest',
|
|
103
|
+
services: ['github'],
|
|
104
|
+
broker,
|
|
105
|
+
async execute({ request, credential }) {
|
|
106
|
+
const operation = buildOperation(request);
|
|
107
|
+
const token = typeof credential === 'string' ? credential : credential?.access_token;
|
|
108
|
+
if (!token) throw new Error('GitHub credential does not contain an access token');
|
|
109
|
+
|
|
110
|
+
const response = await fetchImpl(`${baseUrl}${operation.path}`, {
|
|
111
|
+
method: operation.method,
|
|
112
|
+
headers: {
|
|
113
|
+
accept: 'application/vnd.github+json',
|
|
114
|
+
authorization: `Bearer ${token}`,
|
|
115
|
+
'content-type': 'application/json',
|
|
116
|
+
'x-github-api-version': '2022-11-28',
|
|
117
|
+
'user-agent': 'nullsquare-agent-authority/0.3'
|
|
118
|
+
},
|
|
119
|
+
body: operation.body ? JSON.stringify(operation.body) : undefined
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const text = await response.text();
|
|
123
|
+
let body = text;
|
|
124
|
+
if (text) {
|
|
125
|
+
try { body = JSON.parse(text); } catch { /* preserve text */ }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const output = {
|
|
129
|
+
provider: 'github',
|
|
130
|
+
status: response.status,
|
|
131
|
+
ok: response.ok,
|
|
132
|
+
body: sanitizeBody(body),
|
|
133
|
+
request_id: response.headers?.get?.('x-github-request-id') || null
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
if (!response.ok) {
|
|
137
|
+
const error = new Error(`GitHub API ${response.status}`);
|
|
138
|
+
error.code = 'provider_error';
|
|
139
|
+
error.provider_output = output;
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return output;
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
adapter.isMutation = (request) => MUTATING_ACTIONS.has(request?.action);
|
|
148
|
+
return adapter;
|
|
149
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { dirname, join } from 'node:path';
|
|
2
|
+
import { AdapterRegistry, descriptorAdapter } from './index.js';
|
|
3
|
+
import { CredentialBroker } from './connections.js';
|
|
4
|
+
import { ExecutingAuthorityRuntime } from './execution.js';
|
|
5
|
+
import { createGitHubProviderAdapter } from './providers/github.js';
|
|
6
|
+
import { JsonFileApprovalStore } from './approvals.js';
|
|
7
|
+
import { JsonFileExecutionGuard } from './idempotency.js';
|
|
8
|
+
import { readOrCreateSecretKey } from './keys.js';
|
|
9
|
+
import {
|
|
10
|
+
EncryptedFileSecretStore,
|
|
11
|
+
JsonFileConnectionRegistry,
|
|
12
|
+
JsonFileRevocationStore,
|
|
13
|
+
JsonFileUsageLedger,
|
|
14
|
+
loadConfig
|
|
15
|
+
} from './storage.js';
|
|
16
|
+
|
|
17
|
+
export function createRuntimeEnvironment({ home } = {}) {
|
|
18
|
+
const config = loadConfig({ home });
|
|
19
|
+
const connections = new JsonFileConnectionRegistry(config.paths.connections);
|
|
20
|
+
const secrets = new EncryptedFileSecretStore({ path: config.paths.secrets, keyPath: config.paths.master_key });
|
|
21
|
+
const broker = new CredentialBroker({ connections, secrets });
|
|
22
|
+
const revocations = new JsonFileRevocationStore(config.paths.revocations);
|
|
23
|
+
const usage = new JsonFileUsageLedger(config.paths.usage);
|
|
24
|
+
const stateDir = dirname(config.paths.connections);
|
|
25
|
+
const vaultDir = dirname(config.paths.master_key);
|
|
26
|
+
const approvals = new JsonFileApprovalStore(join(stateDir, 'approvals.json'));
|
|
27
|
+
const executions = new JsonFileExecutionGuard(join(stateDir, 'executions.json'));
|
|
28
|
+
const agentAuthKeyPath = join(vaultDir, 'agent-auth.key');
|
|
29
|
+
const agentAuthKey = readOrCreateSecretKey(agentAuthKeyPath);
|
|
30
|
+
|
|
31
|
+
const adapters = new AdapterRegistry()
|
|
32
|
+
.register(createGitHubProviderAdapter({ broker }))
|
|
33
|
+
.register(descriptorAdapter('oauth', ['google', 'slack', 'microsoft']))
|
|
34
|
+
.register(descriptorAdapter('mcp', ['mcp:*']))
|
|
35
|
+
.register(descriptorAdapter('api-key', ['cloudflare', 'apollo']))
|
|
36
|
+
.register(descriptorAdapter('cli', ['cli:*']));
|
|
37
|
+
|
|
38
|
+
const runtime = new ExecutingAuthorityRuntime({ adapters, revocations, usage, approvals, executions });
|
|
39
|
+
return {
|
|
40
|
+
config,
|
|
41
|
+
connections,
|
|
42
|
+
secrets,
|
|
43
|
+
broker,
|
|
44
|
+
revocations,
|
|
45
|
+
usage,
|
|
46
|
+
approvals,
|
|
47
|
+
executions,
|
|
48
|
+
adapters,
|
|
49
|
+
runtime,
|
|
50
|
+
agentAuthKey,
|
|
51
|
+
agentAuthKeyPath
|
|
52
|
+
};
|
|
53
|
+
}
|
package/src/sdk.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export class AgentAuthorityClient {
|
|
2
|
+
constructor({ baseUrl = 'http://127.0.0.1:8787', token = null, tokenProvider = null, fetchImpl = globalThis.fetch } = {}) {
|
|
3
|
+
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
4
|
+
this.token = token;
|
|
5
|
+
this.tokenProvider = tokenProvider;
|
|
6
|
+
this.fetchImpl = fetchImpl;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async currentToken() {
|
|
10
|
+
if (typeof this.tokenProvider === 'function') return this.tokenProvider();
|
|
11
|
+
return this.token;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async request(path, { method = 'POST', payload, authenticated = true } = {}) {
|
|
15
|
+
const headers = {};
|
|
16
|
+
if (payload !== undefined) headers['content-type'] = 'application/json';
|
|
17
|
+
if (authenticated) {
|
|
18
|
+
const token = await this.currentToken();
|
|
19
|
+
if (!token) throw new Error('Agent Authority client requires an agent-instance token');
|
|
20
|
+
headers.authorization = `Bearer ${token}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
24
|
+
method,
|
|
25
|
+
headers,
|
|
26
|
+
body: payload === undefined ? undefined : JSON.stringify(payload)
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const text = await response.text();
|
|
30
|
+
let body = text;
|
|
31
|
+
if (text) {
|
|
32
|
+
try { body = JSON.parse(text); } catch { /* preserve text */ }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
const error = new Error(`Agent Authority ${response.status}: ${typeof body === 'string' ? body : body?.error || 'request failed'}`);
|
|
37
|
+
error.status = response.status;
|
|
38
|
+
error.response = body;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
return body;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
health() {
|
|
45
|
+
return this.request('/health', { method: 'GET', authenticated: false });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
discover() {
|
|
49
|
+
return this.request('/.well-known/agent-authority', { method: 'GET', authenticated: false });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
evaluate(mission, request) {
|
|
53
|
+
return this.request('/v1/evaluate', { payload: { mission, request } });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
prepare(mission, request) {
|
|
57
|
+
return this.request('/v1/prepare', { payload: { mission, request } });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
execute(mission, request) {
|
|
61
|
+
return this.request('/v1/execute', { payload: { mission, request } });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
approval(approval_id) {
|
|
65
|
+
return this.request(`/v1/approvals/${encodeURIComponent(approval_id)}`, { method: 'GET' });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
revoke(mission_id, reason) {
|
|
69
|
+
return this.request('/v1/revoke', { payload: { mission_id, reason } });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
listConnections() {
|
|
73
|
+
return this.request('/v1/connections', { method: 'GET' });
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { createRuntimeEnvironment } from './runtime-env.js';
|
|
4
|
+
import { writeReceipt } from './storage.js';
|
|
5
|
+
import { bearerToken, verifyAgentToken } from './agent-auth.js';
|
|
6
|
+
|
|
7
|
+
async function readJson(req, maxBytes = 1024 * 1024) {
|
|
8
|
+
let body = '';
|
|
9
|
+
for await (const chunk of req) {
|
|
10
|
+
body += chunk;
|
|
11
|
+
if (Buffer.byteLength(body) > maxBytes) {
|
|
12
|
+
const error = new Error('request body too large');
|
|
13
|
+
error.code = 'payload_too_large';
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return body ? JSON.parse(body) : {};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function send(res, status, value) {
|
|
21
|
+
const body = JSON.stringify(value, null, 2);
|
|
22
|
+
res.writeHead(status, {
|
|
23
|
+
'content-type': 'application/json; charset=utf-8',
|
|
24
|
+
'cache-control': 'no-store',
|
|
25
|
+
'x-content-type-options': 'nosniff',
|
|
26
|
+
'referrer-policy': 'no-referrer'
|
|
27
|
+
});
|
|
28
|
+
res.end(body);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function persistReceipt(config, output) {
|
|
32
|
+
if (output?.receipt) writeReceipt(config.paths.receipts, output.receipt);
|
|
33
|
+
return output;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function authenticate(req, env, options = {}) {
|
|
37
|
+
const token = bearerToken(req.headers);
|
|
38
|
+
return verifyAgentToken(token, {
|
|
39
|
+
key: env.agentAuthKey,
|
|
40
|
+
principal_id: env.config.principal_id,
|
|
41
|
+
...options
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function authStatus(error) {
|
|
46
|
+
if (['missing_agent_token', 'invalid_agent_token', 'agent_token_expired'].includes(error?.code)) return 401;
|
|
47
|
+
if (['principal_mismatch', 'mission_binding_mismatch', 'agent_identity_mismatch', 'agent_capability_denied'].includes(error?.code)) return 403;
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createAgentAuthorityServer({ home, host, port } = {}) {
|
|
52
|
+
const env = createRuntimeEnvironment({ home });
|
|
53
|
+
const bindHost = host ?? process.env.AGENT_AUTHORITY_HOST ?? env.config.server.host ?? '127.0.0.1';
|
|
54
|
+
const bindPort = Number(port ?? process.env.AGENT_AUTHORITY_PORT ?? env.config.server.port ?? 8787);
|
|
55
|
+
|
|
56
|
+
const server = http.createServer(async (req, res) => {
|
|
57
|
+
try {
|
|
58
|
+
const url = new URL(req.url, `http://${req.headers.host || `${bindHost}:${bindPort}`}`);
|
|
59
|
+
|
|
60
|
+
if (req.method === 'GET' && url.pathname === '/health') {
|
|
61
|
+
return send(res, 200, { ok: true, service: 'agent-authority', version: '0.3.0' });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (req.method === 'GET' && url.pathname === '/.well-known/agent-authority') {
|
|
65
|
+
return send(res, 200, {
|
|
66
|
+
service: 'agent-authority',
|
|
67
|
+
version: '0.3.0',
|
|
68
|
+
api_version: 'v1',
|
|
69
|
+
authorization: { scheme: 'Bearer', token_type: 'agent-instance', mission_binding: true },
|
|
70
|
+
endpoints: {
|
|
71
|
+
evaluate: '/v1/evaluate',
|
|
72
|
+
prepare: '/v1/prepare',
|
|
73
|
+
execute: '/v1/execute',
|
|
74
|
+
approval: '/v1/approvals/{approval_id}'
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (req.method === 'GET' && url.pathname === '/v1/connections') {
|
|
80
|
+
authenticate(req, env, { capability: 'connections.read' });
|
|
81
|
+
return send(res, 200, { connections: env.broker.listConnections(env.config.principal_id) });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (req.method === 'GET' && url.pathname.startsWith('/v1/approvals/')) {
|
|
85
|
+
const approvalId = decodeURIComponent(url.pathname.slice('/v1/approvals/'.length));
|
|
86
|
+
const approval = env.approvals.get(approvalId);
|
|
87
|
+
if (!approval) return send(res, 404, { error: 'approval not found' });
|
|
88
|
+
const claims = authenticate(req, env, { capability: 'approval.read', mission_id: approval.mission_id });
|
|
89
|
+
if (claims.sub !== approval.agent_id) return send(res, 403, { error: 'approval belongs to another agent', code: 'agent_identity_mismatch' });
|
|
90
|
+
return send(res, 200, { approval });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (req.method === 'POST' && url.pathname === '/v1/evaluate') {
|
|
94
|
+
const { mission, request } = await readJson(req);
|
|
95
|
+
authenticate(req, env, { capability: 'evaluate', mission });
|
|
96
|
+
return send(res, 200, persistReceipt(env.config, env.runtime.evaluate(mission, request)));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (req.method === 'POST' && url.pathname === '/v1/prepare') {
|
|
100
|
+
const { mission, request } = await readJson(req);
|
|
101
|
+
authenticate(req, env, { capability: 'prepare', mission });
|
|
102
|
+
return send(res, 200, persistReceipt(env.config, await env.runtime.prepare(mission, request)));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (req.method === 'POST' && url.pathname === '/v1/execute') {
|
|
106
|
+
const { mission, request } = await readJson(req);
|
|
107
|
+
authenticate(req, env, { capability: 'execute', mission });
|
|
108
|
+
const result = persistReceipt(env.config, await env.runtime.execute(mission, request));
|
|
109
|
+
const status = result.result?.code === 'connection_required' ? 409 : 200;
|
|
110
|
+
return send(res, status, result);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (req.method === 'POST' && url.pathname === '/v1/revoke') {
|
|
114
|
+
const { mission_id, reason } = await readJson(req);
|
|
115
|
+
if (!mission_id) return send(res, 400, { error: 'mission_id is required' });
|
|
116
|
+
authenticate(req, env, { capability: 'mission.revoke', mission_id });
|
|
117
|
+
return send(res, 200, { mission_id, ...env.runtime.revoke(mission_id, reason) });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return send(res, 404, { error: 'not found' });
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (error?.code === 'provider_error' && error.provider_output) {
|
|
123
|
+
return send(res, 502, { error: error.message, provider: error.provider_output });
|
|
124
|
+
}
|
|
125
|
+
const authenticationStatus = authStatus(error);
|
|
126
|
+
if (authenticationStatus) return send(res, authenticationStatus, { error: error.message, code: error.code });
|
|
127
|
+
const status = error?.code === 'payload_too_large' ? 413 : 400;
|
|
128
|
+
return send(res, status, { error: error.message, code: error.code || 'bad_request' });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
return { server, env, host: bindHost, port: bindPort };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function startServer(options = {}) {
|
|
136
|
+
const instance = createAgentAuthorityServer(options);
|
|
137
|
+
instance.server.listen(instance.port, instance.host, () => {
|
|
138
|
+
const address = instance.server.address();
|
|
139
|
+
const actualPort = typeof address === 'object' && address ? address.port : instance.port;
|
|
140
|
+
console.log(`Agent Authority listening on http://${instance.host}:${actualPort}`);
|
|
141
|
+
console.log('API authentication: signed agent-instance bearer tokens required');
|
|
142
|
+
});
|
|
143
|
+
return instance;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) startServer();
|