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

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,19 @@
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
+ framework?: "hermes" | "openclaw";
18
+ }): Promise<void>;
19
+ export {};
@@ -0,0 +1,210 @@
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 { activateManagedProfile, managedProfile } from './managed.js';
8
+ import { setupAgent, parseSetupBundle } from './setup.js';
9
+ import { CommerceError } from './types.js';
10
+ function privateJson(file) {
11
+ const s = lstatSync(file);
12
+ if (!s.isFile() || s.isSymbolicLink() || s.size > 16384 || (s.mode & 0o077))
13
+ throw new CommerceError('setup_private_file', 'Save the setup invitation in an owner-only file (chmod600).');
14
+ return JSON.parse(readFileSync(file, 'utf8'));
15
+ }
16
+ export function invitation(value) {
17
+ if (value?.version !== 1 || !/^[0-9a-f-]{36}$/.test(value.agentId) || !/^envar_setup_[A-Za-z0-9_-]{43}$/.test(value.setupToken))
18
+ throw new CommerceError('setup_invitation', 'Copy a fresh setup prompt.');
19
+ const url = new URL(value.platformOrigin);
20
+ if (url.protocol !== 'https:' || url.origin !== value.platformOrigin || url.username || url.password)
21
+ throw new CommerceError('setup_origin', 'Use the exact Envar HTTPS origin.');
22
+ return value;
23
+ }
24
+ async function request(invite, path, data) {
25
+ 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) });
26
+ if (!r.ok)
27
+ throw new CommerceError('setup_' + path, `Envar ${path} returned ${r.status}. Check connection or copy a fresh prompt; do not report success.`);
28
+ const text = await r.text();
29
+ if (text.length > 65536)
30
+ throw new CommerceError('setup_response', 'Envar returned an oversized response.');
31
+ return JSON.parse(text);
32
+ }
33
+ async function freePort() {
34
+ const socket = createServer();
35
+ await new Promise((resolve, reject) => { socket.once('error', reject); socket.listen(0, '127.0.0.1', resolve); });
36
+ const port = socket.address().port;
37
+ await new Promise(resolve => socket.close(() => resolve()));
38
+ return port;
39
+ }
40
+ function save(file, data) { writeFileSync(file, JSON.stringify(data, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); }
41
+ async function publicOrigin(root, port, agentId, supplied) {
42
+ if (supplied) {
43
+ const u = new URL(supplied);
44
+ if (u.protocol !== 'https:' || u.origin !== supplied)
45
+ throw Error('Use a public HTTPS origin');
46
+ return supplied;
47
+ }
48
+ // Use an available existing tunnel session, without deleting or rebinding any endpoint.
49
+ for (const apiPort of [4040, 4041, 4042, 4043]) {
50
+ try {
51
+ const api = `http://127.0.0.1:${apiPort}`;
52
+ const r = await fetch(api + '/api/tunnels', { signal: AbortSignal.timeout(1000) });
53
+ if (!r.ok)
54
+ continue;
55
+ const list = (await r.json());
56
+ const name = 'envarpay-' + agentId, addr = `http://127.0.0.1:${port}`;
57
+ const prior = list.tunnels?.find((t) => t.name === name);
58
+ if (prior) {
59
+ if (prior.config.addr !== addr)
60
+ continue;
61
+ return prior.public_url;
62
+ }
63
+ if (list.tunnels?.length >= 3)
64
+ continue;
65
+ 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) });
66
+ if (created.ok)
67
+ return (await created.json()).public_url;
68
+ }
69
+ catch { }
70
+ }
71
+ // Outbound HTTPS works inside Docker too; never requires a Docker socket or host port mapping.
72
+ const log = join(root, 'tunnel.log');
73
+ if (existsSync(log))
74
+ privateLog(log);
75
+ const offset = existsSync(log) ? readFileSync(log, 'utf8').length : 0, fd = openSync(log, 'a', 0o600);
76
+ const tunnel = spawn('cloudflared', ['tunnel', '--url', `http://127.0.0.1:${port}`, '--no-autoupdate'], { detached: true, stdio: ['ignore', fd, fd] });
77
+ closeSync(fd);
78
+ let failed = false;
79
+ tunnel.on('error', () => { failed = true; });
80
+ tunnel.unref();
81
+ for (let i = 0; i < 45; i++) {
82
+ if (failed)
83
+ break;
84
+ await new Promise(resolve => setTimeout(resolve, 1000));
85
+ const url = readFileSync(log, 'utf8').slice(offset).match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com\b/)?.[0];
86
+ if (url) {
87
+ writeFileSync(join(root, 'tunnel-process.json'), JSON.stringify({ pid: tunnel.pid, origin: url, temporary: true }), { mode: 0o600 });
88
+ return url;
89
+ }
90
+ }
91
+ if (tunnel.pid)
92
+ try {
93
+ tunnel.kill();
94
+ }
95
+ catch { }
96
+ throw new CommerceError('setup_https', 'Install cloudflared from its official distribution, or supply --origin with an existing HTTPS forwarder. Preserve other tunnels.');
97
+ }
98
+ function privateLog(file) { const s = lstatSync(file); if (!s.isFile() || s.isSymbolicLink() || (s.mode & 0o077))
99
+ throw new CommerceError('setup_private_file', 'Runtime log must be an owner-only regular file.'); }
100
+ export async function agentSetup(options) {
101
+ const invite = invitation(privateJson(resolve(options.file)));
102
+ const managedRoot = process.env.ENVARPAY_MANAGED_ROOT ? resolve(process.env.ENVARPAY_MANAGED_ROOT) : undefined;
103
+ const managedDirectory = managedRoot ? join(managedRoot, 'profiles', invite.agentId) : undefined;
104
+ const profile = resolve(options.directory ?? managedDirectory ?? join(homedir(), '.envarpay', invite.agentId));
105
+ if (managedRoot) {
106
+ if (options.container || profile !== managedDirectory)
107
+ throw new CommerceError('managed_profile', 'Use this managed runtime and its existing profile location');
108
+ const active = managedProfile(managedRoot);
109
+ if (active !== managedRoot && active !== profile)
110
+ throw new CommerceError('managed_bound', 'This runtime belongs to another Agent; preserve its original profile');
111
+ const origin = process.env.ENVARPAY_ORIGIN, port = Number(process.env.ENVARPAY_PORT ?? '4020');
112
+ if (!origin || new URL(origin).protocol !== 'https:' || new URL(origin).origin !== origin || !Number.isInteger(port) || port < 1024 || port > 65535 || (options.origin && options.origin !== origin) || (options.port && options.port !== port))
113
+ throw new CommerceError('managed_origin', 'Use the managed HTTPS origin and port');
114
+ options = { ...options, origin, port };
115
+ }
116
+ const root = profile + '.onboarding';
117
+ if (options.checkOnly) {
118
+ const result = await request(invite, 'status', {});
119
+ if (result.agent_id !== invite.agentId)
120
+ throw new CommerceError('setup_identity', 'Receipt belongs to another Agent.');
121
+ console.log(JSON.stringify(result, null, 2));
122
+ if (result.status !== 'connected')
123
+ process.exitCode = 1;
124
+ return;
125
+ }
126
+ mkdirSync(root, { recursive: true, mode: 0o700 });
127
+ if (lstatSync(root).isSymbolicLink() || (lstatSync(root).mode & 0o077))
128
+ throw new CommerceError('setup_directory', 'Use a private directory owned by this user.');
129
+ const manifest = join(root, 'setup.json');
130
+ if (existsSync(manifest) && privateJson(manifest).agentId !== invite.agentId)
131
+ throw new CommerceError('setup_identity', 'This directory belongs to another Agent.');
132
+ const bundle = parseSetupBundle(await request(invite, 'claim', {}));
133
+ if (bundle.agentId !== invite.agentId || bundle.platformOrigin !== invite.platformOrigin)
134
+ throw new CommerceError('setup_identity', 'Envar returned another Agent identity.');
135
+ const bundleFile = join(root, 'bundle.json');
136
+ if (existsSync(bundleFile))
137
+ privateJson(bundleFile);
138
+ writeFileSync(bundleFile, JSON.stringify(bundle), { mode: 0o600 });
139
+ if (!existsSync(profile)) {
140
+ let settings = existsSync(manifest) ? privateJson(manifest) : undefined;
141
+ if (!settings) {
142
+ const port = options.port ?? await freePort();
143
+ const origin = await publicOrigin(root, port, invite.agentId, options.origin);
144
+ settings = { agentId: invite.agentId, port, origin };
145
+ save(manifest, settings);
146
+ }
147
+ await setupAgent({ framework: options.framework, managed: !!managedRoot, file: bundleFile, directory: profile, python: options.python, home: options.home, container: options.container, automatic: settings });
148
+ }
149
+ if (lstatSync(profile).isSymbolicLink() || (lstatSync(profile).mode & 0o077))
150
+ throw new CommerceError('setup_directory', 'The runtime must be a private directory.');
151
+ const connection = privateJson(join(profile, 'connection.json'));
152
+ privateLog(join(profile, 'envar.token'));
153
+ const launcher = lstatSync(join(profile, 'start.mjs'));
154
+ if (!launcher.isFile() || launcher.isSymbolicLink() || (launcher.mode & 0o077))
155
+ throw new CommerceError('setup_private_file', 'Use the original private runtime launcher.');
156
+ if (connection.agentId !== invite.agentId)
157
+ throw new CommerceError('setup_identity', 'Existing runtime belongs to another Agent.');
158
+ const policy = privateJson(join(profile, 'envar.json')).policy;
159
+ if (policy.agentId !== invite.agentId || policy.platformOrigin !== invite.platformOrigin)
160
+ throw new CommerceError('setup_identity', 'Existing integration belongs to another Agent.');
161
+ if (managedRoot) {
162
+ if (readFileSync(join(profile, 'envar.token'), 'utf8') !== bundle.token)
163
+ writeFileSync(join(profile, 'envar.token'), bundle.token, { mode: 0o600 });
164
+ activateManagedProfile(managedRoot, invite.agentId, profile);
165
+ }
166
+ else {
167
+ const running = join(root, 'process.json');
168
+ let started = false;
169
+ if (existsSync(running))
170
+ try {
171
+ process.kill(privateJson(running).pid, 0);
172
+ started = true;
173
+ }
174
+ catch { }
175
+ if (started && readFileSync(join(profile, 'envar.token'), 'utf8') !== bundle.token)
176
+ throw new CommerceError('setup_running', 'Existing runtime uses an earlier credential. Stop its recorded process, keep its ledger, then retry this invitation.');
177
+ if (!started) {
178
+ privateLog(join(profile, 'envar.token'));
179
+ writeFileSync(join(profile, 'envar.token'), bundle.token, { mode: 0o600 });
180
+ const log = join(root, 'runtime.log');
181
+ if (existsSync(log))
182
+ privateLog(log);
183
+ const fd = openSync(log, 'a', 0o600), child = spawn(process.execPath, [join(profile, 'start.mjs')], { detached: true, stdio: ['ignore', fd, fd] });
184
+ closeSync(fd);
185
+ child.unref();
186
+ writeFileSync(running, JSON.stringify({ pid: child.pid }), { mode: 0o600 });
187
+ }
188
+ }
189
+ let result;
190
+ for (let attempt = 0; attempt < 12; attempt++) {
191
+ try {
192
+ result = await request(invite, 'connect', { agentId: invite.agentId, url: connection.url, authToken: connection.authToken });
193
+ if (result.status === 'connected')
194
+ break;
195
+ }
196
+ catch (e) {
197
+ if (attempt === 11)
198
+ throw e;
199
+ }
200
+ await new Promise(resolve => setTimeout(resolve, 3000));
201
+ }
202
+ result = await request(invite, 'status', {});
203
+ if (result.status !== 'connected' || result.agent_id !== invite.agentId || !result.verified_at)
204
+ throw new CommerceError('setup_unverified', 'Platform did not verify this setup. Keep files and report the blocker.');
205
+ const output = { ...result, local_directory: profile, receipt_file: join(root, 'receipt.json'), scope: 'connected; not published; no payment', restart: managedRoot ? 'managed by the deployment' : `node ${JSON.stringify(join(profile, 'start.mjs'))}`, temporary_tunnel: !options.origin, automatic_restart: !!managedRoot };
206
+ if (existsSync(join(root, 'receipt.json')))
207
+ privateJson(join(root, 'receipt.json'));
208
+ writeFileSync(join(root, 'receipt.json'), JSON.stringify(output, null, 2) + '\n', { mode: 0o600 });
209
+ console.log(JSON.stringify(output, null, 2));
210
+ }
@@ -21,17 +21,19 @@ import { evmReceiptVerifier, evmSettlementRecovery } from './chain.js';
21
21
  import { CommerceError } from './types.js';
