@envarai/envarpay 0.2.0-alpha.1 → 0.2.0-alpha.11
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/README.md +2 -0
- package/dist/commerce/alerts.d.ts +37 -0
- package/dist/commerce/alerts.js +127 -0
- package/dist/commerce/buyer-store.d.ts +8 -0
- package/dist/commerce/buyer.d.ts +12 -3
- package/dist/commerce/buyer.js +151 -7
- package/dist/commerce/card.js +2 -2
- package/dist/commerce/cli.js +74 -9
- package/dist/commerce/client-runtime.d.ts +1 -1
- package/dist/commerce/config.d.ts +5 -1
- package/dist/commerce/config.js +37 -1
- package/dist/commerce/configure.d.ts +3 -0
- package/dist/commerce/configure.js +158 -0
- package/dist/commerce/discovery.d.ts +23 -0
- package/dist/commerce/discovery.js +162 -0
- package/dist/commerce/envar-runtime.d.ts +3 -1
- package/dist/commerce/envar-runtime.js +28 -1
- package/dist/commerce/envar.d.ts +5 -0
- package/dist/commerce/envar.js +13 -0
- package/dist/commerce/index.d.ts +1 -0
- package/dist/commerce/index.js +1 -0
- package/dist/commerce/management.js +3 -1
- package/dist/commerce/mpp-client.d.ts +11 -0
- package/dist/commerce/mpp-client.js +5 -0
- package/dist/commerce/mpp-runtime.d.ts +1 -0
- package/dist/commerce/mpp-runtime.js +1 -0
- package/dist/commerce/native-skills.d.ts +31 -0
- package/dist/commerce/native-skills.js +174 -0
- package/dist/commerce/operations.d.ts +22 -0
- package/dist/commerce/operations.js +39 -0
- package/dist/commerce/peer-proxy.js +11 -5
- package/dist/commerce/pricing.js +2 -2
- package/dist/commerce/runtime.d.ts +2 -0
- package/dist/commerce/runtime.js +2 -0
- package/dist/commerce/server.d.ts +4 -1
- package/dist/commerce/server.js +40 -9
- package/dist/commerce/setup.d.ts +18 -0
- package/dist/commerce/setup.js +344 -0
- package/dist/commerce/skill-gate.d.ts +54 -0
- package/dist/commerce/skill-gate.js +153 -0
- package/dist/commerce/store.d.ts +2 -0
- package/dist/commerce/store.js +7 -0
- package/dist/commerce/stripe-issuer.d.ts +41 -0
- package/dist/commerce/stripe-issuer.js +351 -0
- package/dist/commerce/stripe-provider.d.ts +2 -0
- package/dist/commerce/stripe-provider.js +57 -22
- package/dist/commerce/types.d.ts +27 -2
- package/dist/commerce/x402.d.ts +1 -0
- package/dist/commerce/x402.js +8 -1
- package/examples/hermes-setup-probe.py +105 -0
- package/examples/hermes-skill-inventory.py +107 -0
- package/examples/native-skill-runner.py +197 -0
- package/package.json +1 -1
- package/schemas/buyer-policy.schema.json +105 -0
- package/schemas/envarpay.config.schema.json +22 -15
package/dist/commerce/server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { timingSafeEqual } from 'node:crypto';
|
|
2
2
|
import { AgentCard, Role, Task, TaskState, SendMessageRequest } from '@a2a-js/sdk';
|
|
3
3
|
import { DefaultRequestHandler, JsonRpcTransportHandler, ServerCallContext } from '@a2a-js/sdk/server';
|
|
4
|
-
import { RequestMalformedError, TaskNotFoundError, UnsupportedOperationError } from '@a2a-js/sdk/errors';
|
|
4
|
+
import { ContentTypeNotSupportedError, RequestMalformedError, TaskNotFoundError, UnsupportedOperationError, VersionNotSupportedError } from '@a2a-js/sdk/errors';
|
|
5
5
|
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
6
6
|
import addFormatsImport from 'ajv-formats';
|
|
7
7
|
import { createOfferCard, offerPath } from './card.js';
|
|
@@ -171,6 +171,7 @@ export class CommerceServer {
|
|
|
171
171
|
running = false;
|
|
172
172
|
kickPending = false;
|
|
173
173
|
stopped = false;
|
|
174
|
+
workerFailed = false;
|
|
174
175
|
waiters = new Map();
|
|
175
176
|
activeOrders = new Set();
|
|
176
177
|
recoveries = new Map();
|
|
@@ -187,6 +188,11 @@ export class CommerceServer {
|
|
|
187
188
|
if (this.stopped)
|
|
188
189
|
throw new CommerceError('server_draining', 'Cannot apply configuration while stopping');
|
|
189
190
|
const config = loadCommerceConfig(value);
|
|
191
|
+
for (const service of config.services.filter(s => s.execution.type === 'skill')) {
|
|
192
|
+
if (!this.options.execute.assertSkill)
|
|
193
|
+
throw new CommerceError('skill_gate_required', 'Skill services require an enforcing skill runtime');
|
|
194
|
+
this.options.execute.assertSkill(service);
|
|
195
|
+
}
|
|
190
196
|
if (config.agent.id !== this.currentConfig.agent.id)
|
|
191
197
|
throw new CommerceError('config_agent_changed', 'Runtime Agent identity cannot change');
|
|
192
198
|
const history = this.store.catalogHistory();
|
|
@@ -206,7 +212,7 @@ export class CommerceServer {
|
|
|
206
212
|
const routes = new Map();
|
|
207
213
|
for (const entry of snapshots.values())
|
|
208
214
|
for (const offer of entry.service.offers) {
|
|
209
|
-
const service = entry.service, historical = { ...config, paymentProfiles: entry.profiles, services: [service] };
|
|
215
|
+
const service = entry.service, historical = { ...config, configVersion: (service.execution.type === 'skill' ? 2 : 1), paymentProfiles: entry.profiles, services: [service] };
|
|
210
216
|
const card = createOfferCard(historical, service.id, offer.id, this.options.origin);
|
|
211
217
|
const active = config.services.some(s => s.id === service.id && s.revision === service.revision);
|
|
212
218
|
routes.set(offerPath(service.id, service.revision, offer.id), { card, rpc: new JsonRpcTransportHandler(new OrderHandler(this, service, offer.id, card, this.store, active)), tenant: `${service.id}:${service.revision}`, service, offerId: offer.id, active });
|
|
@@ -222,6 +228,8 @@ export class CommerceServer {
|
|
|
222
228
|
quote(service, offerId, caller, messageId, input) {
|
|
223
229
|
if (!this.activeRevision(service))
|
|
224
230
|
throw new CommerceError('retired_revision', 'This service revision is retired; recover original Tasks');
|
|
231
|
+
if (service.execution.type === 'skill')
|
|
232
|
+
this.options.execute.assertSkill(service);
|
|
225
233
|
const frozen = this.store.catalogHistory().find(entry => entry.service.id === service.id && entry.service.revision === service.revision);
|
|
226
234
|
if (!frozen)
|
|
227
235
|
throw new CommerceError('unknown_revision', 'Service revision is not registered');
|
|
@@ -233,8 +241,23 @@ export class CommerceServer {
|
|
|
233
241
|
const url = new URL(request.url);
|
|
234
242
|
if (url.origin !== new URL(this.options.origin).origin)
|
|
235
243
|
return Response.json({ error: 'invalid_host' }, { status: 421 });
|
|
236
|
-
if (request.method === 'GET' &&
|
|
237
|
-
return Response.json(
|
|
244
|
+
if (request.method === 'GET' && url.pathname === '/.well-known/agent-card.json' && this.options.execute.skillCard)
|
|
245
|
+
return Response.json(AgentCard.toJSON(this.options.execute.skillCard()), { headers: { 'Cache-Control': 'no-store' } });
|
|
246
|
+
if (url.pathname === '/a2a' && this.options.execute.skillCard)
|
|
247
|
+
return Response.json({ error: 'service_required', message: 'Select a named service offer to invoke a skill' }, { status: 400 });
|
|
248
|
+
if (request.method === 'GET' && ['/healthz', '/readyz'].includes(url.pathname)) {
|
|
249
|
+
let ready = !this.stopped;
|
|
250
|
+
if (url.pathname === '/readyz') {
|
|
251
|
+
ready = ready && !this.workerFailed;
|
|
252
|
+
try {
|
|
253
|
+
this.store.assertReady();
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
ready = false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return Response.json({ status: ready ? 'ready' : this.stopped ? 'draining' : 'unavailable' }, { status: ready ? 200 : 503, headers: { 'Cache-Control': 'no-store' } });
|
|
260
|
+
}
|
|
238
261
|
const proof = /^\/\.well-known\/envar\/([0-9a-f-]{36})$/.exec(url.pathname);
|
|
239
262
|
if (request.method === 'GET' && proof) {
|
|
240
263
|
const challenge = this.options.ownershipChallenges?.[proof[1]];
|
|
@@ -255,8 +278,6 @@ export class CommerceServer {
|
|
|
255
278
|
const caller = await this.options.authenticate(request);
|
|
256
279
|
if (!caller)
|
|
257
280
|
return Response.json({ error: 'authentication_required' }, { status: 401, headers: { 'WWW-Authenticate': 'Bearer', 'Cache-Control': 'no-store' } });
|
|
258
|
-
if (request.headers.get('A2A-Version') !== '1.0')
|
|
259
|
-
return Response.json({ error: 'A2A-Version 1.0 required' }, { status: 400 });
|
|
260
281
|
if (Number(request.headers.get('Content-Length') ?? 0) > 1024 * 1024)
|
|
261
282
|
return Response.json({ error: 'request_too_large' }, { status: 413 });
|
|
262
283
|
let size = 0;
|
|
@@ -283,6 +304,14 @@ export class CommerceServer {
|
|
|
283
304
|
throw new RequestMalformedError('A single JSONRPC object is required');
|
|
284
305
|
requestId = raw.id ?? null;
|
|
285
306
|
const params = raw.params;
|
|
307
|
+
// Parse only the bounded envelope to preserve its request ID. Reject these
|
|
308
|
+
// transport conditions before any quote, payment gate or Task dispatch.
|
|
309
|
+
if (request.headers.get('A2A-Version') !== '1.0')
|
|
310
|
+
throw new VersionNotSupportedError('This service supports A2A-Version 1.0');
|
|
311
|
+
if (request.headers.get('Content-Type')?.split(';', 1)[0]?.trim().toLowerCase() !== 'application/json')
|
|
312
|
+
throw new ContentTypeNotSupportedError('Send application/json to this JSON-RPC endpoint');
|
|
313
|
+
if (raw.method === 'SendMessage' && this.workerFailed)
|
|
314
|
+
return Response.json({ error: 'worker_unavailable' }, { status: 503, headers: { 'Cache-Control': 'no-store' } });
|
|
286
315
|
if (params && typeof params === 'object' && 'tenant' in params && params.tenant !== route.tenant)
|
|
287
316
|
return Response.json({ jsonrpc: '2.0', id: raw.id ?? null, error: { code: -32602, message: 'Invalid tenant' } }, { status: 200 });
|
|
288
317
|
let paymentHeaders = {};
|
|
@@ -299,6 +328,8 @@ export class CommerceServer {
|
|
|
299
328
|
if (!prior && !this.activeRevision(route.service))
|
|
300
329
|
throw new UnsupportedOperationError('This service revision is retired');
|
|
301
330
|
const order = prior ?? this.store.ensureQuote(this.quote(route.service, route.offerId, caller, message.message.messageId, input), input);
|
|
331
|
+
if (route.service.execution.type === 'skill' && !['confirmed', 'not_required'].includes(order.paymentState))
|
|
332
|
+
this.options.execute.assertSkill(route.service);
|
|
302
333
|
if (order.paymentState !== 'not_required') {
|
|
303
334
|
const gate = await this.options.paymentGate.handle(new Request(request.url, { method: 'POST', headers: request.headers, body }), order);
|
|
304
335
|
if ('response' in gate)
|
|
@@ -315,7 +346,7 @@ export class CommerceServer {
|
|
|
315
346
|
}
|
|
316
347
|
catch (error) {
|
|
317
348
|
const mapped = error instanceof CommerceError ? { code: -32000, message: error.message, data: { code: error.code } } : JsonRpcTransportHandler.mapToJSONRPCError(error);
|
|
318
|
-
return Response.json({ jsonrpc: '2.0', id: requestId, error: mapped }, { headers: { 'Cache-Control': 'no-store' } });
|
|
349
|
+
return Response.json({ jsonrpc: '2.0', id: requestId, error: mapped }, { headers: { 'A2A-Version': '1.0', 'Cache-Control': 'no-store' } });
|
|
319
350
|
}
|
|
320
351
|
}
|
|
321
352
|
async continueTask(params, input, service, offerId, context) {
|
|
@@ -393,14 +424,14 @@ export class CommerceServer {
|
|
|
393
424
|
return projected;
|
|
394
425
|
}
|
|
395
426
|
schedule() {
|
|
396
|
-
if (this.stopped)
|
|
427
|
+
if (this.stopped || this.workerFailed)
|
|
397
428
|
return;
|
|
398
429
|
if (this.running) {
|
|
399
430
|
this.kickPending = true;
|
|
400
431
|
return;
|
|
401
432
|
}
|
|
402
433
|
this.running = true;
|
|
403
|
-
void this.drain().catch(() => this.options.onError?.('worker_failure')).finally(() => { this.running = false; if (this.kickPending) {
|
|
434
|
+
void this.drain().catch(() => { this.workerFailed = true; this.options.onError?.('worker_failure'); }).finally(() => { this.running = false; if (this.kickPending) {
|
|
404
435
|
this.kickPending = false;
|
|
405
436
|
this.schedule();
|
|
406
437
|
} });
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface SetupBundle {
|
|
2
|
+
version: 1;
|
|
3
|
+
agentId: string;
|
|
4
|
+
agentName: string;
|
|
5
|
+
runtimeAgentId: string;
|
|
6
|
+
platformOrigin: string;
|
|
7
|
+
token: string;
|
|
8
|
+
expiresAt: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function parseSetupBundle(raw: unknown): SetupBundle;
|
|
11
|
+
export declare function prepareTextSkill(source: string, destination: string, name: string): void;
|
|
12
|
+
export declare function setupHermes(options: {
|
|
13
|
+
file: string;
|
|
14
|
+
directory?: string;
|
|
15
|
+
container?: string;
|
|
16
|
+
python?: string;
|
|
17
|
+
home?: string;
|
|
18
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/** Guided local installation. Platform input is data, never a shell command or wallet policy. */
|
|
2
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync, cpSync, mkdtempSync, renameSync, rmSync, } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { createInterface } from "node:readline/promises";
|
|
9
|
+
import { readInstalledSkills } from "./native-skills.js";
|
|
10
|
+
import { loadCommerceConfig, validateUrl } from "./config.js";
|
|
11
|
+
import { CommerceError } from "./types.js";
|
|
12
|
+
const ID = /^[a-z][a-z0-9-]{0,63}$/, CONTAINER = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/;
|
|
13
|
+
export function parseSetupBundle(raw) {
|
|
14
|
+
const v = raw;
|
|
15
|
+
if (!v ||
|
|
16
|
+
v.version !== 1 ||
|
|
17
|
+
!/^[0-9a-f-]{36}$/.test(v.agentId) ||
|
|
18
|
+
!ID.test(v.runtimeAgentId) ||
|
|
19
|
+
typeof v.agentName !== "string" ||
|
|
20
|
+
v.agentName.length > 120 ||
|
|
21
|
+
!/^envar_agent_[A-Za-z0-9_-]{32,100}$/.test(v.token) ||
|
|
22
|
+
!Number.isFinite(Date.parse(v.expiresAt)) ||
|
|
23
|
+
Date.parse(v.expiresAt) <= Date.now())
|
|
24
|
+
throw new CommerceError("setup_file", "Download a fresh Agent setup file from Envar");
|
|
25
|
+
const origin = validateUrl(v.platformOrigin);
|
|
26
|
+
if (origin.origin !== v.platformOrigin)
|
|
27
|
+
throw new CommerceError("setup_origin", "Use the platform HTTPS origin");
|
|
28
|
+
return v;
|
|
29
|
+
}
|
|
30
|
+
export function prepareTextSkill(source, destination, name) {
|
|
31
|
+
if (!ID.test(name) || existsSync(destination))
|
|
32
|
+
throw new CommerceError("setup_skill", "Choose a new exact skill name");
|
|
33
|
+
const stat = lstatSync(source);
|
|
34
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
35
|
+
throw new CommerceError("setup_skill", "Choose a real skill directory");
|
|
36
|
+
cpSync(source, destination, {
|
|
37
|
+
recursive: true,
|
|
38
|
+
filter: (path) => {
|
|
39
|
+
const item = lstatSync(path);
|
|
40
|
+
if (item.isSymbolicLink())
|
|
41
|
+
throw new CommerceError("setup_skill", "Skill symlinks are unsupported");
|
|
42
|
+
if (item.isFile() &&
|
|
43
|
+
!/\.(md|txt)$/i.test(path) &&
|
|
44
|
+
!path.endsWith("/LICENSE"))
|
|
45
|
+
throw new CommerceError("setup_skill", "Choose a text-only package without scripts or binaries");
|
|
46
|
+
return true;
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
const file = join(destination, "SKILL.md"), original = readFileSync(file, "utf8");
|
|
50
|
+
if (!original.startsWith("---\n") && !original.startsWith("---\r\n"))
|
|
51
|
+
throw new CommerceError("setup_skill", "Skill needs YAML frontmatter");
|
|
52
|
+
const adapted = original.replace(/^(---\r?\n)([\s\S]*?)(\r?\n---)/, (_all, open, front, close) => open +
|
|
53
|
+
front.replace(/^envar-runtime:.*\r?\n?/gm, "") +
|
|
54
|
+
"\nenvar-runtime: instruction-only" +
|
|
55
|
+
close);
|
|
56
|
+
writeFileSync(file, adapted +
|
|
57
|
+
"\n\n## Service execution\nUse the supplied buyer input as the complete product context. Bundled Markdown references are included in the task context. Return text in the A2A result. Do not browse, access files, run scripts, or invoke other skills. If information is missing, state what is missing; do not invent it.\n", { mode: 0o600 });
|
|
58
|
+
}
|
|
59
|
+
function privateJson(path, value) {
|
|
60
|
+
writeFileSync(path, JSON.stringify(value, null, 2) + "\n", {
|
|
61
|
+
mode: 0o600,
|
|
62
|
+
flag: "wx",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
export async function setupHermes(options) {
|
|
66
|
+
const file = resolve(options.file), info = lstatSync(file);
|
|
67
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > 16384)
|
|
68
|
+
throw new CommerceError("setup_file", "Use the downloaded setup JSON file");
|
|
69
|
+
const bundle = parseSetupBundle(JSON.parse(readFileSync(file, "utf8")));
|
|
70
|
+
if (options.container && !CONTAINER.test(options.container))
|
|
71
|
+
throw new CommerceError("setup_container", "Use a Docker container name");
|
|
72
|
+
if (!process.stdin.isTTY)
|
|
73
|
+
throw new CommerceError("setup_terminal", "Run setup in an interactive terminal to select skills");
|
|
74
|
+
chmodSync(file, 0o600);
|
|
75
|
+
const prompt = createInterface({
|
|
76
|
+
input: process.stdin,
|
|
77
|
+
output: process.stdout,
|
|
78
|
+
});
|
|
79
|
+
const ask = async (label, fallback = "") => (await prompt.question(`${label}${fallback ? ` [${fallback}]` : ""}: `)).trim() || fallback;
|
|
80
|
+
let staging;
|
|
81
|
+
try {
|
|
82
|
+
console.log(`\nConnect ${bundle.agentName} to ${bundle.platformOrigin}\nModel credentials and tasks remain on this machine. Start with free services; no wallet is created.\n`);
|
|
83
|
+
if ((await ask("Continue? (yes/no)", "no")).toLowerCase() !== "yes")
|
|
84
|
+
return;
|
|
85
|
+
const directory = resolve(options.directory ?? join(homedir(), ".envarpay", bundle.agentId));
|
|
86
|
+
if (existsSync(directory))
|
|
87
|
+
throw new CommerceError("setup_exists", "This Agent already has a local directory. Use its start script; never reset its ledger.");
|
|
88
|
+
let python = options.python;
|
|
89
|
+
const compatibility = 'import inspect;from run_agent import AIAgent;assert all(k in inspect.signature(AIAgent).parameters for k in ["skip_context_files","skip_memory","skip_background_review","ephemeral_system_prompt","enabled_toolsets"])';
|
|
90
|
+
if (!python && options.container) {
|
|
91
|
+
for (const candidate of [
|
|
92
|
+
"/opt/hermes-latest/bin/python",
|
|
93
|
+
"/opt/hermes/.venv/bin/python",
|
|
94
|
+
"python3",
|
|
95
|
+
]) {
|
|
96
|
+
try {
|
|
97
|
+
execFileSync("docker", ["exec", options.container, candidate, "-c", compatibility], { stdio: "pipe", timeout: 60000 });
|
|
98
|
+
python = candidate;
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* try the next compatible installed interpreter */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (!python)
|
|
106
|
+
throw new CommerceError("setup_runtime", "No compatible Hermes interpreter found in this container. Update Hermes or pass --python PATH.");
|
|
107
|
+
}
|
|
108
|
+
python ??= await ask("Hermes Python executable", join(homedir(), "hermes-agent", ".venv", "bin", "python"));
|
|
109
|
+
const probeSource = readFileSync(new URL("../../examples/hermes-setup-probe.py", import.meta.url), "utf8");
|
|
110
|
+
let probe;
|
|
111
|
+
try {
|
|
112
|
+
if (options.container)
|
|
113
|
+
execFileSync("docker", ["exec", options.container, python, "-c", compatibility], { stdio: "pipe", timeout: 60000 });
|
|
114
|
+
else
|
|
115
|
+
execFileSync(python, ["-c", compatibility], {
|
|
116
|
+
stdio: "pipe",
|
|
117
|
+
timeout: 60000,
|
|
118
|
+
});
|
|
119
|
+
const args = ["-c", probeSource, ...(options.home ? [options.home] : [])];
|
|
120
|
+
const data = options.container
|
|
121
|
+
? execFileSync("docker", ["exec", options.container, python, ...args], {
|
|
122
|
+
stdio: "pipe",
|
|
123
|
+
timeout: 30000,
|
|
124
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
125
|
+
})
|
|
126
|
+
: execFileSync(python, args, {
|
|
127
|
+
stdio: "pipe",
|
|
128
|
+
timeout: 30000,
|
|
129
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
130
|
+
});
|
|
131
|
+
probe = JSON.parse(data.toString());
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
throw new CommerceError("setup_model", "Could not read Hermes. Check the container/Python path and configure an OpenAI-compatible model in Hermes first.");
|
|
135
|
+
}
|
|
136
|
+
const candidates = probe.skills.filter((s) => s.textPackage && ID.test(s.name));
|
|
137
|
+
console.log(`Model: ${probe.model}\nInstalled text packages (review their instructions before selecting):`);
|
|
138
|
+
candidates.forEach((skill, i) => console.log(`${i + 1}. ${skill.name} — ${skill.description.replace(/[\r\n\x1b]/g, " ").slice(0, 160)}`));
|
|
139
|
+
console.log('Newly installed Skills will be discovered privately. Enable each one from the Envar service page after reviewing text-only compatibility.');
|
|
140
|
+
const selected = (await ask("Optional: enable existing text Skills now (comma separated, Enter to skip)"))
|
|
141
|
+
.split(",")
|
|
142
|
+
.map((s) => s.trim()).filter(Boolean);
|
|
143
|
+
if (selected.length > 32 ||
|
|
144
|
+
new Set(selected).size !== selected.length ||
|
|
145
|
+
selected.some((name) => !candidates.some((s) => s.name === name)))
|
|
146
|
+
throw new CommerceError("setup_skills", "Choose installed skill names from the list");
|
|
147
|
+
console.log("This adapter supplies buyer text and bundled Markdown references. Browser, script, file and API tools are disabled. Original Hermes skills are preserved; a service copy is created.");
|
|
148
|
+
if (selected.length &&
|
|
149
|
+
(await ask("Do these selected skills work with text input and text output only? (yes/no)", "no")).toLowerCase() !== "yes")
|
|
150
|
+
return;
|
|
151
|
+
const port = Number(await ask("Local service port", "4020"));
|
|
152
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535)
|
|
153
|
+
throw new CommerceError("setup_port", "Choose a local port from 1024 to 65535");
|
|
154
|
+
let origin;
|
|
155
|
+
const tunnelMode = await ask("Public HTTPS: use an existing ngrok session or your own reverse proxy? (ngrok/url)", "ngrok");
|
|
156
|
+
if (tunnelMode === "ngrok") {
|
|
157
|
+
const api = new URL(await ask("Local ngrok API", "http://127.0.0.1:4040"));
|
|
158
|
+
if (api.protocol !== "http:" ||
|
|
159
|
+
api.hostname !== "127.0.0.1" ||
|
|
160
|
+
api.username ||
|
|
161
|
+
api.password ||
|
|
162
|
+
api.pathname !== "/")
|
|
163
|
+
throw new CommerceError("setup_tunnel", "Use the local ngrok API on 127.0.0.1");
|
|
164
|
+
const name = `envarpay-${bundle.agentId}`, addr = `http://127.0.0.1:${port}`;
|
|
165
|
+
const inventory = await fetch(new URL("/api/tunnels", api), {
|
|
166
|
+
signal: AbortSignal.timeout(10000),
|
|
167
|
+
});
|
|
168
|
+
if (!inventory.ok)
|
|
169
|
+
throw new CommerceError("setup_tunnel", "Start ngrok first, or choose your own HTTPS URL");
|
|
170
|
+
const existing = (await inventory.json()).tunnels.find((t) => t.name === name);
|
|
171
|
+
if (existing) {
|
|
172
|
+
if (existing.config.addr !== addr)
|
|
173
|
+
throw new CommerceError("setup_tunnel", "The existing Agent tunnel uses another local port. Keep its original port.");
|
|
174
|
+
origin = existing.public_url;
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
const response = await fetch(new URL("/api/tunnels", api), {
|
|
178
|
+
method: "POST",
|
|
179
|
+
headers: { "Content-Type": "application/json" },
|
|
180
|
+
body: JSON.stringify({
|
|
181
|
+
name,
|
|
182
|
+
proto: "http",
|
|
183
|
+
addr,
|
|
184
|
+
schemes: ["https"],
|
|
185
|
+
inspect: false,
|
|
186
|
+
}),
|
|
187
|
+
signal: AbortSignal.timeout(15000),
|
|
188
|
+
});
|
|
189
|
+
if (!response.ok)
|
|
190
|
+
throw new CommerceError("setup_tunnel", "Could not create a dedicated tunnel. Check ngrok or choose your own HTTPS URL. Existing tunnels were preserved.");
|
|
191
|
+
origin = (await response.json()).public_url;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else if (tunnelMode === "url") {
|
|
195
|
+
origin = await ask(`Public HTTPS origin (forward to http://127.0.0.1:${port})`);
|
|
196
|
+
}
|
|
197
|
+
else
|
|
198
|
+
throw new CommerceError("setup_tunnel", "Choose ngrok or url");
|
|
199
|
+
const publicUrl = validateUrl(origin);
|
|
200
|
+
if (publicUrl.origin !== origin ||
|
|
201
|
+
(publicUrl.port && publicUrl.port !== "443"))
|
|
202
|
+
throw new CommerceError("setup_origin", "Use an HTTPS origin on port443");
|
|
203
|
+
// Only publish a complete new setup directory. An interrupted setup cannot strand
|
|
204
|
+
// the owner behind setup_exists or overwrite a prior ledger.
|
|
205
|
+
mkdirSync(resolve(directory, ".."), { recursive: true, mode: 0o700 });
|
|
206
|
+
staging = mkdtempSync(directory + ".setup-");
|
|
207
|
+
chmodSync(staging, 0o700);
|
|
208
|
+
const skillsDirectory = join(staging, "skills");
|
|
209
|
+
mkdirSync(skillsDirectory, { mode: 0o700 });
|
|
210
|
+
for (const name of selected) {
|
|
211
|
+
const skill = candidates.find((s) => s.name === name);
|
|
212
|
+
if (options.container) {
|
|
213
|
+
const incoming = join(staging, `incoming-${name}`);
|
|
214
|
+
execFileSync("docker", ["cp", `${options.container}:${skill.path}`, incoming], { stdio: "pipe", timeout: 30000 });
|
|
215
|
+
prepareTextSkill(incoming, join(skillsDirectory, name), name);
|
|
216
|
+
rmSync(incoming, { recursive: true });
|
|
217
|
+
}
|
|
218
|
+
else
|
|
219
|
+
prepareTextSkill(skill.path, join(skillsDirectory, name), name);
|
|
220
|
+
}
|
|
221
|
+
readInstalledSkills(skillsDirectory, [], origin + "/.well-known/agent-card.json");
|
|
222
|
+
const access = randomBytes(36).toString("base64url");
|
|
223
|
+
let apiKeyFile = probe.apiKeyFile;
|
|
224
|
+
if (!apiKeyFile) {
|
|
225
|
+
if (options.container) {
|
|
226
|
+
apiKeyFile = join(probe.home, "envarpay-secrets", bundle.agentId, "model.key");
|
|
227
|
+
// Key bytes travel through stdin, never process arguments or console output.
|
|
228
|
+
const writeKey = "import sys,os;from pathlib import Path;p=Path(sys.argv[1]);p.parent.mkdir(parents=True,exist_ok=True,mode=0o700);fd=os.open(p,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600);os.write(fd,sys.stdin.buffer.read());os.close(fd)";
|
|
229
|
+
try {
|
|
230
|
+
execFileSync("docker", [
|
|
231
|
+
"exec",
|
|
232
|
+
"-i",
|
|
233
|
+
options.container,
|
|
234
|
+
python,
|
|
235
|
+
"-c",
|
|
236
|
+
writeKey,
|
|
237
|
+
apiKeyFile,
|
|
238
|
+
], {
|
|
239
|
+
input: probe.apiKey,
|
|
240
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
241
|
+
timeout: 15000,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
throw new CommerceError("setup_model", "Could not save the local model credential. Inspect the Agent-specific envarpay-secrets directory before retrying.");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
apiKeyFile = join(directory, "model.key");
|
|
250
|
+
writeFileSync(join(staging, "model.key"), probe.apiKey, {
|
|
251
|
+
mode: 0o600,
|
|
252
|
+
flag: "wx",
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const stateDirectory = join(directory, "tasks");
|
|
257
|
+
mkdirSync(join(staging, "tasks"), { mode: 0o700 });
|
|
258
|
+
const dockerStateDirectory = options.container
|
|
259
|
+
? join(probe.home, "envarpay-tasks", bundle.agentId)
|
|
260
|
+
: undefined;
|
|
261
|
+
const config = loadCommerceConfig({
|
|
262
|
+
configVersion: 2,
|
|
263
|
+
agent: { id: bundle.runtimeAgentId, name: bundle.agentName },
|
|
264
|
+
paymentProfiles: {},
|
|
265
|
+
services: [],
|
|
266
|
+
});
|
|
267
|
+
privateJson(join(staging, "seller.json"), config);
|
|
268
|
+
privateJson(join(staging, "credentials.json"), {
|
|
269
|
+
callers: { [access]: "owner" },
|
|
270
|
+
upstreams: {},
|
|
271
|
+
skills: {
|
|
272
|
+
framework: "hermes",
|
|
273
|
+
discovery: { hermesHome: probe.home, enabledFromWeb: true },
|
|
274
|
+
skillsDirectory: join(directory, "skills"),
|
|
275
|
+
freeSkills: [],
|
|
276
|
+
stateDirectory,
|
|
277
|
+
python,
|
|
278
|
+
model: probe.model,
|
|
279
|
+
baseUrl: probe.baseUrl,
|
|
280
|
+
apiKeyFile,
|
|
281
|
+
...(options.container
|
|
282
|
+
? { dockerContainer: options.container, dockerStateDirectory }
|
|
283
|
+
: {}),
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
writeFileSync(join(staging, "envar.token"), bundle.token, {
|
|
287
|
+
mode: 0o600,
|
|
288
|
+
flag: "wx",
|
|
289
|
+
});
|
|
290
|
+
privateJson(join(staging, "envar.json"), {
|
|
291
|
+
policy: {
|
|
292
|
+
enabled: true,
|
|
293
|
+
platformOrigin: bundle.platformOrigin,
|
|
294
|
+
agentId: bundle.agentId,
|
|
295
|
+
runtimeAgentId: bundle.runtimeAgentId,
|
|
296
|
+
acceptUpdates: true,
|
|
297
|
+
allowedServices: selected,
|
|
298
|
+
allowedUpstreamOrigins: [origin],
|
|
299
|
+
allowedX402: [],
|
|
300
|
+
allowedMppAccounts: [],
|
|
301
|
+
},
|
|
302
|
+
stateDirectory: join(directory, "envar-state"),
|
|
303
|
+
configDirectory: join(directory, "envar-configs"),
|
|
304
|
+
});
|
|
305
|
+
privateJson(join(staging, "connection.json"), {
|
|
306
|
+
version: 1,
|
|
307
|
+
agentId: bundle.agentId,
|
|
308
|
+
url: origin + "/.well-known/agent-card.json",
|
|
309
|
+
authToken: access,
|
|
310
|
+
});
|
|
311
|
+
const cli = fileURLToPath(new URL("./cli.js", import.meta.url));
|
|
312
|
+
const args = [
|
|
313
|
+
cli,
|
|
314
|
+
"serve",
|
|
315
|
+
"--config",
|
|
316
|
+
join(directory, "seller.json"),
|
|
317
|
+
"--credentials",
|
|
318
|
+
join(directory, "credentials.json"),
|
|
319
|
+
"--state",
|
|
320
|
+
join(directory, "seller.sqlite3"),
|
|
321
|
+
"--origin",
|
|
322
|
+
origin,
|
|
323
|
+
"--host",
|
|
324
|
+
"127.0.0.1",
|
|
325
|
+
"--port",
|
|
326
|
+
String(port),
|
|
327
|
+
"--envar-config",
|
|
328
|
+
join(directory, "envar.json"),
|
|
329
|
+
"--envar-credentials",
|
|
330
|
+
join(directory, "envar.token"),
|
|
331
|
+
];
|
|
332
|
+
const start = join(directory, "start.mjs");
|
|
333
|
+
writeFileSync(join(staging, "start.mjs"), `import {spawn} from 'node:child_process';\nconst child=spawn(${JSON.stringify(process.execPath)},${JSON.stringify(args)},{stdio:'inherit'});\nfor(const signal of ['SIGTERM','SIGINT'])process.on(signal,()=>child.kill(signal));\nchild.on('exit',code=>process.exit(code??1));\n`, { mode: 0o700, flag: "wx" });
|
|
334
|
+
writeFileSync(join(staging, "README.md"), `# ${bundle.agentName}\n\nStart: node ${JSON.stringify(start)}\n\nKeep the terminal, Hermes, and HTTPS tunnel running. Import connection.json in the Envar setup page, then check the connection. Choose a skill and save a free service. Request publication; this process applies the selected service automatically. Return to Envar and check/publish.\n\nOnly free service updates are authorized. Receiving and payment setup is separate. Platform token expires ${bundle.expiresAt}. Keep the original files and ledger when restarting.\n`, { mode: 0o600, flag: "wx" });
|
|
335
|
+
renameSync(staging, directory);
|
|
336
|
+
staging = undefined;
|
|
337
|
+
console.log(`\nReady. Start the local service:\n${process.execPath} ${JSON.stringify(start)}\n\nThen import this connection file in Envar:\n${join(directory, "connection.json")}\n\nKeep the service and HTTPS tunnel online. No task or payment was performed.`);
|
|
338
|
+
}
|
|
339
|
+
finally {
|
|
340
|
+
prompt.close();
|
|
341
|
+
if (staging)
|
|
342
|
+
rmSync(staging, { recursive: true, force: true });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { AgentCard, Task } from '@a2a-js/sdk';
|
|
2
|
+
import { type Input } from './types.js';
|
|
3
|
+
import type { ExecuteOrder } from './server.js';
|
|
4
|
+
import type { OrderRecord } from './store.js';
|
|
5
|
+
/** The name is the service ID. The digest pins the installed skill version. */
|
|
6
|
+
export interface SkillDescriptor {
|
|
7
|
+
name: string;
|
|
8
|
+
digest: string;
|
|
9
|
+
cardUrl: string;
|
|
10
|
+
access: 'free' | 'paid';
|
|
11
|
+
description?: string;
|
|
12
|
+
}
|
|
13
|
+
/** The Agent-owned adapter may expose one private, authenticated A2A Card per skill. */
|
|
14
|
+
export declare function createSkillCard(descriptor: SkillDescriptor, interfaceUrl: string): AgentCard;
|
|
15
|
+
/** In-process authority passed only after the seller has confirmed payment or a free offer. */
|
|
16
|
+
export declare class SkillGrant {
|
|
17
|
+
#private;
|
|
18
|
+
readonly skillName: string;
|
|
19
|
+
readonly skillDigest: string;
|
|
20
|
+
readonly orderId: string;
|
|
21
|
+
readonly caller: string;
|
|
22
|
+
readonly messageId: string;
|
|
23
|
+
readonly inputDigest: string;
|
|
24
|
+
constructor(order: OrderRecord, descriptor: SkillDescriptor, freeSkills: ReadonlySet<string>, messageId?: string);
|
|
25
|
+
/** A purchased task may use its own skill and declared free skills, never another paid skill. */
|
|
26
|
+
require(skillName: string): void;
|
|
27
|
+
}
|
|
28
|
+
export interface SkillInvocation {
|
|
29
|
+
orderId: string;
|
|
30
|
+
messageId: string;
|
|
31
|
+
taskId?: string;
|
|
32
|
+
contextId?: string;
|
|
33
|
+
grant: SkillGrant;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* An Agent-owned adapter. It must route by the passed exact skill name and use
|
|
37
|
+
* grant.require() for every nested skill invocation. Raw paid skills stay private.
|
|
38
|
+
*/
|
|
39
|
+
export interface SkillRuntime {
|
|
40
|
+
list(): readonly SkillDescriptor[];
|
|
41
|
+
send(skillName: string, input: Input, invocation: SkillInvocation): Promise<Task>;
|
|
42
|
+
getTask(skillName: string, taskId: string, invocation: SkillInvocation): Promise<Task>;
|
|
43
|
+
}
|
|
44
|
+
export interface SkillHandler {
|
|
45
|
+
descriptor: SkillDescriptor;
|
|
46
|
+
send(input: Input, invocation: SkillInvocation & {
|
|
47
|
+
callSkill(name: string, input: Input): Promise<Task>;
|
|
48
|
+
}): Promise<Task>;
|
|
49
|
+
getTask(taskId: string, invocation: SkillInvocation): Promise<Task>;
|
|
50
|
+
}
|
|
51
|
+
/** Reference in-process adapter: every nested skill call passes the same purchase gate. */
|
|
52
|
+
export declare function createSkillRegistry(handlers: readonly SkillHandler[]): SkillRuntime;
|
|
53
|
+
/** External buyer/seller communication remains ordinary A2A; only local skill dispatch changes. */
|
|
54
|
+
export declare function skillScopedExecutor(runtime: SkillRuntime, signal?: AbortSignal): ExecuteOrder;
|