@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.
@@ -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
+ }
@@ -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,3 @@
1
+ export declare function refreshLauncher(directory: string): void;
2
+ export declare function upgradeDiscovery(directory: string, home?: string): void;
3
+ export declare function configurePayments(directory: string, file: string): Promise<void>;
@@ -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
  }
@@ -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[];
@@ -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 (!output.length || output.length > 32 || freeSkills.some(name => !output.some(s => s.descriptor.name === name)))
60
- throw new CommerceError('skill_inventory', 'Install between 1 and 32 skills and declare only installed free skills');
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
- const initial = installed();
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
  }
@@ -15,4 +15,8 @@ export declare function setupHermes(options: {
15
15
  container?: string;
16
16
  python?: string;
17
17
  home?: string;
18
+ automatic?: {
19
+ port: number;
20
+ origin: string;
21
+ };
18
22
  }): Promise<void>;
@@ -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
- input: process.stdin,
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
- console.log(`\nConnect ${bundle.agentName} to ${bundle.platformOrigin}\nModel credentials and tasks remain on this machine. Start with free services; no wallet is created.\n`);
83
- if ((await ask("Continue? (yes/no)", "no")).toLowerCase() !== "yes")
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
- console.log(`Model: ${probe.model}\nInstalled text packages (review their instructions before selecting):`);
138
- candidates.forEach((skill, i) => console.log(`${i + 1}. ${skill.name} — ${skill.description.replace(/[\r\n\x1b]/g, " ").slice(0, 160)}`));
139
- if (!candidates.length)
140
- throw new CommerceError("setup_skills", "Install a text skill in Hermes first, then run setup again");
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 (!selected.length ||
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
- 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 ((await ask("Do these selected skills work with text input and text output only? (yes/no)", "no")).toLowerCase() !== "yes")
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
- console.log(`\nReady. Start the local service:\n${process.execPath} ${JSON.stringify(start)}\n\nThen import this connection file in Envar:\n${join(directory, "connection.json")}\n\nKeep the service and HTTPS tunnel online. No task or payment was performed.`);
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.close();
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@envarai/envarpay",
3
- "version": "0.2.0-alpha.10",
3
+ "version": "0.2.0-alpha.12",
4
4
  "description": "Independent A2A service commerce, budgeted buyers and x402/MPP payments",
5
5
  "type": "module",
6
6
  "license": "MIT",