22
22
  import { createNativeSkillExecutor } from './native-skills.js';
23
23
  import { inspectLedger, inspectLocalAlerts } from './operations.js';
24
- import { setupHermes } from './setup.js';
24
+ import { serveManaged } from './managed.js';
25
+ import { setupAgent } from './setup.js';
25
26
  import { SkillDiscovery } from './discovery.js';
26
27
  import { upgradeDiscovery, configurePayments } from './configure.js';
28
+ import { agentSetup } from './agent-setup.js';
27
29
  const { values, positionals } = parseArgs({ allowPositionals: true, options: {
28
30
  config: { type: 'string' }, service: { type: 'string' }, offer: { type: 'string' }, origin: { type: 'string' },
29
31
  version: { type: 'boolean' }, help: { type: 'boolean', short: 'h' }, state: { type: 'string' }, credentials: { type: 'string' }, host: { type: 'string' },
30
32
  port: { type: 'string' }, directory: { type: 'string' }, 'envar-config': { type: 'string' }, 'envar-credentials': { type: 'string' },
31
33
  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' },
32
- file: { type: 'string' }, 'docker-container': { type: 'string' }, python: { type: 'string' }, 'hermes-home': { type: 'string' },
34
+ framework: { type: 'string' }, file: { type: 'string' }, 'docker-container': { type: 'string' }, python: { type: 'string' }, 'hermes-home': { type: 'string' }, check: { type: 'boolean' },
33
35
  } });
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';
36
+ const usage = 'envarpay onboard --file invitation.json [--framework hermes|openclaw] [--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';
35
37
  const closers = [];
