@envarai/envarpay 0.2.0-alpha.10 → 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/dist/commerce/cli.js +19 -4
- 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 +1 -0
- package/dist/commerce/envar-runtime.js +7 -0
- package/dist/commerce/envar.d.ts +5 -0
- package/dist/commerce/envar.js +13 -0
- package/dist/commerce/native-skills.d.ts +4 -0
- package/dist/commerce/native-skills.js +3 -5
- package/dist/commerce/setup.js +7 -7
- package/examples/hermes-skill-inventory.py +107 -0
- package/package.json +1 -1
package/dist/commerce/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync, lstatSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { readFileSync, lstatSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs';
|
|
3
3
|
import { parseArgs } from 'node:util';
|
|
4
4
|
import { createRequire } from 'node:module';
|
|
5
5
|
import { randomBytes } from 'node:crypto';
|
|
@@ -22,6 +22,8 @@ import { CommerceError } from './types.js';
|
|
|
22
22
|
import { createNativeSkillExecutor } from './native-skills.js';
|
|
23
23
|
import { inspectLedger, inspectLocalAlerts } from './operations.js';
|
|
24
24
|
import { setupHermes } from './setup.js';
|
|
25
|
+
import { SkillDiscovery } from './discovery.js';
|
|
26
|
+
import { upgradeDiscovery, configurePayments } from './configure.js';
|
|
25
27
|
const { values, positionals } = parseArgs({ allowPositionals: true, options: {
|
|
26
28
|
config: { type: 'string' }, service: { type: 'string' }, offer: { type: 'string' }, origin: { type: 'string' },
|
|
27
29
|
version: { type: 'boolean' }, help: { type: 'boolean', short: 'h' }, state: { type: 'string' }, credentials: { type: 'string' }, host: { type: 'string' },
|
|
@@ -29,7 +31,7 @@ const { values, positionals } = parseArgs({ allowPositionals: true, options: {
|
|
|
29
31
|
after: { type: 'string' }, limit: { type: 'string' }, 'refund-review': { type: 'boolean' }, alerts: { type: 'boolean' }, 'fail-on-alert': { type: 'boolean' }, 'integration-state': { type: 'string' }, 'no-integration': { type: 'boolean' },
|
|
30
32
|
file: { type: 'string' }, 'docker-container': { type: 'string' }, python: { type: 'string' }, 'hermes-home': { type: 'string' },
|
|
31
33
|
} });
|
|
32
|
-
const usage = 'envarpay setup --file envar-setup.json [--docker-container NAME | --python PATH] [--hermes-home PATH] [--directory DIR]\ninit --directory DIR | inspect --state DB [--after ROW --limit 100 --refund-review] [--alerts --config buyer-policy.json --integration-state DB/--no-integration --fail-on-alert] | validate --config seller.json | card --config seller.json --service ID --offer ID --origin URL | serve/buyer-serve --config FILE --credentials FILE --state DB --origin URL';
|
|
34
|
+
const usage = 'envarpay upgrade --directory DIR [--hermes-home PATH] | payments --directory DIR --file receiving.json\nenvarpay setup --file envar-setup.json [--docker-container NAME | --python PATH] [--hermes-home PATH] [--directory DIR]\ninit --directory DIR | inspect --state DB [--after ROW --limit 100 --refund-review] [--alerts --config buyer-policy.json --integration-state DB/--no-integration --fail-on-alert] | validate --config seller.json | card --config seller.json --service ID --offer ID --origin URL | serve/buyer-serve --config FILE --credentials FILE --state DB --origin URL';
|
|
33
35
|
const closers = [];
|
|
34
36
|
function privateFile(path) {
|
|
35
37
|
const stat = lstatSync(path);
|
|
@@ -89,6 +91,14 @@ async function run() {
|
|
|
89
91
|
return;
|
|
90
92
|
}
|
|
91
93
|
const command = positionals[0];
|
|
94
|
+
if (command === 'upgrade' && values.directory) {
|
|
95
|
+
upgradeDiscovery(values.directory, values['hermes-home']);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (command === 'payments' && values.directory && values.file) {
|
|
99
|
+
await configurePayments(values.directory, values.file);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
92
102
|
if (command === 'setup') {
|
|
93
103
|
if (!values.file)
|
|
94
104
|
throw new CommerceError('setup_file', 'Download the setup file from your Agent service page and pass --file PATH');
|
|
@@ -218,6 +228,10 @@ async function run() {
|
|
|
218
228
|
syncSettings = JSON.parse(privateFile(values['envar-config']).toString('utf8'));
|
|
219
229
|
if (!syncSettings?.policy)
|
|
220
230
|
throw new CommerceError('envar_configuration', 'Envar settings must include an explicit local policy');
|
|
231
|
+
if (secrets.skills?.discovery?.enabledFromWeb) {
|
|
232
|
+
const installed = readdirSync(secrets.skills.skillsDirectory, { withFileTypes: true }).filter(e => e.isDirectory() && !e.isSymbolicLink() && /^[a-z][a-z0-9-]{0,63}$/.test(e.name)).map(e => e.name);
|
|
233
|
+
syncSettings.policy.allowedServices = [...new Set([...syncSettings.policy.allowedServices, ...installed])];
|
|
234
|
+
}
|
|
221
235
|
const tokenFile = resolve(values['envar-credentials']);
|
|
222
236
|
privateFile(tokenFile);
|
|
223
237
|
integration = new EnvarIntegration({ policy: syncSettings.policy, stateDirectory: syncSettings.stateDirectory ?? join(dirname(values.state), 'envar-state'), configDirectory: syncSettings.configDirectory ?? join(dirname(values.state), 'envar-configs'), token: () => privateFile(tokenFile).toString('utf8').trim() });
|
|
@@ -248,7 +262,7 @@ async function run() {
|
|
|
248
262
|
if (!secrets.payers || !secrets.rpcUrls)
|
|
249
263
|
throw new CommerceError('x402_configuration', 'x402 requires explicit caller-payer bindings and network RPCs');
|
|
250
264
|
const gate = new X402Gate({ store, vault: vault, payerFor: caller => secrets.payers?.[caller], verifyReceipt: evmReceiptVerifier(secrets.rpcUrls), ...evmSettlementRecovery(secrets.rpcUrls) });
|
|
251
|
-
await gate.check(Object.values(config.paymentProfiles).filter((p) => p.adapter === 'x402'));
|
|
265
|
+
await gate.check([...Object.values(config.paymentProfiles).filter((p) => p.adapter === 'x402'), ...(secrets.receivingProfiles ?? [])]);
|
|
252
266
|
gates.x402 = gate;
|
|
253
267
|
}
|
|
254
268
|
if (protocols.has('mpp')) {
|
|
@@ -265,6 +279,7 @@ async function run() {
|
|
|
265
279
|
stripeRecipientFor = ref => gate.merchantRecipient(ref);
|
|
266
280
|
}
|
|
267
281
|
const nativeSkills = secrets.skills ? createNativeSkillExecutor(secrets.skills, values.origin, config.agent.name) : undefined;
|
|
282
|
+
const discovery = secrets.skills?.discovery?.enabledFromWeb ? new SkillDiscovery(secrets.skills, secrets.skills.discovery, values.origin) : undefined;
|
|
268
283
|
if (nativeSkills)
|
|
269
284
|
closers.push(() => nativeSkills.close());
|
|
270
285
|
const upstreams = new PinnedUpstreams(join(dirname(values.state), 'upstream-bindings.json'), secrets.upstreams ?? {}, secrets.upstreamInputEncoding, fetch, controller.signal, nativeSkills?.execute);
|
|
@@ -292,7 +307,7 @@ async function run() {
|
|
|
292
307
|
await new Promise(r => setTimeout(r, 50)); });
|
|
293
308
|
let connection;
|
|
294
309
|
if (integration) {
|
|
295
|
-
connection = new EnvarSellerRuntime({ integration, server, upstreams, pollIntervalMs: syncSettings?.pollIntervalMs, validateConfig: validateAdapters, onError: code => console.error(JSON.stringify({ code, integration: 'envar' })) });
|
|
310
|
+
connection = new EnvarSellerRuntime({ integration, server, upstreams, pollIntervalMs: syncSettings?.pollIntervalMs, validateConfig: validateAdapters, beforeSync: discovery ? () => discovery.sync(integration, [...Object.values(server.config.paymentProfiles).filter(p => p.adapter === 'x402'), ...(secrets.receivingProfiles ?? [])].map(p => ({ network: p.network, pay_to: p.payTo, facilitator_url: p.facilitatorUrl }))) : undefined, onError: code => console.error(JSON.stringify({ code, integration: 'envar' })) });
|
|
296
311
|
closers.push(() => connection.stop());
|
|
297
312
|
}
|
|
298
313
|
const http = listenCommerce(server, values.origin, values.host ?? '127.0.0.1', port(4020));
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, lstatSync, existsSync, renameSync, } from "node:fs";
|
|
2
|
+
import { join, resolve, dirname } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
|
+
import { CommerceError } from "./types.js";
|
|
7
|
+
import { validateUrl, loadCommerceConfig } from "./config.js";
|
|
8
|
+
function read(path) {
|
|
9
|
+
const s = lstatSync(path);
|
|
10
|
+
if (!s.isFile() ||
|
|
11
|
+
s.isSymbolicLink() ||
|
|
12
|
+
s.size > 1024 * 1024 ||
|
|
13
|
+
s.mode & 0o077)
|
|
14
|
+
throw new CommerceError("private_file", "Use an owner-only configuration file");
|
|
15
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
16
|
+
}
|
|
17
|
+
function save(path, value) {
|
|
18
|
+
const temp = path + ".tmp";
|
|
19
|
+
writeFileSync(temp, JSON.stringify(value, null, 2) + "\n", {
|
|
20
|
+
mode: 0o600,
|
|
21
|
+
flag: "wx",
|
|
22
|
+
});
|
|
23
|
+
renameSync(temp, path);
|
|
24
|
+
}
|
|
25
|
+
export function refreshLauncher(directory) {
|
|
26
|
+
const root = resolve(directory), connection = read(join(root, "connection.json")), settingsPath = join(root, "runtime.json");
|
|
27
|
+
// alpha.10 start scripts recorded the port in generated source. Parse JSON argv only; never evaluate code.
|
|
28
|
+
let port;
|
|
29
|
+
if (existsSync(settingsPath))
|
|
30
|
+
port = read(settingsPath).port;
|
|
31
|
+
else {
|
|
32
|
+
const old = readFileSync(join(root, "start.mjs"), "utf8");
|
|
33
|
+
const array = /spawn\([^\n]*?,(\[[^\n]+\]),\{stdio:/.exec(old)?.[1];
|
|
34
|
+
if (!array)
|
|
35
|
+
throw new CommerceError("setup_upgrade", "Cannot infer original start settings. Use serve with original files.");
|
|
36
|
+
const args = JSON.parse(array);
|
|
37
|
+
port = Number(args[args.indexOf("--port") + 1]);
|
|
38
|
+
}
|
|
39
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535)
|
|
40
|
+
throw new CommerceError("setup_port", "Invalid local service port");
|
|
41
|
+
const args = [
|
|
42
|
+
fileURLToPath(new URL("./cli.js", import.meta.url)),
|
|
43
|
+
"serve",
|
|
44
|
+
"--config",
|
|
45
|
+
join(root, "seller.json"),
|
|
46
|
+
"--credentials",
|
|
47
|
+
join(root, "credentials.json"),
|
|
48
|
+
"--state",
|
|
49
|
+
join(root, "seller.sqlite3"),
|
|
50
|
+
"--origin",
|
|
51
|
+
new URL(connection.url).origin,
|
|
52
|
+
"--host",
|
|
53
|
+
"127.0.0.1",
|
|
54
|
+
"--port",
|
|
55
|
+
String(port),
|
|
56
|
+
"--envar-config",
|
|
57
|
+
join(root, "envar.json"),
|
|
58
|
+
"--envar-credentials",
|
|
59
|
+
join(root, "envar.token"),
|
|
60
|
+
];
|
|
61
|
+
save(settingsPath, { port });
|
|
62
|
+
const start = join(root, "start.mjs");
|
|
63
|
+
writeFileSync(start, `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 });
|
|
64
|
+
}
|
|
65
|
+
export function upgradeDiscovery(directory, home) {
|
|
66
|
+
const root = resolve(directory), file = join(root, "credentials.json"), credentials = read(file);
|
|
67
|
+
if (credentials.skills?.framework !== "hermes")
|
|
68
|
+
throw new CommerceError("setup_framework", "This discovery setup supports Hermes");
|
|
69
|
+
const skill = credentials.skills;
|
|
70
|
+
const hermesHome = home ??
|
|
71
|
+
skill.discovery?.hermesHome ??
|
|
72
|
+
(skill.dockerStateDirectory
|
|
73
|
+
? dirname(dirname(skill.dockerStateDirectory))
|
|
74
|
+
: undefined);
|
|
75
|
+
if (!hermesHome)
|
|
76
|
+
throw new CommerceError("setup_home", "Pass --hermes-home with your existing Hermes profile directory");
|
|
77
|
+
credentials.skills.discovery = { hermesHome, enabledFromWeb: true };
|
|
78
|
+
refreshLauncher(root);
|
|
79
|
+
save(file, credentials);
|
|
80
|
+
console.log(`Discovery enabled. Stop the original service with Ctrl+C, then restart:\n${process.execPath} ${JSON.stringify(join(root, "start.mjs"))}\nInstalled Skills will appear privately in Envar. No service is automatically enabled or published.`);
|
|
81
|
+
}
|
|
82
|
+
export async function configurePayments(directory, file) {
|
|
83
|
+
if (!process.stdin.isTTY)
|
|
84
|
+
throw new CommerceError("setup_terminal", "Use an interactive terminal to approve receiving configuration");
|
|
85
|
+
const root = resolve(directory), bundle = JSON.parse(readFileSync(file, "utf8"));
|
|
86
|
+
const cfg = read(join(root, "seller.json")), auth = read(join(root, "credentials.json")), sync = read(join(root, "envar.json"));
|
|
87
|
+
const assets = {
|
|
88
|
+
"eip155:8453": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
|
89
|
+
"eip155:84532": "0x036cbd53842c5426634e7929541ec2318f3dcf7e",
|
|
90
|
+
};
|
|
91
|
+
if (bundle.version !== 1 ||
|
|
92
|
+
bundle.agentId !== sync.policy.agentId ||
|
|
93
|
+
!assets[bundle.network] ||
|
|
94
|
+
!/^0x[0-9a-fA-F]{40}$/.test(bundle.payTo) ||
|
|
95
|
+
BigInt(bundle.payTo) === 0n)
|
|
96
|
+
throw new CommerceError("payment_setup", "Download receiving settings for this Agent");
|
|
97
|
+
const prompt = createInterface({
|
|
98
|
+
input: process.stdin,
|
|
99
|
+
output: process.stdout,
|
|
100
|
+
});
|
|
101
|
+
const ask = async (text, fallback = "") => (await prompt.question(`${text}${fallback ? ` [${fallback}]` : ""}: `)).trim() || fallback;
|
|
102
|
+
try {
|
|
103
|
+
console.log(`Receiving account: ${bundle.payTo}\nNetwork: ${bundle.network === "eip155:8453" ? "Base mainnet" : "Base Sepolia testnet"}\nAsset: official USDC. This enables receiving only; it does not send money or create a signing key.`);
|
|
104
|
+
if ((await ask("Approve this receiving account? (yes/no)", "no")) !== "yes")
|
|
105
|
+
return;
|
|
106
|
+
const facilitator = validateUrl(await ask("x402 facilitator HTTPS URL", bundle.facilitatorUrl ?? ""));
|
|
107
|
+
const rpc = validateUrl(await ask("Network RPC HTTPS URL", bundle.network === "eip155:8453"
|
|
108
|
+
? "https://mainnet.base.org"
|
|
109
|
+
: "https://sepolia.base.org"));
|
|
110
|
+
const payer = await ask("Authorized buyer wallet address");
|
|
111
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(payer) || BigInt(payer) === 0n)
|
|
112
|
+
throw new CommerceError("payment_setup", "Use the buyer wallet address for this service access credential");
|
|
113
|
+
const caller = "buyer-" + payer.slice(2).toLowerCase(), token = randomBytes(36).toString("base64url");
|
|
114
|
+
auth.callers = { ...auth.callers, [token]: caller };
|
|
115
|
+
auth.payers = { ...auth.payers, [caller]: payer };
|
|
116
|
+
auth.rpcUrls = { ...auth.rpcUrls, [bundle.network]: rpc.href };
|
|
117
|
+
auth.vaultKeyFile ??= join(root, "vault.key");
|
|
118
|
+
if (!existsSync(auth.vaultKeyFile))
|
|
119
|
+
writeFileSync(auth.vaultKeyFile, randomBytes(32), {
|
|
120
|
+
mode: 0o600,
|
|
121
|
+
flag: "wx",
|
|
122
|
+
});
|
|
123
|
+
const profile = {
|
|
124
|
+
adapter: "x402",
|
|
125
|
+
scheme: "exact",
|
|
126
|
+
network: bundle.network,
|
|
127
|
+
asset: assets[bundle.network],
|
|
128
|
+
payTo: bundle.payTo,
|
|
129
|
+
facilitatorUrl: facilitator.href,
|
|
130
|
+
};
|
|
131
|
+
loadCommerceConfig({
|
|
132
|
+
...cfg,
|
|
133
|
+
paymentProfiles: { ...cfg.paymentProfiles, "setup-receiving": profile },
|
|
134
|
+
});
|
|
135
|
+
auth.receivingProfiles = [
|
|
136
|
+
...(auth.receivingProfiles ?? []).filter((p) => p.network !== bundle.network),
|
|
137
|
+
profile,
|
|
138
|
+
];
|
|
139
|
+
sync.policy.allowedX402 = [
|
|
140
|
+
...sync.policy.allowedX402.filter((p) => p.network !== bundle.network),
|
|
141
|
+
{
|
|
142
|
+
network: bundle.network,
|
|
143
|
+
asset: assets[bundle.network],
|
|
144
|
+
payTo: bundle.payTo.toLowerCase(),
|
|
145
|
+
facilitatorOrigin: facilitator.origin,
|
|
146
|
+
},
|
|
147
|
+
];
|
|
148
|
+
const accessFile = join(root, caller + ".token");
|
|
149
|
+
writeFileSync(accessFile, token + "\n", { mode: 0o600 });
|
|
150
|
+
save(join(root, "credentials.json"), auth);
|
|
151
|
+
save(join(root, "envar.json"), sync);
|
|
152
|
+
refreshLauncher(root);
|
|
153
|
+
console.log(`Receiving configured for ${cfg.agent.name}. Restart the local service using start.mjs, then refresh Envar.\nBuyer service-access token: ${accessFile}\nGive this file only to the authorized buyer. Original wallets, budgets and ledgers were preserved. No payment performed.`);
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
prompt.close();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { NativeSkillSettings } from "./native-skills.js";
|
|
2
|
+
import type { EnvarIntegration } from "./envar.js";
|
|
3
|
+
export interface DiscoverySettings {
|
|
4
|
+
hermesHome: string;
|
|
5
|
+
enabledFromWeb: true;
|
|
6
|
+
}
|
|
7
|
+
export declare class SkillDiscovery {
|
|
8
|
+
private settings;
|
|
9
|
+
private discovery;
|
|
10
|
+
private origin;
|
|
11
|
+
private enabled;
|
|
12
|
+
private cached;
|
|
13
|
+
private nextScan;
|
|
14
|
+
private state;
|
|
15
|
+
readonly path: string;
|
|
16
|
+
constructor(settings: NativeSkillSettings, discovery: DiscoverySettings, origin: string);
|
|
17
|
+
private scan;
|
|
18
|
+
sync(integration: EnvarIntegration, payments: {
|
|
19
|
+
network: string;
|
|
20
|
+
pay_to: string;
|
|
21
|
+
facilitator_url: string;
|
|
22
|
+
}[]): Promise<void>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync, mkdtempSync, mkdirSync, renameSync, rmSync, readdirSync, lstatSync, } from "node:fs";
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
import { prepareTextSkill } from "./setup.js";
|
|
8
|
+
import { readInstalledSkills } from "./native-skills.js";
|
|
9
|
+
const exec = promisify(execFile);
|
|
10
|
+
function packageDigest(root) {
|
|
11
|
+
const files = [];
|
|
12
|
+
let size = 0;
|
|
13
|
+
function walk(folder, prefix = '') {
|
|
14
|
+
if (prefix.split('/').length > 12)
|
|
15
|
+
throw Error('skill_size');
|
|
16
|
+
for (const name of readdirSync(folder).sort()) {
|
|
17
|
+
const path = join(folder, name), stat = lstatSync(path);
|
|
18
|
+
if (stat.isSymbolicLink())
|
|
19
|
+
throw Error('skill_symlink');
|
|
20
|
+
if (stat.isDirectory()) {
|
|
21
|
+
walk(path, prefix + name + '/');
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (!stat.isFile() || stat.size > 512 * 1024 || (size += stat.size) > 1024 * 1024 || files.length >= 128)
|
|
25
|
+
throw Error('skill_size');
|
|
26
|
+
files.push([prefix + name, createHash('sha256').update(readFileSync(path)).digest('hex')]);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
walk(root);
|
|
30
|
+
files.sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
|
|
31
|
+
const json = JSON.stringify(files).replace(/[\u007f-\uffff]/g, c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
|
|
32
|
+
return createHash('sha256').update(json).digest('hex');
|
|
33
|
+
}
|
|
34
|
+
export class SkillDiscovery {
|
|
35
|
+
settings;
|
|
36
|
+
discovery;
|
|
37
|
+
origin;
|
|
38
|
+
enabled;
|
|
39
|
+
cached = [];
|
|
40
|
+
nextScan = 0;
|
|
41
|
+
state = "ready";
|
|
42
|
+
path;
|
|
43
|
+
constructor(settings, discovery, origin) {
|
|
44
|
+
this.settings = settings;
|
|
45
|
+
this.discovery = discovery;
|
|
46
|
+
this.origin = origin;
|
|
47
|
+
this.path = join(settings.skillsDirectory, ".source-digests.json");
|
|
48
|
+
this.enabled = existsSync(this.path)
|
|
49
|
+
? JSON.parse(readFileSync(this.path, "utf8"))
|
|
50
|
+
: {};
|
|
51
|
+
}
|
|
52
|
+
async scan() {
|
|
53
|
+
const code = readFileSync(new URL("../../examples/hermes-skill-inventory.py", import.meta.url), "utf8");
|
|
54
|
+
const args = ["-c", code, this.discovery.hermesHome];
|
|
55
|
+
const { stdout } = this.settings.dockerContainer
|
|
56
|
+
? await exec("docker", [
|
|
57
|
+
"exec",
|
|
58
|
+
this.settings.dockerContainer,
|
|
59
|
+
this.settings.python,
|
|
60
|
+
...args,
|
|
61
|
+
], { timeout: 30000, maxBuffer: 1024 * 1024 })
|
|
62
|
+
: await exec(this.settings.python, args, {
|
|
63
|
+
timeout: 30000,
|
|
64
|
+
maxBuffer: 1024 * 1024,
|
|
65
|
+
});
|
|
66
|
+
const list = JSON.parse(stdout);
|
|
67
|
+
if (!Array.isArray(list) ||
|
|
68
|
+
list.length > 128 ||
|
|
69
|
+
list.some((s) => !/^[a-z][a-z0-9-]{0,63}$/.test(s.name) ||
|
|
70
|
+
!/^[0-9a-f]{64}$/.test(s.digest) ||
|
|
71
|
+
typeof s.path !== "string"))
|
|
72
|
+
throw Error("inventory");
|
|
73
|
+
return list;
|
|
74
|
+
}
|
|
75
|
+
async sync(integration, payments) {
|
|
76
|
+
if (Date.now() >= this.nextScan) {
|
|
77
|
+
this.nextScan = Date.now() + 15000;
|
|
78
|
+
try {
|
|
79
|
+
this.cached = await this.scan();
|
|
80
|
+
this.state = "ready";
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
this.state = this.settings.dockerContainer
|
|
84
|
+
? "agent_unavailable"
|
|
85
|
+
: "scan_failed";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const active = new Set(readdirSync(this.settings.skillsDirectory, { withFileTypes: true })
|
|
89
|
+
.filter((x) => x.isDirectory() && !x.name.startsWith("."))
|
|
90
|
+
.map((x) => x.name));
|
|
91
|
+
for (const skill of this.cached)
|
|
92
|
+
if (active.has(skill.name) && !this.enabled[skill.name])
|
|
93
|
+
this.enabled[skill.name] = skill.digest;
|
|
94
|
+
if (this.state === "ready")
|
|
95
|
+
writeFileSync(this.path, JSON.stringify(this.enabled), { mode: 0o600 });
|
|
96
|
+
const skills = this.cached.map((s) => ({
|
|
97
|
+
name: s.name,
|
|
98
|
+
digest: s.digest,
|
|
99
|
+
description: s.description,
|
|
100
|
+
status: !s.supported
|
|
101
|
+
? "requires_adapter"
|
|
102
|
+
: active.has(s.name)
|
|
103
|
+
? this.enabled[s.name] && this.enabled[s.name] !== s.digest
|
|
104
|
+
? "updated"
|
|
105
|
+
: "enabled"
|
|
106
|
+
: "available",
|
|
107
|
+
}));
|
|
108
|
+
for (const name of active)
|
|
109
|
+
if (!skills.some((s) => s.name === name))
|
|
110
|
+
skills.push({
|
|
111
|
+
name,
|
|
112
|
+
digest: this.enabled[name] ?? "0".repeat(64),
|
|
113
|
+
description: "",
|
|
114
|
+
status: "removed",
|
|
115
|
+
});
|
|
116
|
+
const result = await integration.reportRuntime({
|
|
117
|
+
version: createRequire(import.meta.url)("../../package.json").version,
|
|
118
|
+
card_url: this.origin + "/.well-known/agent-card.json",
|
|
119
|
+
state: this.state,
|
|
120
|
+
skills: skills.slice(0, 128),
|
|
121
|
+
payments: payments
|
|
122
|
+
.filter((p, i, all) => all.findIndex((x) => JSON.stringify(x) === JSON.stringify(p)) === i)
|
|
123
|
+
.slice(0, 8),
|
|
124
|
+
});
|
|
125
|
+
for (const request of result) {
|
|
126
|
+
const skill = this.cached.find((s) => s.name === request.name && s.digest === request.digest && s.supported);
|
|
127
|
+
if (!skill || active.has(skill.name) || this.state !== "ready")
|
|
128
|
+
continue;
|
|
129
|
+
// Check source again after the owner's request. Never overwrite a live package.
|
|
130
|
+
const current = (await this.scan()).find((s) => s.name === skill.name && s.digest === skill.digest && s.supported);
|
|
131
|
+
if (!current)
|
|
132
|
+
continue;
|
|
133
|
+
const stage = mkdtempSync(join(this.settings.skillsDirectory, ".prepare-"));
|
|
134
|
+
try {
|
|
135
|
+
let source = skill.path;
|
|
136
|
+
if (this.settings.dockerContainer) {
|
|
137
|
+
source = join(stage, "source");
|
|
138
|
+
await exec("docker", ["cp", `${this.settings.dockerContainer}:${skill.path}`, source], { timeout: 30000, maxBuffer: 1024 * 1024 });
|
|
139
|
+
}
|
|
140
|
+
if (packageDigest(source) !== skill.digest)
|
|
141
|
+
continue;
|
|
142
|
+
const prepared = join(stage, "prepared");
|
|
143
|
+
mkdirSync(prepared);
|
|
144
|
+
prepareTextSkill(source, join(prepared, skill.name), skill.name);
|
|
145
|
+
readInstalledSkills(prepared, [], this.origin + "/.well-known/agent-card.json");
|
|
146
|
+
const after = (await this.scan()).find((s) => s.name === skill.name);
|
|
147
|
+
if (after?.digest !== skill.digest)
|
|
148
|
+
continue;
|
|
149
|
+
renameSync(join(prepared, skill.name), join(this.settings.skillsDirectory, skill.name));
|
|
150
|
+
this.enabled[skill.name] = skill.digest;
|
|
151
|
+
writeFileSync(this.path, JSON.stringify(this.enabled), { mode: 0o600 });
|
|
152
|
+
// This grant includes only the exact installed package explicitly selected in the owner UI.
|
|
153
|
+
integration.allowInstalledService(skill.name);
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
rmSync(stage, { recursive: true, force: true });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
for (const name of active)
|
|
160
|
+
integration.allowInstalledService(name);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -31,6 +31,7 @@ export interface EnvarSellerRuntimeOptions {
|
|
|
31
31
|
/** Local payment adapters/merchant credentials must already exist; config sync cannot create them. */
|
|
32
32
|
validateConfig?: (config: CommerceConfig) => void;
|
|
33
33
|
onError?: (code: string) => void;
|
|
34
|
+
beforeSync?: () => Promise<void>;
|
|
34
35
|
}
|
|
35
36
|
export declare class EnvarSellerRuntime {
|
|
36
37
|
private readonly options;
|
|
@@ -231,6 +231,13 @@ export class EnvarSellerRuntime {
|
|
|
231
231
|
return this.running;
|
|
232
232
|
this.running = (async () => {
|
|
233
233
|
try {
|
|
234
|
+
try {
|
|
235
|
+
await this.options.beforeSync?.();
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
// A discovery failure must not stall existing service updates or delivery reports.
|
|
239
|
+
this.options.onError?.("skill_discovery");
|
|
240
|
+
}
|
|
234
241
|
for (const candidate of await this.options.integration.pullCandidates())
|
|
235
242
|
await this.options.integration.applyCandidate(candidate, (c) => this.apply(c));
|
|
236
243
|
}
|
package/dist/commerce/envar.d.ts
CHANGED
|
@@ -81,6 +81,11 @@ export declare class EnvarIntegration {
|
|
|
81
81
|
close(): void;
|
|
82
82
|
private open;
|
|
83
83
|
private request;
|
|
84
|
+
allowInstalledService(name: string): void;
|
|
85
|
+
reportRuntime(value: unknown): Promise<{
|
|
86
|
+
name: string;
|
|
87
|
+
digest: string;
|
|
88
|
+
}[]>;
|
|
84
89
|
private candidate;
|
|
85
90
|
/** Latest locally acknowledged revisions; startup recovery never requires platform connectivity. */
|
|
86
91
|
appliedCandidates(): EnvarCandidate[];
|
package/dist/commerce/envar.js
CHANGED
|
@@ -218,6 +218,7 @@ export class EnvarIntegration {
|
|
|
218
218
|
"/api/v1/agent-config/candidates",
|
|
219
219
|
"/api/v1/agent-config/acknowledgments",
|
|
220
220
|
"/api/v1/commerce-reports",
|
|
221
|
+
"/api/v1/agent-config/runtime",
|
|
221
222
|
].includes(path))
|
|
222
223
|
fail("envar_path", "Unsupported platform integration operation");
|
|
223
224
|
const token = await this.options.token();
|
|
@@ -297,6 +298,18 @@ export class EnvarIntegration {
|
|
|
297
298
|
fail("envar_response_json", "Platform response must be an object");
|
|
298
299
|
return value;
|
|
299
300
|
}
|
|
301
|
+
allowInstalledService(name) {
|
|
302
|
+
if (!ID.test(name))
|
|
303
|
+
fail('envar_policy', 'Invalid installed Skill name');
|
|
304
|
+
if (!this.policy.allowedServices.includes(name))
|
|
305
|
+
this.policy.allowedServices.push(name);
|
|
306
|
+
}
|
|
307
|
+
async reportRuntime(value) {
|
|
308
|
+
const response = await this.request('/api/v1/agent-config/runtime', value, randomUUID());
|
|
309
|
+
if (response.agent_id !== this.policy.agentId || !Array.isArray(response.enable) || response.enable.length > 128)
|
|
310
|
+
fail('envar_inventory', 'Invalid runtime acknowledgment');
|
|
311
|
+
return response.enable.filter((x) => x && ID.test(x.name) && /^[0-9a-f]{64}$/.test(x.digest));
|
|
312
|
+
}
|
|
300
313
|
candidate(value) {
|
|
301
314
|
if (!this.policy.acceptUpdates)
|
|
302
315
|
fail("envar_updates_disabled", "Local owner has not enabled platform configuration updates");
|
|
@@ -13,6 +13,10 @@ export interface NativeSkillSettings {
|
|
|
13
13
|
/** Host-side Docker CLI, never a Docker socket mounted into the Agent. */
|
|
14
14
|
dockerContainer?: string;
|
|
15
15
|
dockerStateDirectory?: string;
|
|
16
|
+
discovery?: {
|
|
17
|
+
hermesHome: string;
|
|
18
|
+
enabledFromWeb: true;
|
|
19
|
+
};
|
|
16
20
|
}
|
|
17
21
|
interface InstalledSkill {
|
|
18
22
|
descriptor: SkillDescriptor;
|
|
@@ -56,8 +56,8 @@ export function readInstalledSkills(root, freeSkills, cardUrl) {
|
|
|
56
56
|
throw new CommerceError('skill_size', 'Instruction resources exceed the supported context size');
|
|
57
57
|
output.push({ descriptor: { name, digest: digest(files), cardUrl, access: freeSkills.includes(name) ? 'free' : 'paid', description }, instructions: instructions + references });
|
|
58
58
|
}
|
|
59
|
-
if (
|
|
60
|
-
throw new CommerceError('skill_inventory', 'Install
|
|
59
|
+
if (output.length > 32 || freeSkills.some(name => !output.some(s => s.descriptor.name === name)))
|
|
60
|
+
throw new CommerceError('skill_inventory', 'Install at most 32 skills and declare only installed free skills');
|
|
61
61
|
// Free helpers are executable dependencies of every purchase, so their versions
|
|
62
62
|
// and access-policy changes must invalidate the quote's selected skill digest too.
|
|
63
63
|
const helpers = output.filter(s => s.descriptor.access === 'free').map(s => ({ name: s.descriptor.name, digest: s.descriptor.digest }));
|
|
@@ -70,7 +70,7 @@ export function createNativeSkillExecutor(settings, origin, agentName) {
|
|
|
70
70
|
throw new CommerceError('skill_settings', 'Configure a supported native skill runtime');
|
|
71
71
|
const cardUrl = new URL('/.well-known/agent-card.json', origin).href;
|
|
72
72
|
const installed = () => readInstalledSkills(settings.skillsDirectory, settings.freeSkills, cardUrl);
|
|
73
|
-
|
|
73
|
+
installed();
|
|
74
74
|
mkdirSync(settings.stateDirectory, { recursive: true, mode: 0o700 });
|
|
75
75
|
const stateStat = lstatSync(settings.stateDirectory);
|
|
76
76
|
if (!stateStat.isDirectory() || stateStat.isSymbolicLink() || (stateStat.mode & 0o077) !== 0)
|
|
@@ -170,7 +170,5 @@ export function createNativeSkillExecutor(settings, origin, agentName) {
|
|
|
170
170
|
const skills = installed();
|
|
171
171
|
return AgentCard.fromJSON({ name: agentName, description: 'Installed Agent Skills', version: '1.0', supportedInterfaces: [{ url: new URL('/a2a', origin).href, protocolBinding: 'JSONRPC', protocolVersion: '1.0' }], capabilities: { extensions: [{ uri: 'urn:envarpay:skill-gate:1', required: false, params: { runtime: 'instruction-only-v1', skills: skills.map(s => ({ name: s.descriptor.name, digest: s.descriptor.digest, access: s.descriptor.access })) } }] }, defaultInputModes: ['application/json'], defaultOutputModes: ['text/markdown'], skills: skills.map(s => ({ id: s.descriptor.name, name: s.descriptor.name, description: s.descriptor.description, tags: [s.descriptor.name] })) });
|
|
172
172
|
};
|
|
173
|
-
if (initial.length === 0)
|
|
174
|
-
throw new CommerceError('skill_inventory', 'No installed skills');
|
|
175
173
|
return { execute, close: async () => { await Promise.allSettled(inFlight.values()); db.close(); } };
|
|
176
174
|
}
|
package/dist/commerce/setup.js
CHANGED
|
@@ -136,18 +136,17 @@ export async function setupHermes(options) {
|
|
|
136
136
|
const candidates = probe.skills.filter((s) => s.textPackage && ID.test(s.name));
|
|
137
137
|
console.log(`Model: ${probe.model}\nInstalled text packages (review their instructions before selecting):`);
|
|
138
138
|
candidates.forEach((skill, i) => console.log(`${i + 1}. ${skill.name} — ${skill.description.replace(/[\r\n\x1b]/g, " ").slice(0, 160)}`));
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const selected = (await ask("Skill names to offer (comma separated)"))
|
|
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)"))
|
|
142
141
|
.split(",")
|
|
143
|
-
.map((s) => s.trim());
|
|
144
|
-
if (
|
|
145
|
-
selected.length > 32 ||
|
|
142
|
+
.map((s) => s.trim()).filter(Boolean);
|
|
143
|
+
if (selected.length > 32 ||
|
|
146
144
|
new Set(selected).size !== selected.length ||
|
|
147
145
|
selected.some((name) => !candidates.some((s) => s.name === name)))
|
|
148
146
|
throw new CommerceError("setup_skills", "Choose installed skill names from the list");
|
|
149
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.");
|
|
150
|
-
if (
|
|
148
|
+
if (selected.length &&
|
|
149
|
+
(await ask("Do these selected skills work with text input and text output only? (yes/no)", "no")).toLowerCase() !== "yes")
|
|
151
150
|
return;
|
|
152
151
|
const port = Number(await ask("Local service port", "4020"));
|
|
153
152
|
if (!Number.isInteger(port) || port < 1024 || port > 65535)
|
|
@@ -271,6 +270,7 @@ export async function setupHermes(options) {
|
|
|
271
270
|
upstreams: {},
|
|
272
271
|
skills: {
|
|
273
272
|
framework: "hermes",
|
|
273
|
+
discovery: { hermesHome: probe.home, enabledFromWeb: true },
|
|
274
274
|
skillsDirectory: join(directory, "skills"),
|
|
275
275
|
freeSkills: [],
|
|
276
276
|
stateDirectory,
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Bounded, metadata-only local inventory. Never reads model credentials or executes Skills."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import hermes_yaml as yaml
|
|
12
|
+
except ImportError:
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def inventory(home):
|
|
17
|
+
home = Path(home or os.environ.get("HERMES_HOME", "~/.hermes")).expanduser().resolve()
|
|
18
|
+
config = yaml.safe_load((home / "config.yaml").read_text()) or {}
|
|
19
|
+
roots = [home / "skills"]
|
|
20
|
+
roots.extend(
|
|
21
|
+
Path(os.path.expandvars(str(p))).expanduser().resolve()
|
|
22
|
+
for p in config.get("skills", {}).get("external_dirs", [])
|
|
23
|
+
)
|
|
24
|
+
result, seen = [], set()
|
|
25
|
+
for root in roots:
|
|
26
|
+
if not root.is_dir():
|
|
27
|
+
continue
|
|
28
|
+
for folder, directories, files in os.walk(root, followlinks=False):
|
|
29
|
+
directories[:] = sorted(
|
|
30
|
+
d
|
|
31
|
+
for d in directories
|
|
32
|
+
if not d.startswith(".") and not (Path(folder) / d).is_symlink()
|
|
33
|
+
)
|
|
34
|
+
if len(Path(folder).relative_to(root).parts) > 10:
|
|
35
|
+
directories[:] = []
|
|
36
|
+
continue
|
|
37
|
+
if "SKILL.md" not in files:
|
|
38
|
+
continue
|
|
39
|
+
entry = Path(folder) / "SKILL.md"
|
|
40
|
+
if entry.is_symlink() or entry.stat().st_size > 48000:
|
|
41
|
+
continue
|
|
42
|
+
raw = entry.read_text()
|
|
43
|
+
front = raw.split("---", 2)
|
|
44
|
+
try:
|
|
45
|
+
meta = (
|
|
46
|
+
yaml.safe_load(front[1]) if raw.startswith("---\n") and len(front) == 3 else {}
|
|
47
|
+
)
|
|
48
|
+
except Exception:
|
|
49
|
+
continue
|
|
50
|
+
name = (meta or {}).get("name")
|
|
51
|
+
if (
|
|
52
|
+
not isinstance(name, str)
|
|
53
|
+
or not re.fullmatch(r"[a-z][a-z0-9-]{0,63}", name)
|
|
54
|
+
or name in seen
|
|
55
|
+
):
|
|
56
|
+
continue
|
|
57
|
+
seen.add(name)
|
|
58
|
+
package, size, supported = [], 0, True
|
|
59
|
+
for subfolder, dirs, names in os.walk(entry.parent, followlinks=False):
|
|
60
|
+
for d in dirs[:]:
|
|
61
|
+
if (Path(subfolder) / d).is_symlink():
|
|
62
|
+
supported = False
|
|
63
|
+
dirs.remove(d)
|
|
64
|
+
if len(Path(subfolder).relative_to(entry.parent).parts) > 10:
|
|
65
|
+
supported = False
|
|
66
|
+
dirs[:] = []
|
|
67
|
+
for file_name in sorted(names):
|
|
68
|
+
file = Path(subfolder) / file_name
|
|
69
|
+
if file.is_symlink() or not file.is_file() or file.stat().st_size > 512 * 1024:
|
|
70
|
+
supported = False
|
|
71
|
+
continue
|
|
72
|
+
size += file.stat().st_size
|
|
73
|
+
if size > 1024 * 1024 or len(package) >= 128:
|
|
74
|
+
supported = False
|
|
75
|
+
break
|
|
76
|
+
if file.suffix.lower() not in {".md", ".txt"} and file.name != "LICENSE":
|
|
77
|
+
supported = False
|
|
78
|
+
package.append(
|
|
79
|
+
[
|
|
80
|
+
str(file.relative_to(entry.parent)),
|
|
81
|
+
hashlib.sha256(file.read_bytes()).hexdigest(),
|
|
82
|
+
]
|
|
83
|
+
)
|
|
84
|
+
digest = hashlib.sha256(
|
|
85
|
+
json.dumps(sorted(package), separators=(",", ":")).encode()
|
|
86
|
+
).hexdigest()
|
|
87
|
+
result.append(
|
|
88
|
+
{
|
|
89
|
+
"name": name,
|
|
90
|
+
"digest": digest,
|
|
91
|
+
"path": str(entry.parent),
|
|
92
|
+
"description": str((meta or {}).get("description", ""))[:500],
|
|
93
|
+
"supported": supported,
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
directories[:] = []
|
|
97
|
+
if len(result) >= 128:
|
|
98
|
+
return result
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
try:
|
|
104
|
+
print(json.dumps(inventory(sys.argv[1] if len(sys.argv) > 1 else None)))
|
|
105
|
+
except Exception:
|
|
106
|
+
print("Skill inventory unavailable", file=sys.stderr)
|
|
107
|
+
sys.exit(1)
|