@envarai/envarpay 0.2.0-alpha.10 → 0.2.0-alpha.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, lstatSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { readFileSync, lstatSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs';
3
3
  import { parseArgs } from 'node:util';
4
4
  import { createRequire } from 'node:module';
5
5
  import { randomBytes } from 'node:crypto';
@@ -22,6 +22,8 @@ import { CommerceError } from './types.js';
22
22
  import { createNativeSkillExecutor } from './native-skills.js';
23
23
  import { inspectLedger, inspectLocalAlerts } from './operations.js';
24
24
  import { setupHermes } from './setup.js';
25
+ import { SkillDiscovery } from './discovery.js';
26
+ import { upgradeDiscovery, configurePayments } from './configure.js';
25
27
  const { values, positionals } = parseArgs({ allowPositionals: true, options: {
26
28
  config: { type: 'string' }, service: { type: 'string' }, offer: { type: 'string' }, origin: { type: 'string' },
27
29
  version: { type: 'boolean' }, help: { type: 'boolean', short: 'h' }, state: { type: 'string' }, credentials: { type: 'string' }, host: { type: 'string' },
@@ -29,7 +31,7 @@ const { values, positionals } = parseArgs({ allowPositionals: true, options: {
29
31
  after: { type: 'string' }, limit: { type: 'string' }, 'refund-review': { type: 'boolean' }, alerts: { type: 'boolean' }, 'fail-on-alert': { type: 'boolean' }, 'integration-state': { type: 'string' }, 'no-integration': { type: 'boolean' },
30
32
  file: { type: 'string' }, 'docker-container': { type: 'string' }, python: { type: 'string' }, 'hermes-home': { type: 'string' },
31
33
  } });
32
- const usage = 'envarpay setup --file envar-setup.json [--docker-container NAME | --python PATH] [--hermes-home PATH] [--directory DIR]\ninit --directory DIR | inspect --state DB [--after ROW --limit 100 --refund-review] [--alerts --config buyer-policy.json --integration-state DB/--no-integration --fail-on-alert] | validate --config seller.json | card --config seller.json --service ID --offer ID --origin URL | serve/buyer-serve --config FILE --credentials FILE --state DB --origin URL';
34
+ const usage = 'envarpay upgrade --directory DIR [--hermes-home PATH] | payments --directory DIR --file receiving.json\nenvarpay setup --file envar-setup.json [--docker-container NAME | --python PATH] [--hermes-home PATH] [--directory DIR]\ninit --directory DIR | inspect --state DB [--after ROW --limit 100 --refund-review] [--alerts --config buyer-policy.json --integration-state DB/--no-integration --fail-on-alert] | validate --config seller.json | card --config seller.json --service ID --offer ID --origin URL | serve/buyer-serve --config FILE --credentials FILE --state DB --origin URL';
33
35
  const closers = [];
34
36
  function privateFile(path) {
35
37
  const stat = lstatSync(path);
@@ -89,6 +91,14 @@ async function run() {
89
91
  return;
90
92
  }
91
93
  const command = positionals[0];
94
+ if (command === 'upgrade' && values.directory) {
95
+ upgradeDiscovery(values.directory, values['hermes-home']);
96
+ return;
97
+ }
98
+ if (command === 'payments' && values.directory && values.file) {
99
+ await configurePayments(values.directory, values.file);
100
+ return;
101
+ }
92
102
  if (command === 'setup') {
93
103
  if (!values.file)
94
104
  throw new CommerceError('setup_file', 'Download the setup file from your Agent service page and pass --file PATH');
@@ -218,6 +228,10 @@ async function run() {
218
228
  syncSettings = JSON.parse(privateFile(values['envar-config']).toString('utf8'));
219
229
  if (!syncSettings?.policy)
220
230
  throw new CommerceError('envar_configuration', 'Envar settings must include an explicit local policy');
231
+ if (secrets.skills?.discovery?.enabledFromWeb) {
232
+ const installed = readdirSync(secrets.skills.skillsDirectory, { withFileTypes: true }).filter(e => e.isDirectory() && !e.isSymbolicLink() && /^[a-z][a-z0-9-]{0,63}$/.test(e.name)).map(e => e.name);
233
+ syncSettings.policy.allowedServices = [...new Set([...syncSettings.policy.allowedServices, ...installed])];
234
+ }
221
235
  const tokenFile = resolve(values['envar-credentials']);
222
236
  privateFile(tokenFile);
223
237
  integration = new EnvarIntegration({ policy: syncSettings.policy, stateDirectory: syncSettings.stateDirectory ?? join(dirname(values.state), 'envar-state'), configDirectory: syncSettings.configDirectory ?? join(dirname(values.state), 'envar-configs'), token: () => privateFile(tokenFile).toString('utf8').trim() });
@@ -248,7 +262,7 @@ async function run() {
248
262
  if (!secrets.payers || !secrets.rpcUrls)
249
263
  throw new CommerceError('x402_configuration', 'x402 requires explicit caller-payer bindings and network RPCs');
250
264
  const gate = new X402Gate({ store, vault: vault, payerFor: caller => secrets.payers?.[caller], verifyReceipt: evmReceiptVerifier(secrets.rpcUrls), ...evmSettlementRecovery(secrets.rpcUrls) });
251
- await gate.check(Object.values(config.paymentProfiles).filter((p) => p.adapter === 'x402'));
265
+ await gate.check([...Object.values(config.paymentProfiles).filter((p) => p.adapter === 'x402'), ...(secrets.receivingProfiles ?? [])]);
252
266
  gates.x402 = gate;
253
267
  }
254
268
  if (protocols.has('mpp')) {
@@ -265,6 +279,7 @@ async function run() {
265
279
  stripeRecipientFor = ref => gate.merchantRecipient(ref);
266
280
  }
267
281
  const nativeSkills = secrets.skills ? createNativeSkillExecutor(secrets.skills, values.origin, config.agent.name) : undefined;
282
+ const discovery = secrets.skills?.discovery?.enabledFromWeb ? new SkillDiscovery(secrets.skills, secrets.skills.discovery, values.origin) : undefined;
268
283
  if (nativeSkills)
269
284
  closers.push(() => nativeSkills.close());
270
285
  const upstreams = new PinnedUpstreams(join(dirname(values.state), 'upstream-bindings.json'), secrets.upstreams ?? {}, secrets.upstreamInputEncoding, fetch, controller.signal, nativeSkills?.execute);
@@ -292,7 +307,7 @@ async function run() {
292
307
  await new Promise(r => setTimeout(r, 50)); });
293
308
  let connection;
294
309
  if (integration) {
295
- connection = new EnvarSellerRuntime({ integration, server, upstreams, pollIntervalMs: syncSettings?.pollIntervalMs, validateConfig: validateAdapters, onError: code => console.error(JSON.stringify({ code, integration: 'envar' })) });
310
+ connection = new EnvarSellerRuntime({ integration, server, upstreams, pollIntervalMs: syncSettings?.pollIntervalMs, validateConfig: validateAdapters, beforeSync: discovery ? () => discovery.sync(integration, [...Object.values(server.config.paymentProfiles).filter(p => p.adapter === 'x402'), ...(secrets.receivingProfiles ?? [])].map(p => ({ network: p.network, pay_to: p.payTo, facilitator_url: p.facilitatorUrl }))) : undefined, onError: code => console.error(JSON.stringify({ code, integration: 'envar' })) });
296
311
  closers.push(() => connection.stop());
297
312
  }
298
313
  const http = listenCommerce(server, values.origin, values.host ?? '127.0.0.1', port(4020));
@@ -0,0 +1,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
  }
@@ -136,18 +136,17 @@ export async function setupHermes(options) {
136
136
  const candidates = probe.skills.filter((s) => s.textPackage && ID.test(s.name));
137
137
  console.log(`Model: ${probe.model}\nInstalled text packages (review their instructions before selecting):`);
138
138
  candidates.forEach((skill, i) => console.log(`${i + 1}. ${skill.name} — ${skill.description.replace(/[\r\n\x1b]/g, " ").slice(0, 160)}`));
139
- 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)"))
139
+ console.log('Newly installed Skills will be discovered privately. Enable each one from the Envar service page after reviewing text-only compatibility.');
140
+ const selected = (await ask("Optional: enable existing text Skills now (comma separated, Enter to skip)"))
142
141
  .split(",")
143
- .map((s) => s.trim());
144
- if (!selected.length ||
145
- selected.length > 32 ||
142
+ .map((s) => s.trim()).filter(Boolean);
143
+ if (selected.length > 32 ||
146
144
  new Set(selected).size !== selected.length ||
147
145
  selected.some((name) => !candidates.some((s) => s.name === name)))
148
146
  throw new CommerceError("setup_skills", "Choose installed skill names from the list");
149
147
  console.log("This adapter supplies buyer text and bundled Markdown references. Browser, script, file and API tools are disabled. Original Hermes skills are preserved; a service copy is created.");
150
- if ((await ask("Do these selected skills work with text input and text output only? (yes/no)", "no")).toLowerCase() !== "yes")
148
+ if (selected.length &&
149
+ (await ask("Do these selected skills work with text input and text output only? (yes/no)", "no")).toLowerCase() !== "yes")
151
150
  return;
152
151
  const port = Number(await ask("Local service port", "4020"));
153
152
  if (!Number.isInteger(port) || port < 1024 || port > 65535)
@@ -271,6 +270,7 @@ export async function setupHermes(options) {
271
270
  upstreams: {},
272
271
  skills: {
273
272
  framework: "hermes",
273
+ discovery: { hermesHome: probe.home, enabledFromWeb: true },
274
274
  skillsDirectory: join(directory, "skills"),
275
275
  freeSkills: [],
276
276
  stateDirectory,
@@ -0,0 +1,107 @@
1
+ """Bounded, metadata-only local inventory. Never reads model credentials or executes Skills."""
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ try:
11
+ import hermes_yaml as yaml
12
+ except ImportError:
13
+ import yaml
14
+
15
+
16
+ def inventory(home):
17
+ home = Path(home or os.environ.get("HERMES_HOME", "~/.hermes")).expanduser().resolve()
18
+ config = yaml.safe_load((home / "config.yaml").read_text()) or {}
19
+ roots = [home / "skills"]
20
+ roots.extend(
21
+ Path(os.path.expandvars(str(p))).expanduser().resolve()
22
+ for p in config.get("skills", {}).get("external_dirs", [])
23
+ )
24
+ result, seen = [], set()
25
+ for root in roots:
26
+ if not root.is_dir():
27
+ continue
28
+ for folder, directories, files in os.walk(root, followlinks=False):
29
+ directories[:] = sorted(
30
+ d
31
+ for d in directories
32
+ if not d.startswith(".") and not (Path(folder) / d).is_symlink()
33
+ )
34
+ if len(Path(folder).relative_to(root).parts) > 10:
35
+ directories[:] = []
36
+ continue
37
+ if "SKILL.md" not in files:
38
+ continue
39
+ entry = Path(folder) / "SKILL.md"
40
+ if entry.is_symlink() or entry.stat().st_size > 48000:
41
+ continue
42
+ raw = entry.read_text()
43
+ front = raw.split("---", 2)
44
+ try:
45
+ meta = (
46
+ yaml.safe_load(front[1]) if raw.startswith("---\n") and len(front) == 3 else {}
47
+ )
48
+ except Exception:
49
+ continue
50
+ name = (meta or {}).get("name")
51
+ if (
52
+ not isinstance(name, str)
53
+ or not re.fullmatch(r"[a-z][a-z0-9-]{0,63}", name)
54
+ or name in seen
55
+ ):
56
+ continue
57
+ seen.add(name)
58
+ package, size, supported = [], 0, True
59
+ for subfolder, dirs, names in os.walk(entry.parent, followlinks=False):
60
+ for d in dirs[:]:
61
+ if (Path(subfolder) / d).is_symlink():
62
+ supported = False
63
+ dirs.remove(d)
64
+ if len(Path(subfolder).relative_to(entry.parent).parts) > 10:
65
+ supported = False
66
+ dirs[:] = []
67
+ for file_name in sorted(names):
68
+ file = Path(subfolder) / file_name
69
+ if file.is_symlink() or not file.is_file() or file.stat().st_size > 512 * 1024:
70
+ supported = False
71
+ continue
72
+ size += file.stat().st_size
73
+ if size > 1024 * 1024 or len(package) >= 128:
74
+ supported = False
75
+ break
76
+ if file.suffix.lower() not in {".md", ".txt"} and file.name != "LICENSE":
77
+ supported = False
78
+ package.append(
79
+ [
80
+ str(file.relative_to(entry.parent)),
81
+ hashlib.sha256(file.read_bytes()).hexdigest(),
82
+ ]
83
+ )
84
+ digest = hashlib.sha256(
85
+ json.dumps(sorted(package), separators=(",", ":")).encode()
86
+ ).hexdigest()
87
+ result.append(
88
+ {
89
+ "name": name,
90
+ "digest": digest,
91
+ "path": str(entry.parent),
92
+ "description": str((meta or {}).get("description", ""))[:500],
93
+ "supported": supported,
94
+ }
95
+ )
96
+ directories[:] = []
97
+ if len(result) >= 128:
98
+ return result
99
+ return result
100
+
101
+
102
+ if __name__ == "__main__":
103
+ try:
104
+ print(json.dumps(inventory(sys.argv[1] if len(sys.argv) > 1 else None)))
105
+ except Exception:
106
+ print("Skill inventory unavailable", file=sys.stderr)
107
+ sys.exit(1)
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.11",
4
4
  "description": "Independent A2A service commerce, budgeted buyers and x402/MPP payments",
5
5
  "type": "module",
6
6
  "license": "MIT",