36
38
  function privateFile(path) {
37
39
  const stat = lstatSync(path);
@@ -91,6 +93,20 @@ async function run() {
91
93
  return;
92
94
  }
93
95
  const command = positionals[0];
96
+ if (values.framework && !['hermes', 'openclaw'].includes(values.framework))
97
+ throw new CommerceError('setup_runtime', 'Use hermes or openclaw');
98
+ if (command === 'managed-serve') {
99
+ if (!values.directory || !values.origin)
100
+ throw new CommerceError('managed_configuration', 'Managed serve requires its directory and HTTPS origin');
101
+ await serveManaged(values.directory, values.origin, values.host ?? '127.0.0.1', port(4020));
102
+ return;
103
+ }
104
+ if (command === 'onboard') {
105
+ if (!values.file)
106
+ throw new CommerceError('setup_file', 'Save the private invitation and pass --file PATH.');
107
+ await agentSetup({ framework: values.framework, 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 });
108
+ return;
109
+ }
94
110
  if (command === 'upgrade' && values.directory) {
95
111
  upgradeDiscovery(values.directory, values['hermes-home']);
96
112
  return;
@@ -102,7 +118,7 @@ async function run() {
102
118
  if (command === 'setup') {
103
119
  if (!values.file)
104
120
  throw new CommerceError('setup_file', 'Download the setup file from your Agent service page and pass --file PATH');
105
- await setupHermes({ file: values.file, directory: values.directory, container: values['docker-container'], python: values.python, home: values['hermes-home'] });
121
+ await setupAgent({ framework: values.framework, file: values.file, directory: values.directory, container: values['docker-container'], python: values.python, home: values['hermes-home'] });
106
122
  return;
107
123
  }
108
124
  if (values.alerts || values['fail-on-alert'] || values['integration-state'] || values['no-integration']) {
@@ -3,6 +3,7 @@ import type { EnvarIntegration } from "./envar.js";
3
3
  export interface DiscoverySettings {
4
4
  hermesHome: string;
5
5
  enabledFromWeb: true;
6
+ roots?: string[];
6
7
  }
7
8
  export declare class SkillDiscovery {
8
9
  private settings;
@@ -1,3 +1,4 @@
1
+ import { readSkillInventory } from "./native-inventory.js";
1
2
  import { execFile } from "node:child_process";
2
3
  import { promisify } from "node:util";
3
4
  import { readFileSync, writeFileSync, existsSync, mkdtempSync, mkdirSync, renameSync, rmSync, readdirSync, lstatSync, } from "node:fs";
@@ -50,6 +51,8 @@ export class SkillDiscovery {
50
51
  : {};
51
52
  }
52
53
  async scan() {
54
+ if (this.discovery.roots && !this.settings.dockerContainer)
55
+ return readSkillInventory(this.discovery.roots);
53
56
  const code = readFileSync(new URL("../../examples/hermes-skill-inventory.py", import.meta.url), "utf8");
54
57
  const args = ["-c", code, this.discovery.hermesHome];
55
58
  const { stdout } = this.settings.dockerContainer
@@ -0,0 +1,3 @@
1
+ export declare function managedProfile(root: string): string;
2
+ export declare function activateManagedProfile(root: string, agentId: string, profile: string): void;
3
+ export declare function serveManaged(root: string, origin: string, host: string, port: number): Promise<void>;
@@ -0,0 +1,143 @@
1
+ /** A persistent native-runtime host for the existing seller CLI. No wallet authority is added. */
2
+ import { chownSync, closeSync, existsSync, fsyncSync, linkSync, lstatSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { spawn } from 'node:child_process';
6
+ import { once } from 'node:events';
7
+ import { DatabaseSync } from 'node:sqlite';
8
+ import { CommerceError } from './types.js';
9
+ const idPattern = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/;
10
+ function rootDirectory(root) {
11
+ const path = resolve(root), stat = lstatSync(path);
12
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077))
13
+ throw new CommerceError('managed_directory', 'Use the existing private managed EnvarPay directory');
14
+ return path;
15
+ }
16
+ export function managedProfile(root) {
17
+ const base = rootDirectory(root), file = join(base, 'active-profile.json');
18
+ const profiles = join(base, 'profiles');
19
+ if (existsSync(profiles)) {
20
+ const stat = lstatSync(profiles);
21
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077))
22
+ throw new CommerceError('managed_profile', 'Use a private profile directory without links');
23
+ }
24
+ if (!existsSync(file))
25
+ return base;
26
+ const stat = lstatSync(file);
27
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 || (stat.mode & 0o077))
28
+ throw new CommerceError('managed_profile', 'Invalid managed profile');
29
+ const value = JSON.parse(readFileSync(file, 'utf8'));
30
+ if (value.version !== 1 || !idPattern.test(value.agentId))
31
+ throw new CommerceError('managed_profile', 'Invalid managed Agent identity');
32
+ const path = join(base, 'profiles', value.agentId), p = lstatSync(path);
33
+ if (!p.isDirectory() || p.isSymbolicLink() || (p.mode & 0o077))
34
+ throw new CommerceError('managed_profile', 'Use the original private profile');
35
+ return path;
36
+ }
37
+ export function activateManagedProfile(root, agentId, profile) {
38
+ const base = rootDirectory(root);
39
+ if (!idPattern.test(agentId) || resolve(profile) !== join(base, 'profiles', agentId))
40
+ throw new CommerceError('managed_profile', 'Profile does not belong to this managed Agent');
41
+ const previous = managedProfile(base);
42
+ if (previous !== base && previous !== resolve(profile))
43
+ throw new CommerceError('managed_bound', 'This runtime is already bound to another Agent; preserve its ledger');
44
+ if (previous === base) {
45
+ const config = JSON.parse(readFileSync(join(base, 'seller.json'), 'utf8'));
46
+ if (config.services?.length || Object.keys(config.paymentProfiles ?? {}).length)
47
+ throw new CommerceError('managed_in_use', 'Existing seller configuration requires review');
48
+ const ledger = join(base, 'seller.sqlite3');
49
+ if (existsSync(ledger)) {
50
+ const db = new DatabaseSync(ledger, { readOnly: true });
51
+ try {
52
+ for (const table of ['commerce_orders', 'commerce_service_revisions']) {
53
+ if (db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(table) && Number(db.prepare(`SELECT count(*) AS n FROM ${table}`).get()?.n))
54
+ throw new CommerceError('managed_in_use', 'Keep the existing seller ledger and inspect its operations before connecting');
55
+ }
56
+ }
57
+ finally {
58
+ db.close();
59
+ }
60
+ }
61
+ }
62
+ const owner = lstatSync(base);
63
+ function ownership(path) {
64
+ const s = lstatSync(path);
65
+ if (s.isSymbolicLink() || (!s.isDirectory() && !s.isFile()))
66
+ throw new CommerceError('managed_profile', 'Managed profiles cannot contain links or special files');
67
+ if (process.getuid?.() === 0)
68
+ chownSync(path, owner.uid, owner.gid);
69
+ if (s.isDirectory())
70
+ for (const name of readdirSync(path))
71
+ ownership(join(path, name));
72
+ }
73
+ ownership(join(base, 'profiles'));
74
+ const connection = JSON.parse(readFileSync(join(profile, 'connection.json'), 'utf8'));
75
+ if (connection.agentId !== agentId)
76
+ throw new CommerceError('managed_profile', 'Connection belongs to another Agent');
77
+ if (previous === resolve(profile))
78
+ return;
79
+ const temp = join(base, `.active-${process.pid}.json`);
80
+ writeFileSync(temp, JSON.stringify({ version: 1, agentId }), { mode: 0o600, flag: 'wx', flush: true });
81
+ if (process.getuid?.() === 0)
82
+ chownSync(temp, owner.uid, owner.gid);
83
+ try {
84
+ // Atomic create prevents two setup processes from replacing one another's binding.
85
+ linkSync(temp, join(base, 'active-profile.json'));
86
+ }
87
+ catch (error) {
88
+ if (error.code !== 'EEXIST' || managedProfile(base) !== resolve(profile))
89
+ throw new CommerceError('managed_bound', 'This runtime was bound by another setup; preserve its original profile');
90
+ }
91
+ finally {
92
+ unlinkSync(temp);
93
+ }
94
+ const directory = openSync(base, 'r');
95
+ try {
96
+ fsyncSync(directory);
97
+ }
98
+ finally {
99
+ closeSync(directory);
100
+ }
101
+ }
102
+ export async function serveManaged(root, origin, host, port) {
103
+ const base = rootDirectory(root), cli = fileURLToPath(new URL('./cli.js', import.meta.url));
104
+ let child, stopping = false;
105
+ const stop = () => { stopping = true; child?.kill('SIGTERM'); };
106
+ process.once('SIGTERM', stop);
107
+ process.once('SIGINT', stop);
108
+ try {
109
+ while (!stopping) {
110
+ const profile = managedProfile(base);
111
+ const stamp = () => ['credentials.json', 'envar.json', 'envar.token'].map(name => { const p = join(profile, name); return existsSync(p) ? lstatSync(p).mtimeMs : 0; }).join(':');
112
+ const initialStamp = stamp();
113
+ const args = [cli, 'serve', '--config', join(profile, 'seller.json'), '--credentials', join(profile, 'credentials.json'), '--state', join(profile, 'seller.sqlite3'), '--origin', origin, '--host', host, '--port', String(port)];
114
+ if (profile !== base)
115
+ args.push('--envar-config', join(profile, 'envar.json'), '--envar-credentials', join(profile, 'envar.token'));
116
+ child = spawn(process.execPath, args, { stdio: 'inherit' });
117
+ let switching = false;
118
+ const timer = setInterval(() => {
119
+ try {
120
+ if (!switching && (managedProfile(base) !== profile || stamp() !== initialStamp)) {
121
+ switching = true;
122
+ child?.kill('SIGTERM');
123
+ }
124
+ }
125
+ catch {
126
+ stop();
127
+ }
128
+ }, 1000);
129
+ try {
130
+ const [code] = await once(child, 'exit');
131
+ if (!stopping && !switching)
132
+ throw new CommerceError('managed_runtime_exit', `Seller stopped (${code}); keep its profile and inspect its configuration`);
133
+ }
134
+ finally {
135
+ clearInterval(timer);
136
+ }
137
+ }
138
+ }
139
+ finally {
140
+ process.removeListener('SIGTERM', stop);
141
+ process.removeListener('SIGINT', stop);
142
+ }
143
+ }
@@ -0,0 +1,8 @@
1
+ export declare function readSkillInventory(roots: string[]): {
2
+ name: string;
3
+ path: string;
4
+ description: string;
5
+ supported: boolean;
6
+ digest: string;
7
+ files: number;
8
+ }[];
@@ -0,0 +1,72 @@
1
+ import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { extname, join, resolve } from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ import { parse } from 'yaml';
6
+ const namePattern = /^[a-z][a-z0-9-]{0,63}$/;
7
+ export function readSkillInventory(roots) {
8
+ const found = [];
9
+ const seen = new Set();
10
+ function visit(folder, depth) {
11
+ if (depth > 10 || found.length >= 128 || !existsSync(folder))
12
+ return;
13
+ const stat = lstatSync(folder);
14
+ if (!stat.isDirectory() || stat.isSymbolicLink())
15
+ return;
16
+ const entry = join(folder, 'SKILL.md');
17
+ if (!existsSync(entry)) {
18
+ for (const child of readdirSync(folder).sort())
19
+ if (!child.startsWith('.'))
20
+ visit(join(folder, child), depth + 1);
21
+ return;
22
+ }
23
+ const info = lstatSync(entry);
24
+ if (!info.isFile() || info.isSymbolicLink() || info.size > 48000)
25
+ return;
26
+ const raw = readFileSync(entry, 'utf8'), header = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(raw)?.[1];
27
+ let meta;
28
+ try {
29
+ meta = parse(header ?? '') ?? {};
30
+ }
31
+ catch {
32
+ return;
33
+ }
34
+ if (typeof meta.name !== 'string' || !namePattern.test(meta.name) || seen.has(meta.name))
35
+ return;
36
+ const files = [];
37
+ let size = 0, supported = true;
38
+ function collect(path, prefix = '', level = 0) {
39
+ if (level > 10) {
40
+ supported = false;
41
+ return;
42
+ }
43
+ for (const name of readdirSync(path).sort()) {
44
+ const file = join(path, name), s = lstatSync(file);
45
+ if (s.isSymbolicLink()) {
46
+ supported = false;
47
+ continue;
48
+ }
49
+ if (s.isDirectory()) {
50
+ collect(file, prefix + name + '/', level + 1);
51
+ continue;
52
+ }
53
+ if (!s.isFile() || s.size > 512 * 1024 || (size += s.size) > 1024 * 1024 || files.length >= 128) {
54
+ supported = false;
55
+ continue;
56
+ }
57
+ if (!['.md', '.txt'].includes(extname(name).toLowerCase()) && name !== 'LICENSE')
58
+ supported = false;
59
+ files.push([prefix + name, createHash('sha256').update(readFileSync(file)).digest('hex')]);
60
+ }
61
+ }
62
+ collect(folder);
63
+ files.sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
64
+ const canonical = JSON.stringify(files).replace(/[\u007f-\uffff]/g, c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
65
+ seen.add(meta.name);
66
+ found.push({ name: meta.name, path: resolve(folder), description: String(meta.description ?? '').slice(0, 500), supported, digest: createHash('sha256').update(canonical).digest('hex'), files: files.length });
67
+ }
68
+ for (const root of roots.slice(0, 16))
69
+ if (typeof root === 'string')
70
+ visit(resolve(root.replace(/^~(?=\/|$)/, homedir())), 0);
71
+ return found;
72
+ }
@@ -16,6 +16,7 @@ export interface NativeSkillSettings {
16
16
  discovery?: {
17
17
  hermesHome: string;
18
18
  enabledFromWeb: true;
19
+ roots?: string[];
19
20
  };
20
21
  }
21
22
  interface InstalledSkill {
@@ -0,0 +1,28 @@
1
+ export declare function openclawRoots(home?: string): string[];
2
+ export declare function openclawInventory(home?: string): {
3
+ name: string;
4
+ path: string;
5
+ description: string;
6
+ supported: boolean;
7
+ digest: string;
8
+ files: number;
9
+ }[];
10
+ export declare function probeOpenClaw(home?: string, python?: string): {
11
+ home: string;
12
+ python: string;
13
+ command: string[];
14
+ model: string;
15
+ baseUrl: any;
16
+ apiKeyFile: null;
17
+ apiKey: string;
18
+ skillRoots: string[];
19
+ skills: {
20
+ textPackage: boolean;
21
+ name: string;
22
+ path: string;
23
+ description: string;
24
+ supported: boolean;
25
+ digest: string;
26
+ files: number;
27
+ }[];
28
+ };
@@ -0,0 +1,43 @@
1
+ /** Read local OpenClaw configuration and installed Skill metadata without invoking a model. */
2
+ import { existsSync, lstatSync, readFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { readSkillInventory } from './native-inventory.js';
7
+ import { CommerceError } from './types.js';
8
+ function configuration(home) {
9
+ const path = home ? join(resolve(home), 'openclaw.json') : process.env.OPENCLAW_CONFIG_PATH ?? join(process.env.OPENCLAW_STATE_DIR ?? join(homedir(), '.openclaw'), 'openclaw.json');
10
+ const stat = lstatSync(path);
11
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024)
12
+ throw new CommerceError('setup_model', 'Use a bounded local OpenClaw configuration file');
13
+ return { home: dirname(resolve(path)), config: JSON.parse(readFileSync(path, 'utf8')) };
14
+ }
15
+ export function openclawRoots(home) {
16
+ const local = configuration(home), config = local.config, defaults = config.agents?.defaults ?? {};
17
+ const agent = config.agents?.list?.find((a) => a.default || a.id === 'main');
18
+ return [join(local.home, 'skills'), join(agent?.workspace ?? defaults.workspace ?? join(local.home, 'workspace'), 'skills'), ...(config.skills?.load?.extraDirs ?? [])].filter((r) => typeof r === 'string');
19
+ }
20
+ export function openclawInventory(home) { return readSkillInventory(openclawRoots(home)); }
21
+ export function probeOpenClaw(home, python = 'python3') {
22
+ const local = configuration(home), config = local.config;
23
+ const agent = config.agents?.list?.find((a) => a.default || a.id === 'main');
24
+ const selected = agent?.model ?? config.agents?.defaults?.model;
25
+ const primary = typeof selected === 'string' ? selected : selected?.primary;
26
+ if (typeof primary !== 'string' || !primary.includes('/'))
27
+ throw new CommerceError('setup_model', 'Configure an OpenAI-compatible OpenClaw model first');
28
+ const slash = primary.indexOf('/'), provider = config.models?.providers?.[primary.slice(0, slash)], model = primary.slice(slash + 1);
29
+ let key = provider?.apiKey;
30
+ if (typeof key === 'string') {
31
+ const env = /^\$\{([A-Z_][A-Z0-9_]*)\}$/.exec(key);
32
+ if (env)
33
+ key = process.env[env[1]];
34
+ }
35
+ else if (key?.source === 'env' && typeof key.id === 'string')
36
+ key = process.env[key.id];
37
+ if (typeof key !== 'string' || !key.trim() || typeof provider?.baseUrl !== 'string' || (provider.api ?? 'openai-completions') !== 'openai-completions')
38
+ throw new CommerceError('setup_model', 'Use an OpenAI-compatible provider with a locally available API key');
39
+ const command = existsSync('/app/openclaw.mjs') ? [process.execPath, '/app/openclaw.mjs'] : ['openclaw'];
40
+ execFileSync(python, ['--version'], { stdio: 'pipe', timeout: 10000 });
41
+ execFileSync(command[0], [...command.slice(1), '--version'], { stdio: 'pipe', timeout: 30000 });
42
+ return { home: local.home, python, command, model, baseUrl: provider.baseUrl, apiKeyFile: null, apiKey: key, skillRoots: openclawRoots(local.home), skills: openclawInventory(local.home).map(s => ({ ...s, textPackage: s.supported })) };
43
+ }
@@ -9,10 +9,17 @@ export interface SetupBundle {
9
9
  }
10
10
  export declare function parseSetupBundle(raw: unknown): SetupBundle;
11
11
  export declare function prepareTextSkill(source: string, destination: string, name: string): void;
12
- export declare function setupHermes(options: {
12
+ export declare function setupAgent(options: {
13
+ framework?: "hermes" | "openclaw";
14
+ managed?: boolean;
13
15
  file: string;
14
16
  directory?: string;
15
17
  container?: string;
16
18
  python?: string;
17
19
  home?: string;
20
+ automatic?: {
21
+ port: number;
22
+ origin: string;
23
+ };
18
24
  }): Promise<void>;
25
+ export declare const setupHermes: (options: Parameters<typeof setupAgent>[0]) => Promise<void>;
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  import { randomBytes } from "node:crypto";
7
7
  import { execFileSync } from "node:child_process";
8
8
  import { createInterface } from "node:readline/promises";
9
+ import { probeOpenClaw } from "./openclaw-setup.js";
9
10
  import { readInstalledSkills } from "./native-skills.js";
10
11
  import { loadCommerceConfig, validateUrl } from "./config.js";
11
12
  import { CommerceError } from "./types.js";
@@ -62,97 +63,117 @@ function privateJson(path, value) {
62
63
  flag: "wx",
63
64
  });
64
65
  }
65
- export async function setupHermes(options) {
66
+ export async function setupAgent(options) {
66
67
  const file = resolve(options.file), info = lstatSync(file);
67
68
  if (!info.isFile() || info.isSymbolicLink() || info.size > 16384)
68
69
  throw new CommerceError("setup_file", "Use the downloaded setup JSON file");
69
70
  const bundle = parseSetupBundle(JSON.parse(readFileSync(file, "utf8")));
70
71
  if (options.container && !CONTAINER.test(options.container))
71
72
  throw new CommerceError("setup_container", "Use a Docker container name");
72
- if (!process.stdin.isTTY)
73
+ if (!process.stdin.isTTY && !options.automatic)
73
74
  throw new CommerceError("setup_terminal", "Run setup in an interactive terminal to select skills");
74
75
  chmodSync(file, 0o600);
75
- const prompt = createInterface({
76
- input: process.stdin,
77
- output: process.stdout,
78
- });
76
+ const prompt = options.automatic ? undefined : createInterface({ input: process.stdin, output: process.stdout });
77
+ const output = options.automatic ? (..._args) => { } : console.log;
79
78
  const ask = async (label, fallback = "") => (await prompt.question(`${label}${fallback ? ` [${fallback}]` : ""}: `)).trim() || fallback;
80
79
  let staging;
81
80
  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")
81
+ output(`\nConnect ${bundle.agentName} to ${bundle.platformOrigin}\nModel credentials and tasks remain on this machine. Start with free services; no wallet is created.\n`);
82
+ if (!options.automatic && (await ask("Continue? (yes/no)", "no")).toLowerCase() !== "yes")
84
83
  return;
85
84
  const directory = resolve(options.directory ?? join(homedir(), ".envarpay", bundle.agentId));
86
85
  if (existsSync(directory))
87
86
  throw new CommerceError("setup_exists", "This Agent already has a local directory. Use its start script; never reset its ledger.");
87
+ const configuredFramework = process.env.ENVARPAY_FRAMEWORK;
88
+ const framework = options.framework ?? (configuredFramework === "hermes" || configuredFramework === "openclaw" ? configuredFramework : process.env.OPENCLAW_STATE_DIR ? "openclaw" : "hermes");
88
89
  let python = options.python;
89
- const compatibility = 'import inspect;from run_agent import AIAgent;assert all(k in inspect.signature(AIAgent).parameters for k in ["skip_context_files","skip_memory","skip_background_review","ephemeral_system_prompt","enabled_toolsets"])';
90
- if (!python && options.container) {
91
- for (const candidate of [
92
- "/opt/hermes-latest/bin/python",
93
- "/opt/hermes/.venv/bin/python",
94
- "python3",
95
- ]) {
96
- try {
97
- execFileSync("docker", ["exec", options.container, candidate, "-c", compatibility], { stdio: "pipe", timeout: 60000 });
98
- python = candidate;
99
- break;
100
- }
101
- catch {
102
- /* try the next compatible installed interpreter */
103
- }
104
- }
105
- if (!python)
106
- throw new CommerceError("setup_runtime", "No compatible Hermes interpreter found in this container. Update Hermes or pass --python PATH.");
107
- }
108
- python ??= await ask("Hermes Python executable", join(homedir(), "hermes-agent", ".venv", "bin", "python"));
109
- const probeSource = readFileSync(new URL("../../examples/hermes-setup-probe.py", import.meta.url), "utf8");
110
90
  let probe;
111
- try {
91
+ if (framework === "openclaw") {
112
92
  if (options.container)
113
- execFileSync("docker", ["exec", options.container, python, "-c", compatibility], { stdio: "pipe", timeout: 60000 });
114
- else
115
- execFileSync(python, ["-c", compatibility], {
116
- stdio: "pipe",
117
- timeout: 60000,
118
- });
119
- const args = ["-c", probeSource, ...(options.home ? [options.home] : [])];
120
- const data = options.container
121
- ? execFileSync("docker", ["exec", options.container, python, ...args], {
122
- stdio: "pipe",
123
- timeout: 30000,
124
- maxBuffer: 2 * 1024 * 1024,
125
- })
126
- : execFileSync(python, args, {
127
- stdio: "pipe",
128
- timeout: 30000,
129
- maxBuffer: 2 * 1024 * 1024,
130
- });
131
- probe = JSON.parse(data.toString());
93
+ throw new CommerceError("setup_runtime", "Run OpenClaw setup inside its own runtime");
94
+ python ??= "python3";
95
+ probe = probeOpenClaw(options.home, python);
132
96
  }
133
- catch {
134
- throw new CommerceError("setup_model", "Could not read Hermes. Check the container/Python path and configure an OpenAI-compatible model in Hermes first.");
97
+ else {
98
+ const compatibility = 'import inspect;from run_agent import AIAgent;assert all(k in inspect.signature(AIAgent).parameters for k in ["skip_context_files","skip_memory","skip_background_review","ephemeral_system_prompt","enabled_toolsets"])';
99
+ if (!python && options.container) {
100
+ for (const candidate of [
101
+ "/opt/hermes-latest/bin/python",
102
+ "/opt/hermes/.venv/bin/python",
103
+ "python3",
104
+ ]) {
105
+ try {
106
+ execFileSync("docker", ["exec", options.container, candidate, "-c", compatibility], { stdio: "pipe", timeout: 60000 });
107
+ python = candidate;
108
+ break;
109
+ }
110
+ catch {
111
+ /* try the next compatible installed interpreter */
112
+ }
113
+ }
114
+ if (!python)
115
+ throw new CommerceError("setup_runtime", "No compatible Hermes interpreter found in this container. Update Hermes or pass --python PATH.");
116
+ }
117
+ if (!python && options.automatic) {
118
+ 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)) {
119
+ try {
120
+ execFileSync(candidate, ["-c", compatibility], { stdio: "pipe", timeout: 30000 });
121
+ python = candidate;
122
+ break;
123
+ }
124
+ catch { }
125
+ }
126
+ if (!python)
127
+ throw new CommerceError("setup_runtime", "No compatible Hermes Python found. Pass its executable using --python.");
128
+ }
129
+ python ??= await ask("Hermes Python executable", join(homedir(), "hermes-agent", ".venv", "bin", "python"));
130
+ const probeSource = readFileSync(new URL("../../examples/hermes-setup-probe.py", import.meta.url), "utf8");
131
+ try {
132
+ if (options.container)
133
+ execFileSync("docker", ["exec", options.container, python, "-c", compatibility], { stdio: "pipe", timeout: 60000 });
134
+ else
135
+ execFileSync(python, ["-c", compatibility], {
136
+ stdio: "pipe",
137
+ timeout: 60000,
138
+ });
139
+ const args = ["-c", probeSource, ...(options.home ? [options.home] : [])];
140
+ const data = options.container
141
+ ? execFileSync("docker", ["exec", options.container, python, ...args], {
142
+ stdio: "pipe",
143
+ timeout: 30000,
144
+ maxBuffer: 2 * 1024 * 1024,
145
+ })
146
+ : execFileSync(python, args, {
147
+ stdio: "pipe",
148
+ timeout: 30000,
149
+ maxBuffer: 2 * 1024 * 1024,
150
+ });
151
+ probe = JSON.parse(data.toString());
152
+ }
153
+ catch {
154
+ throw new CommerceError("setup_model", "Could not read Hermes. Check the container/Python path and configure an OpenAI-compatible model in Hermes first.");
155
+ }
135
156
  }
136
157
  const candidates = probe.skills.filter((s) => s.textPackage && ID.test(s.name));
137
- console.log(`Model: ${probe.model}\nInstalled text packages (review their instructions before selecting):`);
138
- candidates.forEach((skill, i) => console.log(`${i + 1}. ${skill.name} — ${skill.description.replace(/[\r\n\x1b]/g, " ").slice(0, 160)}`));
139
- console.log('Newly installed Skills will be discovered privately. Enable each one from the Envar service page after reviewing text-only compatibility.');
140
- const selected = (await ask("Optional: enable existing text Skills now (comma separated, Enter to skip)"))
158
+ output(`Model: ${probe.model}\nInstalled text packages (review their instructions before selecting):`);
159
+ candidates.forEach((skill, i) => output(`${i + 1}. ${skill.name} — ${skill.description.replace(/[\r\n\x1b]/g, " ").slice(0, 160)}`));
160
+ output('Newly installed Skills will be discovered privately. Enable each one from the Envar service page after reviewing text-only compatibility.');
161
+ const selected = (options.automatic ? "" : await ask("Optional: enable existing text Skills now (comma separated, Enter to skip)"))
141
162
  .split(",")
142
163
  .map((s) => s.trim()).filter(Boolean);
143
164
  if (selected.length > 32 ||
144
165
  new Set(selected).size !== selected.length ||
145
166
  selected.some((name) => !candidates.some((s) => s.name === name)))
146
167
  throw new CommerceError("setup_skills", "Choose installed skill names from the list");
147
- console.log("This adapter supplies buyer text and bundled Markdown references. Browser, script, file and API tools are disabled. Original Hermes skills are preserved; a service copy is created.");
168
+ output("This adapter supplies buyer text and bundled Markdown references. Browser, script, file and API tools are disabled. Original Agent skills are preserved; a service copy is created.");
148
169
  if (selected.length &&
149
170
  (await ask("Do these selected skills work with text input and text output only? (yes/no)", "no")).toLowerCase() !== "yes")
150
171
  return;
151
- const port = Number(await ask("Local service port", "4020"));
172
+ const port = options.automatic?.port ?? Number(await ask("Local service port", "4020"));
152
173
  if (!Number.isInteger(port) || port < 1024 || port > 65535)
153
174
  throw new CommerceError("setup_port", "Choose a local port from 1024 to 65535");
154
175
  let origin;
155
- const tunnelMode = await ask("Public HTTPS: use an existing ngrok session or your own reverse proxy? (ngrok/url)", "ngrok");
176
+ const tunnelMode = options.automatic ? "url" : await ask("Public HTTPS: use an existing ngrok session or your own reverse proxy? (ngrok/url)", "ngrok");
156
177
  if (tunnelMode === "ngrok") {
157
178
  const api = new URL(await ask("Local ngrok API", "http://127.0.0.1:4040"));
158
179
  if (api.protocol !== "http:" ||
@@ -192,7 +213,7 @@ export async function setupHermes(options) {
192
213
  }
193
214
  }
194
215
  else if (tunnelMode === "url") {
195
- origin = await ask(`Public HTTPS origin (forward to http://127.0.0.1:${port})`);
216
+ origin = options.automatic?.origin ?? await ask(`Public HTTPS origin (forward to http://127.0.0.1:${port})`);
196
217
  }
197
218
  else
198
219
  throw new CommerceError("setup_tunnel", "Choose ngrok or url");
@@ -221,6 +242,13 @@ export async function setupHermes(options) {
221
242
  readInstalledSkills(skillsDirectory, [], origin + "/.well-known/agent-card.json");
222
243
  const access = randomBytes(36).toString("base64url");
223
244
  let apiKeyFile = probe.apiKeyFile;
245
+ if (options.managed && apiKeyFile) {
246
+ const stat = lstatSync(apiKeyFile);
247
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 16384)
248
+ throw new CommerceError("setup_model", "Invalid local model key file");
249
+ probe.apiKey = readFileSync(apiKeyFile, "utf8");
250
+ apiKeyFile = null;
251
+ }
224
252
  if (!apiKeyFile) {
225
253
  if (options.container) {
226
254
  apiKeyFile = join(probe.home, "envarpay-secrets", bundle.agentId, "model.key");
@@ -269,8 +297,9 @@ export async function setupHermes(options) {
269
297
  callers: { [access]: "owner" },
270
298
  upstreams: {},
271
299
  skills: {
272
- framework: "hermes",
273
- discovery: { hermesHome: probe.home, enabledFromWeb: true },
300
+ framework,
301
+ ...(probe.command ? { command: probe.command } : {}),
302
+ discovery: { hermesHome: probe.home, enabledFromWeb: true, ...(probe.skillRoots ? { roots: probe.skillRoots } : {}) },
274
303
  skillsDirectory: join(directory, "skills"),
275
304
  freeSkills: [],
276
305
  stateDirectory,
@@ -334,11 +363,13 @@ export async function setupHermes(options) {
334
363
  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
364
  renameSync(staging, directory);
336
365
  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.`);
366
+ 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
367
  }
339
368
  finally {
340
- prompt.close();
369
+ prompt?.close();
341
370
  if (staging)
342
371
  rmSync(staging, { recursive: true, force: true });
343
372
  }
344
373
  }
374
+ // Preserve the existing SDK helper name for callers using Hermes explicitly.
375
+ export const setupHermes = (options) => setupAgent({ ...options, framework: "hermes" });
@@ -18,7 +18,8 @@ def inspect(home):
18
18
  model = config.get("model", {})
19
19
  if isinstance(model, str):
20
20
  model = {"default": model}
21
- provider = config.get("providers", {}).get(model.get("provider"), {})
21
+ provider_id = str(model.get("provider", "")).removeprefix("custom:")
22
+ provider = config.get("providers", {}).get(provider_id, {})
22
23
  dotenv = {}
23
24
  if (home / ".env").exists():
24
25
  for line in (home / ".env").read_text().splitlines():
@@ -32,6 +33,8 @@ def inspect(home):
32
33
  key = (
33
34
  provider.get("api_key")
34
35
  or provider.get("key")
36
+ or dotenv.get(provider.get("key_env", ""))
37
+ or os.environ.get(provider.get("key_env", ""))
35
38
  or dotenv.get("OPENAI_API_KEY")
36
39
  or os.environ.get("OPENAI_API_KEY")
37
40
  )
@@ -90,6 +93,7 @@ def inspect(home):
90
93
  "apiKeyFile": key_file,
91
94
  "apiKey": None if key_file else key,
92
95
  "skills": skills,
96
+ "skillRoots": [str(root) for root in roots],
93
97
  }
94
98
 
95
99
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@envarai/envarpay",
3
- "version": "0.2.0-alpha.11",
3
+ "version": "0.2.0-alpha.13",
4
4
  "description": "Independent A2A service commerce, budgeted buyers and x402/MPP payments",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -68,7 +68,8 @@
68
68
  "ajv-formats": "3.0.1",
69
69
  "canonicalize": "2.1.0",
70
70
  "mppx": "0.13.1",
71
- "viem": "2.57.1"
71
+ "viem": "2.57.1",
72
+ "yaml": "2.8.2"
72
73
  },
73
74
  "devDependencies": {
74
75
  "@types/node": "^22.0.0",