@envarai/envarpay 0.2.0-alpha.10 → 0.2.0-alpha.12
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/agent-setup.d.ts +18 -0
- package/dist/commerce/agent-setup.js +189 -0
- package/dist/commerce/cli.js +27 -5
- 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.d.ts +4 -0
- package/dist/commerce/setup.js +32 -22
- package/examples/hermes-skill-inventory.py +107 -0
- package/package.json +1 -1
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
interface Invitation {
|
|
2
|
+
version: 1;
|
|
3
|
+
agentId: string;
|
|
4
|
+
platformOrigin: string;
|
|
5
|
+
setupToken: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function invitation(value: any): Invitation;
|
|
8
|
+
export declare function agentSetup(options: {
|
|
9
|
+
file: string;
|
|
10
|
+
directory?: string;
|
|
11
|
+
python?: string;
|
|
12
|
+
home?: string;
|
|
13
|
+
container?: string;
|
|
14
|
+
origin?: string;
|
|
15
|
+
port?: number;
|
|
16
|
+
checkOnly?: boolean;
|
|
17
|
+
}): Promise<void>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/** Agent-driven installation. Secrets stay in local files; receipts come from Envar. */
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync, openSync, closeSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { createServer } from 'node:net';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { setupHermes, parseSetupBundle } from './setup.js';
|
|
8
|
+
import { CommerceError } from './types.js';
|
|
9
|
+
function privateJson(file) {
|
|
10
|
+
const s = lstatSync(file);
|
|
11
|
+
if (!s.isFile() || s.isSymbolicLink() || s.size > 16384 || (s.mode & 0o077))
|
|
12
|
+
throw new CommerceError('setup_private_file', 'Save the setup invitation in an owner-only file (chmod600).');
|
|
13
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
14
|
+
}
|
|
15
|
+
export function invitation(value) {
|
|
16
|
+
if (value?.version !== 1 || !/^[0-9a-f-]{36}$/.test(value.agentId) || !/^envar_setup_[A-Za-z0-9_-]{43}$/.test(value.setupToken))
|
|
17
|
+
throw new CommerceError('setup_invitation', 'Copy a fresh setup prompt.');
|
|
18
|
+
const url = new URL(value.platformOrigin);
|
|
19
|
+
if (url.protocol !== 'https:' || url.origin !== value.platformOrigin || url.username || url.password)
|
|
20
|
+
throw new CommerceError('setup_origin', 'Use the exact Envar HTTPS origin.');
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
async function request(invite, path, data) {
|
|
24
|
+
const r = await fetch(invite.platformOrigin + '/api/v1/agent-setup/' + path, { method: 'POST', redirect: 'error', headers: { Authorization: 'Bearer ' + invite.setupToken, 'Content-Type': 'application/json' }, body: JSON.stringify(data), signal: AbortSignal.timeout(45000) });
|
|
25
|
+
if (!r.ok)
|
|
26
|
+
throw new CommerceError('setup_' + path, `Envar ${path} returned ${r.status}. Check connection or copy a fresh prompt; do not report success.`);
|
|
27
|
+
const text = await r.text();
|
|
28
|
+
if (text.length > 65536)
|
|
29
|
+
throw new CommerceError('setup_response', 'Envar returned an oversized response.');
|
|
30
|
+
return JSON.parse(text);
|
|
31
|
+
}
|
|
32
|
+
async function freePort() {
|
|
33
|
+
const socket = createServer();
|
|
34
|
+
await new Promise((resolve, reject) => { socket.once('error', reject); socket.listen(0, '127.0.0.1', resolve); });
|
|
35
|
+
const port = socket.address().port;
|
|
36
|
+
await new Promise(resolve => socket.close(() => resolve()));
|
|
37
|
+
return port;
|
|
38
|
+
}
|
|
39
|
+
function save(file, data) { writeFileSync(file, JSON.stringify(data, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); }
|
|
40
|
+
async function publicOrigin(root, port, agentId, supplied) {
|
|
41
|
+
if (supplied) {
|
|
42
|
+
const u = new URL(supplied);
|
|
43
|
+
if (u.protocol !== 'https:' || u.origin !== supplied)
|
|
44
|
+
throw Error('Use a public HTTPS origin');
|
|
45
|
+
return supplied;
|
|
46
|
+
}
|
|
47
|
+
// Use an available existing tunnel session, without deleting or rebinding any endpoint.
|
|
48
|
+
for (const apiPort of [4040, 4041, 4042, 4043]) {
|
|
49
|
+
try {
|
|
50
|
+
const api = `http://127.0.0.1:${apiPort}`;
|
|
51
|
+
const r = await fetch(api + '/api/tunnels', { signal: AbortSignal.timeout(1000) });
|
|
52
|
+
if (!r.ok)
|
|
53
|
+
continue;
|
|
54
|
+
const list = (await r.json());
|
|
55
|
+
const name = 'envarpay-' + agentId, addr = `http://127.0.0.1:${port}`;
|
|
56
|
+
const prior = list.tunnels?.find((t) => t.name === name);
|
|
57
|
+
if (prior) {
|
|
58
|
+
if (prior.config.addr !== addr)
|
|
59
|
+
continue;
|
|
60
|
+
return prior.public_url;
|
|
61
|
+
}
|
|
62
|
+
if (list.tunnels?.length >= 3)
|
|
63
|
+
continue;
|
|
64
|
+
const created = await fetch(api + '/api/tunnels', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, proto: 'http', addr, schemes: ['https'], inspect: false }), signal: AbortSignal.timeout(12000) });
|
|
65
|
+
if (created.ok)
|
|
66
|
+
return (await created.json()).public_url;
|
|
67
|
+
}
|
|
68
|
+
catch { }
|
|
69
|
+
}
|
|
70
|
+
// Outbound HTTPS works inside Docker too; never requires a Docker socket or host port mapping.
|
|
71
|
+
const log = join(root, 'tunnel.log');
|
|
72
|
+
if (existsSync(log))
|
|
73
|
+
privateLog(log);
|
|
74
|
+
const offset = existsSync(log) ? readFileSync(log, 'utf8').length : 0, fd = openSync(log, 'a', 0o600);
|
|
75
|
+
const tunnel = spawn('cloudflared', ['tunnel', '--url', `http://127.0.0.1:${port}`, '--no-autoupdate'], { detached: true, stdio: ['ignore', fd, fd] });
|
|
76
|
+
closeSync(fd);
|
|
77
|
+
let failed = false;
|
|
78
|
+
tunnel.on('error', () => { failed = true; });
|
|
79
|
+
tunnel.unref();
|
|
80
|
+
for (let i = 0; i < 45; i++) {
|
|
81
|
+
if (failed)
|
|
82
|
+
break;
|
|
83
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
84
|
+
const url = readFileSync(log, 'utf8').slice(offset).match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com\b/)?.[0];
|
|
85
|
+
if (url) {
|
|
86
|
+
writeFileSync(join(root, 'tunnel-process.json'), JSON.stringify({ pid: tunnel.pid, origin: url, temporary: true }), { mode: 0o600 });
|
|
87
|
+
return url;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (tunnel.pid)
|
|
91
|
+
try {
|
|
92
|
+
tunnel.kill();
|
|
93
|
+
}
|
|
94
|
+
catch { }
|
|
95
|
+
throw new CommerceError('setup_https', 'Install cloudflared from its official distribution, or supply --origin with an existing HTTPS forwarder. Preserve other tunnels.');
|
|
96
|
+
}
|
|
97
|
+
function privateLog(file) { const s = lstatSync(file); if (!s.isFile() || s.isSymbolicLink() || (s.mode & 0o077))
|
|
98
|
+
throw new CommerceError('setup_private_file', 'Runtime log must be an owner-only regular file.'); }
|
|
99
|
+
export async function agentSetup(options) {
|
|
100
|
+
const invite = invitation(privateJson(resolve(options.file)));
|
|
101
|
+
const profile = resolve(options.directory ?? join(homedir(), '.envarpay', invite.agentId));
|
|
102
|
+
const root = profile + '.onboarding';
|
|
103
|
+
if (options.checkOnly) {
|
|
104
|
+
const result = await request(invite, 'status', {});
|
|
105
|
+
if (result.agent_id !== invite.agentId)
|
|
106
|
+
throw new CommerceError('setup_identity', 'Receipt belongs to another Agent.');
|
|
107
|
+
console.log(JSON.stringify(result, null, 2));
|
|
108
|
+
if (result.status !== 'connected')
|
|
109
|
+
process.exitCode = 1;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
113
|
+
if (lstatSync(root).isSymbolicLink() || (lstatSync(root).mode & 0o077))
|
|
114
|
+
throw new CommerceError('setup_directory', 'Use a private directory owned by this user.');
|
|
115
|
+
const manifest = join(root, 'setup.json');
|
|
116
|
+
if (existsSync(manifest) && privateJson(manifest).agentId !== invite.agentId)
|
|
117
|
+
throw new CommerceError('setup_identity', 'This directory belongs to another Agent.');
|
|
118
|
+
const bundle = parseSetupBundle(await request(invite, 'claim', {}));
|
|
119
|
+
if (bundle.agentId !== invite.agentId || bundle.platformOrigin !== invite.platformOrigin)
|
|
120
|
+
throw new CommerceError('setup_identity', 'Envar returned another Agent identity.');
|
|
121
|
+
const bundleFile = join(root, 'bundle.json');
|
|
122
|
+
if (existsSync(bundleFile))
|
|
123
|
+
privateJson(bundleFile);
|
|
124
|
+
writeFileSync(bundleFile, JSON.stringify(bundle), { mode: 0o600 });
|
|
125
|
+
if (!existsSync(profile)) {
|
|
126
|
+
let settings = existsSync(manifest) ? privateJson(manifest) : undefined;
|
|
127
|
+
if (!settings) {
|
|
128
|
+
const port = options.port ?? await freePort();
|
|
129
|
+
const origin = await publicOrigin(root, port, invite.agentId, options.origin);
|
|
130
|
+
settings = { agentId: invite.agentId, port, origin };
|
|
131
|
+
save(manifest, settings);
|
|
132
|
+
}
|
|
133
|
+
await setupHermes({ file: bundleFile, directory: profile, python: options.python, home: options.home, container: options.container, automatic: settings });
|
|
134
|
+
}
|
|
135
|
+
if (lstatSync(profile).isSymbolicLink() || (lstatSync(profile).mode & 0o077))
|
|
136
|
+
throw new CommerceError('setup_directory', 'The runtime must be a private directory.');
|
|
137
|
+
const connection = privateJson(join(profile, 'connection.json'));
|
|
138
|
+
privateLog(join(profile, 'envar.token'));
|
|
139
|
+
const launcher = lstatSync(join(profile, 'start.mjs'));
|
|
140
|
+
if (!launcher.isFile() || launcher.isSymbolicLink() || (launcher.mode & 0o077))
|
|
141
|
+
throw new CommerceError('setup_private_file', 'Use the original private runtime launcher.');
|
|
142
|
+
if (connection.agentId !== invite.agentId)
|
|
143
|
+
throw new CommerceError('setup_identity', 'Existing runtime belongs to another Agent.');
|
|
144
|
+
const policy = privateJson(join(profile, 'envar.json')).policy;
|
|
145
|
+
if (policy.agentId !== invite.agentId || policy.platformOrigin !== invite.platformOrigin)
|
|
146
|
+
throw new CommerceError('setup_identity', 'Existing integration belongs to another Agent.');
|
|
147
|
+
const running = join(root, 'process.json');
|
|
148
|
+
let started = false;
|
|
149
|
+
if (existsSync(running))
|
|
150
|
+
try {
|
|
151
|
+
process.kill(privateJson(running).pid, 0);
|
|
152
|
+
started = true;
|
|
153
|
+
}
|
|
154
|
+
catch { }
|
|
155
|
+
if (started && readFileSync(join(profile, 'envar.token'), 'utf8') !== bundle.token)
|
|
156
|
+
throw new CommerceError('setup_running', 'Existing runtime uses an earlier credential. Stop its recorded process, keep its ledger, then retry this invitation.');
|
|
157
|
+
if (!started) {
|
|
158
|
+
privateLog(join(profile, 'envar.token'));
|
|
159
|
+
writeFileSync(join(profile, 'envar.token'), bundle.token, { mode: 0o600 });
|
|
160
|
+
const log = join(root, 'runtime.log');
|
|
161
|
+
if (existsSync(log))
|
|
162
|
+
privateLog(log);
|
|
163
|
+
const fd = openSync(log, 'a', 0o600), child = spawn(process.execPath, [join(profile, 'start.mjs')], { detached: true, stdio: ['ignore', fd, fd] });
|
|
164
|
+
closeSync(fd);
|
|
165
|
+
child.unref();
|
|
166
|
+
writeFileSync(running, JSON.stringify({ pid: child.pid }), { mode: 0o600 });
|
|
167
|
+
}
|
|
168
|
+
let result;
|
|
169
|
+
for (let attempt = 0; attempt < 12; attempt++) {
|
|
170
|
+
try {
|
|
171
|
+
result = await request(invite, 'connect', { agentId: invite.agentId, url: connection.url, authToken: connection.authToken });
|
|
172
|
+
if (result.status === 'connected')
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
if (attempt === 11)
|
|
177
|
+
throw e;
|
|
178
|
+
}
|
|
179
|
+
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
180
|
+
}
|
|
181
|
+
result = await request(invite, 'status', {});
|
|
182
|
+
if (result.status !== 'connected' || result.agent_id !== invite.agentId || !result.verified_at)
|
|
183
|
+
throw new CommerceError('setup_unverified', 'Platform did not verify this setup. Keep files and report the blocker.');
|
|
184
|
+
const output = { ...result, local_directory: profile, receipt_file: join(root, 'receipt.json'), scope: 'connected; not published; no payment', restart: `node ${JSON.stringify(join(profile, 'start.mjs'))}`, temporary_tunnel: !options.origin, automatic_restart: false };
|
|
185
|
+
if (existsSync(join(root, 'receipt.json')))
|
|
186
|
+
privateJson(join(root, 'receipt.json'));
|
|
187
|
+
writeFileSync(join(root, 'receipt.json'), JSON.stringify(output, null, 2) + '\n', { mode: 0o600 });
|
|
188
|
+
console.log(JSON.stringify(output, null, 2));
|
|
189
|
+
}
|
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,14 +22,17 @@ 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';
|
|
27
|
+
import { agentSetup } from './agent-setup.js';
|
|
25
28
|
const { values, positionals } = parseArgs({ allowPositionals: true, options: {
|
|
26
29
|
config: { type: 'string' }, service: { type: 'string' }, offer: { type: 'string' }, origin: { type: 'string' },
|
|
27
30
|
version: { type: 'boolean' }, help: { type: 'boolean', short: 'h' }, state: { type: 'string' }, credentials: { type: 'string' }, host: { type: 'string' },
|
|
28
31
|
port: { type: 'string' }, directory: { type: 'string' }, 'envar-config': { type: 'string' }, 'envar-credentials': { type: 'string' },
|
|
29
32
|
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
|
-
file: { type: 'string' }, 'docker-container': { type: 'string' }, python: { type: 'string' }, 'hermes-home': { type: 'string' },
|
|
33
|
+
file: { type: 'string' }, 'docker-container': { type: 'string' }, python: { type: 'string' }, 'hermes-home': { type: 'string' }, check: { type: 'boolean' },
|
|
31
34
|
} });
|
|
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';
|
|
35
|
+
const usage = 'envarpay onboard --file invitation.json [--check] [--python PATH] [--docker-container NAME] [--hermes-home PATH] [--origin HTTPS_ORIGIN]\nenvarpay 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
36
|
const closers = [];
|
|
34
37
|
function privateFile(path) {
|
|
35
38
|
const stat = lstatSync(path);
|
|
@@ -89,6 +92,20 @@ async function run() {
|
|
|
89
92
|
return;
|
|
90
93
|
}
|
|
91
94
|
const command = positionals[0];
|
|
95
|
+
if (command === 'onboard') {
|
|
96
|
+
if (!values.file)
|
|
97
|
+
throw new CommerceError('setup_file', 'Save the private invitation and pass --file PATH.');
|
|
98
|
+
await agentSetup({ file: values.file, directory: values.directory, python: values.python, home: values['hermes-home'], container: values['docker-container'], origin: values.origin, port: values.port ? port(4020) : undefined, checkOnly: values.check });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (command === 'upgrade' && values.directory) {
|
|
102
|
+
upgradeDiscovery(values.directory, values['hermes-home']);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (command === 'payments' && values.directory && values.file) {
|
|
106
|
+
await configurePayments(values.directory, values.file);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
92
109
|
if (command === 'setup') {
|
|
93
110
|
if (!values.file)
|
|
94
111
|
throw new CommerceError('setup_file', 'Download the setup file from your Agent service page and pass --file PATH');
|
|
@@ -218,6 +235,10 @@ async function run() {
|
|
|
218
235
|
syncSettings = JSON.parse(privateFile(values['envar-config']).toString('utf8'));
|
|
219
236
|
if (!syncSettings?.policy)
|
|
220
237
|
throw new CommerceError('envar_configuration', 'Envar settings must include an explicit local policy');
|
|
238
|
+
if (secrets.skills?.discovery?.enabledFromWeb) {
|
|
239
|
+
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);
|
|
240
|
+
syncSettings.policy.allowedServices = [...new Set([...syncSettings.policy.allowedServices, ...installed])];
|
|
241
|
+
}
|
|
221
242
|
const tokenFile = resolve(values['envar-credentials']);
|
|
222
243
|
privateFile(tokenFile);
|
|
223
244
|
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 +269,7 @@ async function run() {
|
|
|
248
269
|
if (!secrets.payers || !secrets.rpcUrls)
|
|
249
270
|
throw new CommerceError('x402_configuration', 'x402 requires explicit caller-payer bindings and network RPCs');
|
|
250
271
|
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'));
|
|
272
|
+
await gate.check([...Object.values(config.paymentProfiles).filter((p) => p.adapter === 'x402'), ...(secrets.receivingProfiles ?? [])]);
|
|
252
273
|
gates.x402 = gate;
|
|
253
274
|
}
|
|
254
275
|
if (protocols.has('mpp')) {
|
|
@@ -265,6 +286,7 @@ async function run() {
|
|
|
265
286
|
stripeRecipientFor = ref => gate.merchantRecipient(ref);
|
|
266
287
|
}
|
|
267
288
|
const nativeSkills = secrets.skills ? createNativeSkillExecutor(secrets.skills, values.origin, config.agent.name) : undefined;
|
|
289
|
+
const discovery = secrets.skills?.discovery?.enabledFromWeb ? new SkillDiscovery(secrets.skills, secrets.skills.discovery, values.origin) : undefined;
|
|
268
290
|
if (nativeSkills)
|
|
269
291
|
closers.push(() => nativeSkills.close());
|
|
270
292
|
const upstreams = new PinnedUpstreams(join(dirname(values.state), 'upstream-bindings.json'), secrets.upstreams ?? {}, secrets.upstreamInputEncoding, fetch, controller.signal, nativeSkills?.execute);
|
|
@@ -292,7 +314,7 @@ async function run() {
|
|
|
292
314
|
await new Promise(r => setTimeout(r, 50)); });
|
|
293
315
|
let connection;
|
|
294
316
|
if (integration) {
|
|
295
|
-
connection = new EnvarSellerRuntime({ integration, server, upstreams, pollIntervalMs: syncSettings?.pollIntervalMs, validateConfig: validateAdapters, onError: code => console.error(JSON.stringify({ code, integration: 'envar' })) });
|
|
317
|
+
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
318
|
closers.push(() => connection.stop());
|
|
297
319
|
}
|
|
298
320
|
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.d.ts
CHANGED
package/dist/commerce/setup.js
CHANGED
|
@@ -69,18 +69,16 @@ export async function setupHermes(options) {
|
|
|
69
69
|
const bundle = parseSetupBundle(JSON.parse(readFileSync(file, "utf8")));
|
|
70
70
|
if (options.container && !CONTAINER.test(options.container))
|
|
71
71
|
throw new CommerceError("setup_container", "Use a Docker container name");
|
|
72
|
-
if (!process.stdin.isTTY)
|
|
72
|
+
if (!process.stdin.isTTY && !options.automatic)
|
|
73
73
|
throw new CommerceError("setup_terminal", "Run setup in an interactive terminal to select skills");
|
|
74
74
|
chmodSync(file, 0o600);
|
|
75
|
-
const prompt = createInterface({
|
|
76
|
-
|
|
77
|
-
output: process.stdout,
|
|
78
|
-
});
|
|
75
|
+
const prompt = options.automatic ? undefined : createInterface({ input: process.stdin, output: process.stdout });
|
|
76
|
+
const output = options.automatic ? (..._args) => { } : console.log;
|
|
79
77
|
const ask = async (label, fallback = "") => (await prompt.question(`${label}${fallback ? ` [${fallback}]` : ""}: `)).trim() || fallback;
|
|
80
78
|
let staging;
|
|
81
79
|
try {
|
|
82
|
-
|
|
83
|
-
if ((await ask("Continue? (yes/no)", "no")).toLowerCase() !== "yes")
|
|
80
|
+
output(`\nConnect ${bundle.agentName} to ${bundle.platformOrigin}\nModel credentials and tasks remain on this machine. Start with free services; no wallet is created.\n`);
|
|
81
|
+
if (!options.automatic && (await ask("Continue? (yes/no)", "no")).toLowerCase() !== "yes")
|
|
84
82
|
return;
|
|
85
83
|
const directory = resolve(options.directory ?? join(homedir(), ".envarpay", bundle.agentId));
|
|
86
84
|
if (existsSync(directory))
|
|
@@ -105,6 +103,18 @@ export async function setupHermes(options) {
|
|
|
105
103
|
if (!python)
|
|
106
104
|
throw new CommerceError("setup_runtime", "No compatible Hermes interpreter found in this container. Update Hermes or pass --python PATH.");
|
|
107
105
|
}
|
|
106
|
+
if (!python && options.automatic) {
|
|
107
|
+
for (const candidate of [process.env.VIRTUAL_ENV && join(process.env.VIRTUAL_ENV, "bin/python"), "/opt/hermes-latest/bin/python", "/opt/hermes/.venv/bin/python", join(homedir(), "hermes-agent/.venv/bin/python"), "python3"].filter(Boolean)) {
|
|
108
|
+
try {
|
|
109
|
+
execFileSync(candidate, ["-c", compatibility], { stdio: "pipe", timeout: 30000 });
|
|
110
|
+
python = candidate;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
catch { }
|
|
114
|
+
}
|
|
115
|
+
if (!python)
|
|
116
|
+
throw new CommerceError("setup_runtime", "No compatible Hermes Python found. Pass its executable using --python.");
|
|
117
|
+
}
|
|
108
118
|
python ??= await ask("Hermes Python executable", join(homedir(), "hermes-agent", ".venv", "bin", "python"));
|
|
109
119
|
const probeSource = readFileSync(new URL("../../examples/hermes-setup-probe.py", import.meta.url), "utf8");
|
|
110
120
|
let probe;
|
|
@@ -134,26 +144,25 @@ export async function setupHermes(options) {
|
|
|
134
144
|
throw new CommerceError("setup_model", "Could not read Hermes. Check the container/Python path and configure an OpenAI-compatible model in Hermes first.");
|
|
135
145
|
}
|
|
136
146
|
const candidates = probe.skills.filter((s) => s.textPackage && ID.test(s.name));
|
|
137
|
-
|
|
138
|
-
candidates.forEach((skill, i) =>
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const selected = (await ask("Skill names to offer (comma separated)"))
|
|
147
|
+
output(`Model: ${probe.model}\nInstalled text packages (review their instructions before selecting):`);
|
|
148
|
+
candidates.forEach((skill, i) => output(`${i + 1}. ${skill.name} — ${skill.description.replace(/[\r\n\x1b]/g, " ").slice(0, 160)}`));
|
|
149
|
+
output('Newly installed Skills will be discovered privately. Enable each one from the Envar service page after reviewing text-only compatibility.');
|
|
150
|
+
const selected = (options.automatic ? "" : await ask("Optional: enable existing text Skills now (comma separated, Enter to skip)"))
|
|
142
151
|
.split(",")
|
|
143
|
-
.map((s) => s.trim());
|
|
144
|
-
if (
|
|
145
|
-
selected.length > 32 ||
|
|
152
|
+
.map((s) => s.trim()).filter(Boolean);
|
|
153
|
+
if (selected.length > 32 ||
|
|
146
154
|
new Set(selected).size !== selected.length ||
|
|
147
155
|
selected.some((name) => !candidates.some((s) => s.name === name)))
|
|
148
156
|
throw new CommerceError("setup_skills", "Choose installed skill names from the list");
|
|
149
|
-
|
|
150
|
-
if (
|
|
157
|
+
output("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.");
|
|
158
|
+
if (selected.length &&
|
|
159
|
+
(await ask("Do these selected skills work with text input and text output only? (yes/no)", "no")).toLowerCase() !== "yes")
|
|
151
160
|
return;
|
|
152
|
-
const port = Number(await ask("Local service port", "4020"));
|
|
161
|
+
const port = options.automatic?.port ?? Number(await ask("Local service port", "4020"));
|
|
153
162
|
if (!Number.isInteger(port) || port < 1024 || port > 65535)
|
|
154
163
|
throw new CommerceError("setup_port", "Choose a local port from 1024 to 65535");
|
|
155
164
|
let origin;
|
|
156
|
-
const tunnelMode = await ask("Public HTTPS: use an existing ngrok session or your own reverse proxy? (ngrok/url)", "ngrok");
|
|
165
|
+
const tunnelMode = options.automatic ? "url" : await ask("Public HTTPS: use an existing ngrok session or your own reverse proxy? (ngrok/url)", "ngrok");
|
|
157
166
|
if (tunnelMode === "ngrok") {
|
|
158
167
|
const api = new URL(await ask("Local ngrok API", "http://127.0.0.1:4040"));
|
|
159
168
|
if (api.protocol !== "http:" ||
|
|
@@ -193,7 +202,7 @@ export async function setupHermes(options) {
|
|
|
193
202
|
}
|
|
194
203
|
}
|
|
195
204
|
else if (tunnelMode === "url") {
|
|
196
|
-
origin = await ask(`Public HTTPS origin (forward to http://127.0.0.1:${port})`);
|
|
205
|
+
origin = options.automatic?.origin ?? await ask(`Public HTTPS origin (forward to http://127.0.0.1:${port})`);
|
|
197
206
|
}
|
|
198
207
|
else
|
|
199
208
|
throw new CommerceError("setup_tunnel", "Choose ngrok or url");
|
|
@@ -271,6 +280,7 @@ export async function setupHermes(options) {
|
|
|
271
280
|
upstreams: {},
|
|
272
281
|
skills: {
|
|
273
282
|
framework: "hermes",
|
|
283
|
+
discovery: { hermesHome: probe.home, enabledFromWeb: true },
|
|
274
284
|
skillsDirectory: join(directory, "skills"),
|
|
275
285
|
freeSkills: [],
|
|
276
286
|
stateDirectory,
|
|
@@ -334,10 +344,10 @@ export async function setupHermes(options) {
|
|
|
334
344
|
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
345
|
renameSync(staging, directory);
|
|
336
346
|
staging = undefined;
|
|
337
|
-
|
|
347
|
+
output(`\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
348
|
}
|
|
339
349
|
finally {
|
|
340
|
-
prompt
|
|
350
|
+
prompt?.close();
|
|
341
351
|
if (staging)
|
|
342
352
|
rmSync(staging, { recursive: true, force: true });
|
|
343
353
|
}
|
|
@@ -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)
|