ajp-protocol 0.1.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/.github/workflows/publish.yml +20 -0
- package/LICENSE +21 -0
- package/README.md +201 -0
- package/examples/agent-to-agent/example.js +67 -0
- package/examples/human-to-agent/example.js +83 -0
- package/examples/orchestrator/example.js +55 -0
- package/package.json +20 -0
- package/schema/job-offer.json +95 -0
- package/spec/SPEC.md +333 -0
- package/src/client.js +196 -0
- package/src/index.js +24 -0
- package/src/server.js +273 -0
- package/src/utils.js +93 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AJPServer — Receive and process jobs from any sender.
|
|
3
|
+
*
|
|
4
|
+
* Handles: signature verification, trust checks, job lifecycle.
|
|
5
|
+
* Returns standard HTTP handler functions compatible with
|
|
6
|
+
* Express, Next.js API routes, Fastify, or any Node HTTP framework.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { verify, validateOffer, JOB_STATUS, FROM_TYPE } from './utils.js';
|
|
10
|
+
import { Provenance } from 'provenance-protocol';
|
|
11
|
+
|
|
12
|
+
export class AJPServer {
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {object} opts
|
|
16
|
+
* @param {string} opts.provenanceId — this agent's Provenance ID
|
|
17
|
+
* @param {string} opts.secret — HMAC verification secret (must match client)
|
|
18
|
+
* @param {Function} opts.onJob — async (job) => result — your agent logic
|
|
19
|
+
* @param {object} [opts.trustRequirements] — applied to all agent/orchestrator senders
|
|
20
|
+
* @param {boolean} [opts.trustRequirements.requireDeclared]
|
|
21
|
+
* @param {string[]} [opts.trustRequirements.requireConstraints]
|
|
22
|
+
* @param {boolean} [opts.trustRequirements.requireClean]
|
|
23
|
+
* @param {number} [opts.trustRequirements.requireMinAge]
|
|
24
|
+
* @param {number} [opts.trustRequirements.requireMinConfidence]
|
|
25
|
+
* @param {string} [opts.provenanceApiUrl] — override Provenance API URL
|
|
26
|
+
*/
|
|
27
|
+
constructor({
|
|
28
|
+
provenanceId,
|
|
29
|
+
secret,
|
|
30
|
+
onJob,
|
|
31
|
+
trustRequirements = {},
|
|
32
|
+
provenanceApiUrl,
|
|
33
|
+
}) {
|
|
34
|
+
this.provenanceId = provenanceId;
|
|
35
|
+
this.secret = secret;
|
|
36
|
+
this.onJob = onJob;
|
|
37
|
+
this.trustRequirements = trustRequirements;
|
|
38
|
+
this.provenance = new Provenance({ apiUrl: provenanceApiUrl });
|
|
39
|
+
|
|
40
|
+
// In-memory job store — replace with DB for production
|
|
41
|
+
this.jobs = new Map();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── POST /jobs — receive a new job ────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
receive() {
|
|
47
|
+
return async (req, res) => {
|
|
48
|
+
try {
|
|
49
|
+
const offer = await this._parseBody(req);
|
|
50
|
+
|
|
51
|
+
// 1. Validate structure
|
|
52
|
+
const { valid, errors } = validateOffer(offer);
|
|
53
|
+
if (!valid) {
|
|
54
|
+
return this._json(res, 400, { error: 'Invalid JobOffer', errors });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 2. Verify signature
|
|
58
|
+
if (!verify(offer, this.secret)) {
|
|
59
|
+
return this._json(res, 401, { error: 'Invalid signature' });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 3. Trust check — required for agent/orchestrator senders
|
|
63
|
+
if (offer.from.type === FROM_TYPE.AGENT || offer.from.type === FROM_TYPE.ORCHESTRATOR) {
|
|
64
|
+
const trustResult = await this.provenance.gate(
|
|
65
|
+
offer.from.provenance_id,
|
|
66
|
+
{
|
|
67
|
+
requireDeclared: this.trustRequirements.requireDeclared ?? false,
|
|
68
|
+
requireConstraints: this.trustRequirements.requireConstraints ?? [],
|
|
69
|
+
requireClean: this.trustRequirements.requireClean ?? true,
|
|
70
|
+
requireMinAge: this.trustRequirements.requireMinAge ?? 0,
|
|
71
|
+
requireMinConfidence: this.trustRequirements.requireMinConfidence ?? 0,
|
|
72
|
+
}
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
if (!trustResult.allowed) {
|
|
76
|
+
return this._json(res, 403, {
|
|
77
|
+
error: 'Trust check failed',
|
|
78
|
+
reason: trustResult.reason,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 4. Accept the job
|
|
84
|
+
const job = {
|
|
85
|
+
...offer,
|
|
86
|
+
status: JOB_STATUS.ACCEPTED,
|
|
87
|
+
accepted_at: new Date().toISOString(),
|
|
88
|
+
started_at: null,
|
|
89
|
+
completed_at: null,
|
|
90
|
+
output: null,
|
|
91
|
+
error: null,
|
|
92
|
+
usage: { llm_tokens: 0, duration_seconds: 0, cost_usd: 0 },
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
this.jobs.set(offer.job_id, job);
|
|
96
|
+
|
|
97
|
+
// 5. Respond immediately — execution is async
|
|
98
|
+
this._json(res, 202, { job_id: offer.job_id, status: JOB_STATUS.ACCEPTED });
|
|
99
|
+
|
|
100
|
+
// 6. Execute in background
|
|
101
|
+
this._execute(offer.job_id);
|
|
102
|
+
|
|
103
|
+
} catch (e) {
|
|
104
|
+
this._json(res, 500, { error: e.message });
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── GET /jobs/:id — status check ─────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
status() {
|
|
112
|
+
return async (req, res) => {
|
|
113
|
+
try {
|
|
114
|
+
const jobId = this._extractJobId(req);
|
|
115
|
+
const job = this.jobs.get(jobId);
|
|
116
|
+
|
|
117
|
+
if (!job) {
|
|
118
|
+
return this._json(res, 404, { error: 'Job not found', job_id: jobId });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const response = {
|
|
122
|
+
ajp: '0.1',
|
|
123
|
+
job_id: job.job_id,
|
|
124
|
+
status: job.status,
|
|
125
|
+
started_at: job.started_at,
|
|
126
|
+
updated_at: job.updated_at || job.accepted_at,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
if (job.status === JOB_STATUS.COMPLETED) {
|
|
130
|
+
response.output = job.output;
|
|
131
|
+
response.usage = job.usage;
|
|
132
|
+
response.agent = {
|
|
133
|
+
provenance_id: this.provenanceId,
|
|
134
|
+
};
|
|
135
|
+
response.completed_at = job.completed_at;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (job.status === JOB_STATUS.FAILED) {
|
|
139
|
+
response.message = job.error;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
this._json(res, 200, response);
|
|
143
|
+
} catch (e) {
|
|
144
|
+
this._json(res, 500, { error: e.message });
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── POST /jobs/:id/ack — confirm receipt ──────────────────────────────
|
|
150
|
+
|
|
151
|
+
ack() {
|
|
152
|
+
return async (req, res) => {
|
|
153
|
+
try {
|
|
154
|
+
const jobId = this._extractJobId(req);
|
|
155
|
+
const job = this.jobs.get(jobId);
|
|
156
|
+
|
|
157
|
+
if (!job) {
|
|
158
|
+
return this._json(res, 404, { error: 'Job not found' });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
job.acknowledged_at = new Date().toISOString();
|
|
162
|
+
this.jobs.set(jobId, job);
|
|
163
|
+
|
|
164
|
+
// Payment settlement hook — implement when integrating with ClawMarket
|
|
165
|
+
// await this._settlePayment(job);
|
|
166
|
+
|
|
167
|
+
this._json(res, 200, { settled: true, job_id: jobId });
|
|
168
|
+
} catch (e) {
|
|
169
|
+
this._json(res, 500, { error: e.message });
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Execution ─────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
async _execute(jobId) {
|
|
177
|
+
const job = this.jobs.get(jobId);
|
|
178
|
+
if (!job) return;
|
|
179
|
+
|
|
180
|
+
const startTime = Date.now();
|
|
181
|
+
job.status = JOB_STATUS.RUNNING;
|
|
182
|
+
job.started_at = new Date().toISOString();
|
|
183
|
+
job.updated_at = job.started_at;
|
|
184
|
+
this.jobs.set(jobId, job);
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const output = await this.onJob(job);
|
|
188
|
+
|
|
189
|
+
const durationSeconds = (Date.now() - startTime) / 1000;
|
|
190
|
+
job.status = JOB_STATUS.COMPLETED;
|
|
191
|
+
job.output = output;
|
|
192
|
+
job.completed_at = new Date().toISOString();
|
|
193
|
+
job.updated_at = job.completed_at;
|
|
194
|
+
job.usage.duration_seconds = durationSeconds;
|
|
195
|
+
this.jobs.set(jobId, job);
|
|
196
|
+
|
|
197
|
+
// Deliver result via callback if set
|
|
198
|
+
if (job.callback?.url) {
|
|
199
|
+
await this._deliverCallback(job);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
} catch (e) {
|
|
203
|
+
job.status = JOB_STATUS.FAILED;
|
|
204
|
+
job.error = e.message;
|
|
205
|
+
job.updated_at = new Date().toISOString();
|
|
206
|
+
this.jobs.set(jobId, job);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async _deliverCallback(job) {
|
|
211
|
+
try {
|
|
212
|
+
await fetch(job.callback.url, {
|
|
213
|
+
method: 'POST',
|
|
214
|
+
headers: {
|
|
215
|
+
'Content-Type': 'application/json',
|
|
216
|
+
...job.callback.headers,
|
|
217
|
+
},
|
|
218
|
+
body: JSON.stringify({
|
|
219
|
+
ajp: '0.1',
|
|
220
|
+
job_id: job.job_id,
|
|
221
|
+
status: job.status,
|
|
222
|
+
output: job.output,
|
|
223
|
+
usage: job.usage,
|
|
224
|
+
agent: { provenance_id: this.provenanceId },
|
|
225
|
+
completed_at: job.completed_at,
|
|
226
|
+
}),
|
|
227
|
+
});
|
|
228
|
+
} catch (e) {
|
|
229
|
+
console.error(`[AJP] Callback delivery failed for ${job.job_id}:`, e.message);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── HTTP helpers ──────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
_json(res, status, body) {
|
|
236
|
+
// Works with Express (res.status().json()) and Next.js (NextResponse)
|
|
237
|
+
if (typeof res.status === 'function' && typeof res.json === 'function') {
|
|
238
|
+
return res.status(status).json(body);
|
|
239
|
+
}
|
|
240
|
+
// Raw Node http.ServerResponse
|
|
241
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
242
|
+
res.end(JSON.stringify(body));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async _parseBody(req) {
|
|
246
|
+
// Next.js App Router: req.json()
|
|
247
|
+
if (typeof req.json === 'function') return req.json();
|
|
248
|
+
// Express: req.body already parsed
|
|
249
|
+
if (req.body) return req.body;
|
|
250
|
+
// Raw Node: read stream
|
|
251
|
+
return new Promise((resolve, reject) => {
|
|
252
|
+
let data = '';
|
|
253
|
+
req.on('data', chunk => { data += chunk; });
|
|
254
|
+
req.on('end', () => { try { resolve(JSON.parse(data)); } catch (e) { reject(e); } });
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
_extractJobId(req) {
|
|
259
|
+
// Express: req.params.id
|
|
260
|
+
if (req.params?.id) return req.params.id;
|
|
261
|
+
// Next.js: params from route segment
|
|
262
|
+
if (req.nextUrl) {
|
|
263
|
+
const parts = req.nextUrl.pathname.split('/');
|
|
264
|
+
return parts[parts.length - 1] === 'ack'
|
|
265
|
+
? parts[parts.length - 2]
|
|
266
|
+
: parts[parts.length - 1];
|
|
267
|
+
}
|
|
268
|
+
// Fallback: parse URL
|
|
269
|
+
const url = new URL(req.url, 'http://localhost');
|
|
270
|
+
const parts = url.pathname.split('/');
|
|
271
|
+
return parts[parts.length - 1] === 'ack' ? parts[parts.length - 2] : parts[parts.length - 1];
|
|
272
|
+
}
|
|
273
|
+
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AJP — Signing and Validation Utilities
|
|
3
|
+
* Consistent with provenance-protocol SDK conventions.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import crypto from 'crypto';
|
|
7
|
+
|
|
8
|
+
// ── Signing ───────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Sign a message body with HMAC-SHA256.
|
|
12
|
+
* Excludes the `signature` field from the hash input.
|
|
13
|
+
*/
|
|
14
|
+
export function sign(body, secret) {
|
|
15
|
+
const { signature: _, ...rest } = body;
|
|
16
|
+
const canonical = JSON.stringify(rest, Object.keys(rest).sort());
|
|
17
|
+
const hash = crypto.createHmac('sha256', secret).update(canonical).digest('hex');
|
|
18
|
+
return `sha256:${hash}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Verify a signed message. Returns true if valid.
|
|
23
|
+
*/
|
|
24
|
+
export function verify(body, secret) {
|
|
25
|
+
if (!body.signature) return false;
|
|
26
|
+
const expected = sign(body, secret);
|
|
27
|
+
return crypto.timingSafeEqual(
|
|
28
|
+
Buffer.from(body.signature),
|
|
29
|
+
Buffer.from(expected)
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── Job ID generation ─────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Generate a unique job ID.
|
|
37
|
+
* Format: job_ + timestamp_ms (base36) + random (base36)
|
|
38
|
+
* Sortable, URL-safe, no external deps.
|
|
39
|
+
*/
|
|
40
|
+
export function generateJobId() {
|
|
41
|
+
const ts = Date.now().toString(36);
|
|
42
|
+
const rand = Math.random().toString(36).slice(2, 10);
|
|
43
|
+
return `job_${ts}${rand}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Validation ────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Validate a JobOffer has required fields.
|
|
50
|
+
* Returns { valid, errors }.
|
|
51
|
+
*/
|
|
52
|
+
export function validateOffer(offer) {
|
|
53
|
+
const errors = [];
|
|
54
|
+
|
|
55
|
+
if (!offer.ajp) errors.push('missing: ajp');
|
|
56
|
+
if (!offer.job_id) errors.push('missing: job_id');
|
|
57
|
+
if (!offer.from?.type) errors.push('missing: from.type');
|
|
58
|
+
if (!offer.to?.provenance_id) errors.push('missing: to.provenance_id');
|
|
59
|
+
if (!offer.task?.type) errors.push('missing: task.type');
|
|
60
|
+
if (!offer.task?.instruction) errors.push('missing: task.instruction');
|
|
61
|
+
if (!offer.budget?.max_usd && offer.budget?.max_usd !== 0) errors.push('missing: budget.max_usd');
|
|
62
|
+
if (!offer.issued_at) errors.push('missing: issued_at');
|
|
63
|
+
if (!offer.expires_at) errors.push('missing: expires_at');
|
|
64
|
+
if (!offer.signature) errors.push('missing: signature');
|
|
65
|
+
|
|
66
|
+
if (offer.from?.type === 'agent' || offer.from?.type === 'orchestrator') {
|
|
67
|
+
if (!offer.from.provenance_id) errors.push('from.provenance_id required when type is agent/orchestrator');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Check expiry
|
|
71
|
+
if (offer.expires_at && new Date(offer.expires_at) < new Date()) {
|
|
72
|
+
errors.push('offer has expired');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { valid: errors.length === 0, errors };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Status helpers ────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
export const JOB_STATUS = {
|
|
81
|
+
ACCEPTED: 'accepted',
|
|
82
|
+
RUNNING: 'running',
|
|
83
|
+
COMPLETED: 'completed',
|
|
84
|
+
FAILED: 'failed',
|
|
85
|
+
REJECTED: 'rejected',
|
|
86
|
+
EXPIRED: 'expired',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const FROM_TYPE = {
|
|
90
|
+
HUMAN: 'human',
|
|
91
|
+
AGENT: 'agent',
|
|
92
|
+
ORCHESTRATOR: 'orchestrator',
|
|
93
|
+
};
|