ajp-protocol 0.1.0 → 0.2.2
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 +63 -4
- package/README.md +72 -22
- package/cli/index.js +231 -0
- package/cli/package.json +26 -0
- package/package.json +15 -5
- package/schema/job-offer.json +3 -3
- package/spec/SPEC.md +102 -23
- package/src/client.js +36 -16
- package/src/index.js +7 -0
- package/src/server.js +109 -29
- package/src/trust.js +205 -0
- package/src/utils.js +62 -10
- package/test/trust.test.mjs +88 -0
package/spec/SPEC.md
CHANGED
|
@@ -121,7 +121,8 @@ Confirm result received. Triggers payment settlement if applicable.
|
|
|
121
121
|
"from": {
|
|
122
122
|
"type": "human",
|
|
123
123
|
"id": "user_abc123",
|
|
124
|
-
"provenance_id": null
|
|
124
|
+
"provenance_id": null,
|
|
125
|
+
"declaration_url": null
|
|
125
126
|
},
|
|
126
127
|
|
|
127
128
|
"to": {
|
|
@@ -159,6 +160,13 @@ Confirm result received. Triggers payment settlement if applicable.
|
|
|
159
160
|
}
|
|
160
161
|
```
|
|
161
162
|
|
|
163
|
+
`from.declaration_url` is where the sender's signed declaration is published.
|
|
164
|
+
Agent and orchestrator senders SHOULD provide it: it is what lets a receiver
|
|
165
|
+
establish the sender's key offline rather than trusting a third-party index.
|
|
166
|
+
`null` for human senders, whose identity is carried by the platform's signature.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
162
170
|
### JobStatus
|
|
163
171
|
|
|
164
172
|
```json
|
|
@@ -195,6 +203,8 @@ Status values: `accepted` `running` `completed` `failed` `rejected` `expired`
|
|
|
195
203
|
]
|
|
196
204
|
},
|
|
197
205
|
|
|
206
|
+
"constraints_asserted": ["no:pii", "no:persist:data"],
|
|
207
|
+
|
|
198
208
|
"usage": {
|
|
199
209
|
"llm_tokens": 4821,
|
|
200
210
|
"duration_seconds": 18,
|
|
@@ -208,48 +218,103 @@ Status values: `accepted` `running` `completed` `failed` `rejected` `expired`
|
|
|
208
218
|
},
|
|
209
219
|
|
|
210
220
|
"completed_at": "2026-03-05T10:00:19Z",
|
|
211
|
-
"signature": "
|
|
221
|
+
"signature": "ed25519:base64..."
|
|
212
222
|
}
|
|
213
223
|
```
|
|
214
224
|
|
|
225
|
+
`constraints_asserted` lists the constraints the agent declares it honored during this job. The field is included in the signed payload — the signature ties the assertion to the agent's registered Provenance identity. Self-reported, but attributable: a false assertion is a cryptographic receipt of the lie, actionable via the Provenance incidents system.
|
|
226
|
+
|
|
215
227
|
---
|
|
216
228
|
|
|
217
229
|
## Trust verification
|
|
218
230
|
|
|
219
|
-
|
|
220
|
-
|
|
231
|
+
Establishing whether to accept a job from another agent splits into two
|
|
232
|
+
questions, and only one of them needs a network service.
|
|
233
|
+
|
|
234
|
+
### 1. Identity — MUST, and offline
|
|
235
|
+
|
|
236
|
+
*Is this signature really from the party named in `from`?*
|
|
237
|
+
|
|
238
|
+
The sender publishes its signed declaration and points at it with
|
|
239
|
+
`from.declaration_url`. The receiver fetches that file and verifies it locally:
|
|
221
240
|
|
|
222
241
|
```js
|
|
223
|
-
import {
|
|
242
|
+
import { verifyDeclaration } from 'provenance-protocol/verify';
|
|
224
243
|
|
|
225
|
-
const result = await
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
});
|
|
244
|
+
const result = await verifyDeclaration(declaration, { retrievedFrom: declarationUrl });
|
|
245
|
+
// result.valid — signature checks against the key inside the file
|
|
246
|
+
// result.location — the file was served from the location its id names
|
|
247
|
+
// result.fingerprint — the key's fingerprint, for rotation detection
|
|
248
|
+
```
|
|
231
249
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
250
|
+
A receiver MUST reject the offer unless the declaration verifies, the retrieval
|
|
251
|
+
location matches the `provenance_id` it claims, and that id equals
|
|
252
|
+
`from.provenance_id`. The sender's public key is then taken from the declaration
|
|
253
|
+
and used to check the offer's signature.
|
|
254
|
+
|
|
255
|
+
No index is consulted. Re-hosting a genuine declaration elsewhere fails the
|
|
256
|
+
location check, and forging one requires the genuine private key.
|
|
257
|
+
|
|
258
|
+
Receivers SHOULD remember the key fingerprint they saw for a `provenance_id`. A
|
|
259
|
+
later offer signed by a different key is a key rotation and MUST be treated as
|
|
260
|
+
a material change rather than a routine update.
|
|
261
|
+
|
|
262
|
+
An earlier version of this specification obtained the sender's public key from a
|
|
263
|
+
single index, which made signature verification depend on one service being
|
|
264
|
+
reachable. That is no longer permitted for identity.
|
|
265
|
+
|
|
266
|
+
### 2. Standing — SHOULD, online, and the receiver's choice
|
|
267
|
+
|
|
268
|
+
*Is that party currently in good order?* Revoked, open incidents, stale
|
|
269
|
+
evidence. This cannot be answered offline: the absence of news cannot be
|
|
270
|
+
carried in a document, so somebody has to be asked.
|
|
271
|
+
|
|
272
|
+
**Which attester to ask is the receiver's decision, not this protocol's.** One
|
|
273
|
+
index, several, a private attester, or none. A conformant implementation MUST
|
|
274
|
+
NOT hardcode a single provider.
|
|
275
|
+
|
|
276
|
+
```js
|
|
277
|
+
import { AJPServer, declarationKeyResolver, indexStandingCheck } from 'ajp-protocol';
|
|
278
|
+
import { Provenance } from 'provenance-protocol';
|
|
279
|
+
|
|
280
|
+
const server = new AJPServer({
|
|
281
|
+
provenanceId, onJob,
|
|
282
|
+
resolveSenderKey: declarationKeyResolver(), // offline, the default
|
|
283
|
+
checkStanding: indexStandingCheck(new Provenance(), // opt-in, swappable
|
|
284
|
+
{ requireConstraints: ['no:pii'], requireClean: true, requireMinAge: 7 }),
|
|
285
|
+
onStandingUnavailable: 'deny',
|
|
286
|
+
});
|
|
235
287
|
```
|
|
236
288
|
|
|
237
|
-
|
|
238
|
-
|
|
289
|
+
A receiver that performs a standing check MUST distinguish *checked and failed*
|
|
290
|
+
from *could not check*, and MUST state which way it fails when the check is
|
|
291
|
+
unavailable. Reporting an unreachable attester as a failed trust check turns
|
|
292
|
+
someone else's downtime into an accusation against the sender.
|
|
293
|
+
|
|
294
|
+
For `from.type === 'human'`, identity is established by the platform issuing the
|
|
295
|
+
offer, and the shared-secret signature covers it.
|
|
239
296
|
|
|
240
297
|
---
|
|
241
298
|
|
|
242
299
|
## Signature
|
|
243
300
|
|
|
244
301
|
Every JobOffer and JobResult is signed by the sender. The signature covers the
|
|
245
|
-
full message body excluding the `signature` field itself.
|
|
302
|
+
full message body excluding the `signature` field itself, with keys sorted canonically.
|
|
303
|
+
|
|
304
|
+
**Human senders** (no Provenance identity) — HMAC-SHA256 with a shared secret:
|
|
305
|
+
```
|
|
306
|
+
signature = "sha256:" + hex(HMAC-SHA256(canonical(body), sender_secret))
|
|
307
|
+
```
|
|
246
308
|
|
|
309
|
+
**Agent and orchestrator senders** — Ed25519 with the sender's registered Provenance private key:
|
|
247
310
|
```
|
|
248
|
-
signature = "
|
|
311
|
+
signature = "ed25519:" + base64(Ed25519Sign(canonical(body), provenance_private_key))
|
|
249
312
|
```
|
|
250
313
|
|
|
251
|
-
|
|
252
|
-
|
|
314
|
+
The receiving server verifies agent signatures by fetching the sender's public key from the
|
|
315
|
+
Provenance index. This ties every message to a registered identity without a shared secret.
|
|
316
|
+
|
|
317
|
+
The `ajp-protocol` SDK handles signing and verification automatically based on `from.type`.
|
|
253
318
|
|
|
254
319
|
---
|
|
255
320
|
|
|
@@ -262,7 +327,8 @@ import { AJPServer } from 'ajp-protocol';
|
|
|
262
327
|
|
|
263
328
|
const server = new AJPServer({
|
|
264
329
|
provenanceId: 'provenance:github:alice/research-assistant',
|
|
265
|
-
|
|
330
|
+
privateKey: process.env.PROVENANCE_PRIVATE_KEY,
|
|
331
|
+
constraints: ['no:pii', 'no:persist:data'], // asserted in every signed result
|
|
266
332
|
onJob: async (job) => {
|
|
267
333
|
// your agent logic here
|
|
268
334
|
return { papers: [...] };
|
|
@@ -321,6 +387,19 @@ can discover an agent's AJP endpoint without out-of-band communication.
|
|
|
321
387
|
|
|
322
388
|
---
|
|
323
389
|
|
|
390
|
+
## Implementing this specification
|
|
391
|
+
|
|
392
|
+
This specification and its JSON Schema are published under the MIT Licence.
|
|
393
|
+
You may implement them in any language, for any purpose, commercial or
|
|
394
|
+
otherwise, without permission, notification or fee.
|
|
395
|
+
|
|
396
|
+
AJP describes messages exchanged directly between agents. Nothing in it
|
|
397
|
+
requires contacting any particular service. Trust verification is a separate
|
|
398
|
+
concern, defined by the [Provenance Protocol](https://github.com/ilucky21c/provenance-protocol);
|
|
399
|
+
an AJP implementation may use any verifier, or none.
|
|
400
|
+
|
|
401
|
+
---
|
|
402
|
+
|
|
324
403
|
## Versioning
|
|
325
404
|
|
|
326
405
|
The `ajp` field in every message declares the spec version. `0.1` is the current
|
|
@@ -329,5 +408,5 @@ version. Future versions add fields, never remove them.
|
|
|
329
408
|
---
|
|
330
409
|
|
|
331
410
|
*AJP v0.1 — Provenance Protocol Family — MIT License*
|
|
332
|
-
*https://
|
|
333
|
-
*https://github.com/
|
|
411
|
+
*https://getprovenance.dev/ajp*
|
|
412
|
+
*https://github.com/ilucky21c/ajp-protocol*
|
package/src/client.js
CHANGED
|
@@ -5,29 +5,41 @@
|
|
|
5
5
|
* Consistent with provenance-protocol SDK class structure.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { sign, generateJobId, validateOffer } from './utils.js';
|
|
8
|
+
import { sign, signWithKey, generateJobId, validateOffer } from './utils.js';
|
|
9
9
|
import { Provenance } from 'provenance-protocol';
|
|
10
10
|
|
|
11
11
|
export class AJPClient {
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* @param {object} opts
|
|
15
|
-
* @param {object} opts.from
|
|
16
|
-
* @param {string} opts.from.type
|
|
17
|
-
* @param {string} [opts.from.id]
|
|
15
|
+
* @param {object} opts.from — sender identity
|
|
16
|
+
* @param {string} opts.from.type — 'human' | 'agent' | 'orchestrator'
|
|
17
|
+
* @param {string} [opts.from.id] — platform user ID (human only)
|
|
18
18
|
* @param {string} [opts.from.provenance_id] — required for agent/orchestrator
|
|
19
|
-
* @param {string} opts.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* @param {string} [opts.from.declaration_url] — where this sender's signed
|
|
20
|
+
* declaration is published. Lets receivers establish this sender's key
|
|
21
|
+
* offline instead of looking it up in an index. Strongly recommended:
|
|
22
|
+
* without it a receiver must fall back to trusting a third party.
|
|
23
|
+
* @param {string} [opts.privateKey] — Base64 PKCS8 Ed25519 private key (PROVENANCE_PRIVATE_KEY).
|
|
24
|
+
* Required for agent/orchestrator senders. Allows any
|
|
25
|
+
* indexed agent to call any other without a shared secret.
|
|
26
|
+
* @param {string} [opts.secret] — HMAC secret. Required for human senders only.
|
|
27
|
+
* @param {string} [opts.provenanceApiUrl] — override Provenance API URL
|
|
28
|
+
* @param {number} [opts.defaultTimeoutMs] — default job timeout in ms (30s)
|
|
22
29
|
*/
|
|
23
|
-
constructor({ from, secret, provenanceApiUrl, defaultTimeoutMs = 30000 }) {
|
|
30
|
+
constructor({ from, privateKey, secret, provenanceApiUrl, defaultTimeoutMs = 30000 }) {
|
|
24
31
|
this.from = from;
|
|
25
|
-
this.
|
|
32
|
+
this.privateKey = privateKey || null;
|
|
33
|
+
this.secret = secret || null;
|
|
26
34
|
this.defaultTimeoutMs = defaultTimeoutMs;
|
|
27
35
|
this.provenance = new Provenance({ apiUrl: provenanceApiUrl });
|
|
28
36
|
|
|
29
|
-
if (
|
|
30
|
-
throw new Error('from.provenance_id required when type is agent or orchestrator');
|
|
37
|
+
if (from.type === 'agent' || from.type === 'orchestrator') {
|
|
38
|
+
if (!from.provenance_id) throw new Error('from.provenance_id required when type is agent or orchestrator');
|
|
39
|
+
if (!privateKey) throw new Error('privateKey (PROVENANCE_PRIVATE_KEY) required for agent/orchestrator senders');
|
|
40
|
+
}
|
|
41
|
+
if (from.type === 'human' && !secret) {
|
|
42
|
+
throw new Error('secret required for human senders');
|
|
31
43
|
}
|
|
32
44
|
}
|
|
33
45
|
|
|
@@ -64,6 +76,7 @@ export class AJPClient {
|
|
|
64
76
|
type: this.from.type,
|
|
65
77
|
id: this.from.id || null,
|
|
66
78
|
provenance_id: this.from.provenance_id || null,
|
|
79
|
+
declaration_url: this.from.declaration_url || null,
|
|
67
80
|
},
|
|
68
81
|
|
|
69
82
|
to: { provenance_id: toProvenanceId },
|
|
@@ -93,8 +106,10 @@ export class AJPClient {
|
|
|
93
106
|
signature: '',
|
|
94
107
|
};
|
|
95
108
|
|
|
96
|
-
// Sign
|
|
97
|
-
offer.signature =
|
|
109
|
+
// Sign with Ed25519 (agents/orchestrators) or HMAC (humans)
|
|
110
|
+
offer.signature = (this.from.type === 'agent' || this.from.type === 'orchestrator')
|
|
111
|
+
? signWithKey(offer, this.privateKey)
|
|
112
|
+
: sign(offer, this.secret);
|
|
98
113
|
|
|
99
114
|
// Validate before sending
|
|
100
115
|
const { valid, errors } = validateOffer(offer);
|
|
@@ -155,7 +170,9 @@ export class AJPClient {
|
|
|
155
170
|
method: 'POST',
|
|
156
171
|
headers: { 'Content-Type': 'application/json' },
|
|
157
172
|
body: JSON.stringify({ received: true }),
|
|
158
|
-
}).catch(() => {
|
|
173
|
+
}).catch((e) => {
|
|
174
|
+
console.warn(`[AJP] Ack failed for ${jobId} — payment settlement may not trigger:`, e.message);
|
|
175
|
+
});
|
|
159
176
|
return status;
|
|
160
177
|
}
|
|
161
178
|
|
|
@@ -182,8 +199,11 @@ export class AJPClient {
|
|
|
182
199
|
const endpoint = profile.provenance_yml?.ajp?.endpoint;
|
|
183
200
|
if (endpoint) return endpoint.replace(/\/$/, '');
|
|
184
201
|
|
|
185
|
-
// Fallback: derive from agent URL
|
|
186
|
-
if (profile.url)
|
|
202
|
+
// Fallback: derive from agent URL — unreliable, agent should declare ajp.endpoint in PROVENANCE.yml
|
|
203
|
+
if (profile.url) {
|
|
204
|
+
console.warn(`[AJP] No ajp.endpoint declared for ${provenanceId} — falling back to ${profile.url}/api/agent. Add ajp.endpoint to PROVENANCE.yml for reliability.`);
|
|
205
|
+
return `${profile.url.replace(/\/$/, '')}/api/agent`;
|
|
206
|
+
}
|
|
187
207
|
|
|
188
208
|
throw new Error(`No AJP endpoint found for ${provenanceId}`);
|
|
189
209
|
} catch (e) {
|
package/src/index.js
CHANGED
|
@@ -22,3 +22,10 @@
|
|
|
22
22
|
export { AJPClient } from './client.js';
|
|
23
23
|
export { AJPServer } from './server.js';
|
|
24
24
|
export { sign, verify, generateJobId, validateOffer, JOB_STATUS, FROM_TYPE } from './utils.js';
|
|
25
|
+
export {
|
|
26
|
+
declarationKeyResolver,
|
|
27
|
+
indexKeyResolver,
|
|
28
|
+
firstResolver,
|
|
29
|
+
indexStandingCheck,
|
|
30
|
+
SenderIdentityError,
|
|
31
|
+
} from './trust.js';
|
package/src/server.js
CHANGED
|
@@ -6,36 +6,64 @@
|
|
|
6
6
|
* Express, Next.js API routes, Fastify, or any Node HTTP framework.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { verify, validateOffer, JOB_STATUS, FROM_TYPE } from './utils.js';
|
|
9
|
+
import { verify, verifyWithKey, sign, signWithKey, validateOffer, JOB_STATUS, FROM_TYPE } from './utils.js';
|
|
10
10
|
import { Provenance } from 'provenance-protocol';
|
|
11
|
+
import { declarationKeyResolver, SenderIdentityError } from './trust.js';
|
|
11
12
|
|
|
12
13
|
export class AJPServer {
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* @param {object} opts
|
|
16
|
-
* @param {string} opts.provenanceId
|
|
17
|
-
* @param {string} opts.
|
|
18
|
-
*
|
|
17
|
+
* @param {string} opts.provenanceId — this agent's Provenance ID
|
|
18
|
+
* @param {string} opts.privateKey — Base64 PKCS8 Ed25519 private key (PROVENANCE_PRIVATE_KEY).
|
|
19
|
+
* Used to sign job results so callers can verify authenticity.
|
|
20
|
+
* @param {string} [opts.secret] — HMAC secret for verifying human callers. Optional if you
|
|
21
|
+
* only accept agent/orchestrator callers.
|
|
22
|
+
* @param {Function} opts.onJob — async (job) => result — your agent logic
|
|
23
|
+
* @param {function} [opts.resolveSenderKey] — async (provenanceId, from) => { publicKey, fingerprint }
|
|
24
|
+
* How the sender's key is established. Defaults to resolving it from the
|
|
25
|
+
* sender's own signed declaration, offline, with no index involved.
|
|
26
|
+
* @param {function} [opts.checkStanding] — async (provenanceId) => { allowed, reason }
|
|
27
|
+
* Optional current-standing check (revocation, incidents, freshness).
|
|
28
|
+
* Off by default: standing is the receiver's policy, not a protocol rule.
|
|
29
|
+
* @param {'deny'|'allow'} [opts.onStandingUnavailable] — what to do when the
|
|
30
|
+
* standing check itself fails to answer. Defaults to 'deny'.
|
|
19
31
|
* @param {object} [opts.trustRequirements] — applied to all agent/orchestrator senders
|
|
20
32
|
* @param {boolean} [opts.trustRequirements.requireDeclared]
|
|
21
33
|
* @param {string[]} [opts.trustRequirements.requireConstraints]
|
|
34
|
+
* @param {string[]} [opts.trustRequirements.requireCapabilities] — e.g. ['delegate:agents'] to only accept jobs from declared orchestrators
|
|
22
35
|
* @param {boolean} [opts.trustRequirements.requireClean]
|
|
23
36
|
* @param {number} [opts.trustRequirements.requireMinAge]
|
|
24
|
-
* @param {
|
|
37
|
+
* @param {string[]} [opts.constraints] — constraints this agent honors (from PROVENANCE.yml).
|
|
38
|
+
* Included as `constraints_asserted` in every signed JobResult,
|
|
39
|
+
* creating a cryptographic receipt tied to the registered identity.
|
|
25
40
|
* @param {string} [opts.provenanceApiUrl] — override Provenance API URL
|
|
26
41
|
*/
|
|
27
42
|
constructor({
|
|
28
43
|
provenanceId,
|
|
44
|
+
privateKey,
|
|
29
45
|
secret,
|
|
30
46
|
onJob,
|
|
47
|
+
constraints = [],
|
|
31
48
|
trustRequirements = {},
|
|
32
49
|
provenanceApiUrl,
|
|
50
|
+
resolveSenderKey,
|
|
51
|
+
checkStanding,
|
|
52
|
+
onStandingUnavailable = 'deny',
|
|
33
53
|
}) {
|
|
54
|
+
if (!privateKey) throw new Error('privateKey (PROVENANCE_PRIVATE_KEY) required — used to sign job results');
|
|
34
55
|
this.provenanceId = provenanceId;
|
|
35
|
-
this.
|
|
56
|
+
this.privateKey = privateKey;
|
|
57
|
+
this.secret = secret || null;
|
|
36
58
|
this.onJob = onJob;
|
|
59
|
+
this.constraints = constraints;
|
|
37
60
|
this.trustRequirements = trustRequirements;
|
|
38
61
|
this.provenance = new Provenance({ apiUrl: provenanceApiUrl });
|
|
62
|
+
// Identity resolution never touches an index by default: a signature check
|
|
63
|
+
// that depends on someone's web service being up is not a signature check.
|
|
64
|
+
this.resolveSenderKey = resolveSenderKey ?? declarationKeyResolver();
|
|
65
|
+
this.checkStanding = checkStanding ?? null;
|
|
66
|
+
this.onStandingUnavailable = onStandingUnavailable;
|
|
39
67
|
|
|
40
68
|
// In-memory job store — replace with DB for production
|
|
41
69
|
this.jobs = new Map();
|
|
@@ -55,28 +83,59 @@ export class AJPServer {
|
|
|
55
83
|
}
|
|
56
84
|
|
|
57
85
|
// 2. Verify signature
|
|
58
|
-
if (
|
|
59
|
-
|
|
86
|
+
if (offer.from.type === FROM_TYPE.HUMAN) {
|
|
87
|
+
// Human callers: HMAC-SHA256 with shared secret
|
|
88
|
+
if (!this.secret) {
|
|
89
|
+
return this._json(res, 403, { error: 'This agent does not accept human callers' });
|
|
90
|
+
}
|
|
91
|
+
if (!verify(offer, this.secret)) {
|
|
92
|
+
return this._json(res, 401, { error: 'Invalid signature' });
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
// Agent/orchestrator callers: Ed25519. The key comes from whatever the
|
|
96
|
+
// receiver configured — by default the sender's own signed declaration,
|
|
97
|
+
// verified offline. No index is consulted to check a signature.
|
|
98
|
+
let sender;
|
|
99
|
+
try {
|
|
100
|
+
sender = await this.resolveSenderKey(offer.from.provenance_id, offer.from);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
const code = e instanceof SenderIdentityError ? e.code : 'SENDER_IDENTITY_FAILED';
|
|
103
|
+
return this._json(res, 403, { error: 'Sender identity could not be established', reason: e.message, code });
|
|
104
|
+
}
|
|
105
|
+
if (!sender?.publicKey) {
|
|
106
|
+
return this._json(res, 403, { error: 'Sender identity could not be established', reason: 'No public key resolved' });
|
|
107
|
+
}
|
|
108
|
+
if (!verifyWithKey(offer, sender.publicKey)) {
|
|
109
|
+
return this._json(res, 401, { error: 'Invalid signature' });
|
|
110
|
+
}
|
|
60
111
|
}
|
|
61
112
|
|
|
62
|
-
// 3.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
113
|
+
// 3. Standing check — optional, and the receiver's policy rather than a
|
|
114
|
+
// protocol requirement. Identity is settled above without a network
|
|
115
|
+
// service; standing is the part that genuinely needs asking someone,
|
|
116
|
+
// so it is configured, degradable, and absent by default.
|
|
117
|
+
if (
|
|
118
|
+
this.checkStanding &&
|
|
119
|
+
(offer.from.type === FROM_TYPE.AGENT || offer.from.type === FROM_TYPE.ORCHESTRATOR)
|
|
120
|
+
) {
|
|
121
|
+
let standing;
|
|
122
|
+
try {
|
|
123
|
+
standing = await this.checkStanding(offer.from.provenance_id, this.trustRequirements);
|
|
124
|
+
} catch (e) {
|
|
125
|
+
// "Could not check" must not be reported as "checked and failed".
|
|
126
|
+
if (this.onStandingUnavailable === 'allow') {
|
|
127
|
+
standing = { allowed: true, unavailable: true, reason: e.message };
|
|
128
|
+
} else {
|
|
129
|
+
return this._json(res, 403, {
|
|
130
|
+
error: 'Standing could not be checked',
|
|
131
|
+
reason: e.message,
|
|
132
|
+
code: 'STANDING_UNAVAILABLE',
|
|
133
|
+
});
|
|
72
134
|
}
|
|
73
|
-
|
|
135
|
+
}
|
|
74
136
|
|
|
75
|
-
if (
|
|
76
|
-
return this._json(res, 403, {
|
|
77
|
-
error: 'Trust check failed',
|
|
78
|
-
reason: trustResult.reason,
|
|
79
|
-
});
|
|
137
|
+
if (standing && standing.allowed === false) {
|
|
138
|
+
return this._json(res, 403, { error: 'Trust check failed', reason: standing.reason ?? null });
|
|
80
139
|
}
|
|
81
140
|
}
|
|
82
141
|
|
|
@@ -128,11 +187,11 @@ export class AJPServer {
|
|
|
128
187
|
|
|
129
188
|
if (job.status === JOB_STATUS.COMPLETED) {
|
|
130
189
|
response.output = job.output;
|
|
190
|
+
response.constraints_asserted = job.constraints_asserted;
|
|
131
191
|
response.usage = job.usage;
|
|
132
|
-
response.agent = {
|
|
133
|
-
provenance_id: this.provenanceId,
|
|
134
|
-
};
|
|
192
|
+
response.agent = { provenance_id: this.provenanceId };
|
|
135
193
|
response.completed_at = job.completed_at;
|
|
194
|
+
response.signature = job.signature;
|
|
136
195
|
}
|
|
137
196
|
|
|
138
197
|
if (job.status === JOB_STATUS.FAILED) {
|
|
@@ -184,14 +243,33 @@ export class AJPServer {
|
|
|
184
243
|
this.jobs.set(jobId, job);
|
|
185
244
|
|
|
186
245
|
try {
|
|
187
|
-
const
|
|
246
|
+
const maxSeconds = job.budget?.max_seconds ?? 120;
|
|
247
|
+
const output = await Promise.race([
|
|
248
|
+
this.onJob(job),
|
|
249
|
+
new Promise((_, reject) =>
|
|
250
|
+
setTimeout(() => reject(new Error(`Job exceeded max_seconds (${maxSeconds}s)`)), maxSeconds * 1000)
|
|
251
|
+
),
|
|
252
|
+
]);
|
|
188
253
|
|
|
189
254
|
const durationSeconds = (Date.now() - startTime) / 1000;
|
|
190
255
|
job.status = JOB_STATUS.COMPLETED;
|
|
191
256
|
job.output = output;
|
|
257
|
+
job.constraints_asserted = this.constraints;
|
|
192
258
|
job.completed_at = new Date().toISOString();
|
|
193
259
|
job.updated_at = job.completed_at;
|
|
194
260
|
job.usage.duration_seconds = durationSeconds;
|
|
261
|
+
|
|
262
|
+
// Sign the result with Ed25519 — callers verify using this agent's public key from Provenance index.
|
|
263
|
+
// constraints_asserted is included in the signed payload — a cryptographic receipt of declared behavior.
|
|
264
|
+
job.signature = signWithKey({
|
|
265
|
+
job_id: job.job_id,
|
|
266
|
+
status: job.status,
|
|
267
|
+
output: job.output,
|
|
268
|
+
constraints_asserted: job.constraints_asserted,
|
|
269
|
+
completed_at: job.completed_at,
|
|
270
|
+
agent: { provenance_id: this.provenanceId },
|
|
271
|
+
}, this.privateKey);
|
|
272
|
+
|
|
195
273
|
this.jobs.set(jobId, job);
|
|
196
274
|
|
|
197
275
|
// Deliver result via callback if set
|
|
@@ -200,7 +278,7 @@ export class AJPServer {
|
|
|
200
278
|
}
|
|
201
279
|
|
|
202
280
|
} catch (e) {
|
|
203
|
-
job.status = JOB_STATUS.FAILED;
|
|
281
|
+
job.status = e.message.includes('max_seconds') ? JOB_STATUS.EXPIRED : JOB_STATUS.FAILED;
|
|
204
282
|
job.error = e.message;
|
|
205
283
|
job.updated_at = new Date().toISOString();
|
|
206
284
|
this.jobs.set(jobId, job);
|
|
@@ -220,9 +298,11 @@ export class AJPServer {
|
|
|
220
298
|
job_id: job.job_id,
|
|
221
299
|
status: job.status,
|
|
222
300
|
output: job.output,
|
|
301
|
+
constraints_asserted: job.constraints_asserted,
|
|
223
302
|
usage: job.usage,
|
|
224
303
|
agent: { provenance_id: this.provenanceId },
|
|
225
304
|
completed_at: job.completed_at,
|
|
305
|
+
signature: job.signature,
|
|
226
306
|
}),
|
|
227
307
|
});
|
|
228
308
|
} catch (e) {
|