@envarai/envarpay 0.2.0-alpha.12 → 0.2.0-alpha.14

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.
@@ -14,5 +14,6 @@ export declare function agentSetup(options: {
14
14
  origin?: string;
15
15
  port?: number;
16
16
  checkOnly?: boolean;
17
+ framework?: "hermes" | "openclaw";
17
18
  }): Promise<void>;
18
19
  export {};
@@ -4,7 +4,8 @@ import { homedir } from 'node:os';
4
4
  import { join, resolve } from 'node:path';
5
5
  import { createServer } from 'node:net';
6
6
  import { spawn } from 'node:child_process';
7
- import { setupHermes, parseSetupBundle } from './setup.js';
7
+ import { activateManagedProfile, managedProfile } from './managed.js';
8
+ import { setupAgent, parseSetupBundle } from './setup.js';
8
9
  import { CommerceError } from './types.js';
9
10
  function privateJson(file) {
10
11
  const s = lstatSync(file);
@@ -98,7 +99,20 @@ function privateLog(file) { const s = lstatSync(file); if (!s.isFile() || s.isSy
98
99
  throw new CommerceError('setup_private_file', 'Runtime log must be an owner-only regular file.'); }
99
100
  export async function agentSetup(options) {
100
101
  const invite = invitation(privateJson(resolve(options.file)));
101
- const profile = resolve(options.directory ?? join(homedir(), '.envarpay', invite.agentId));
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
+ }
102
116
  const root = profile + '.onboarding';
103
117
  if (options.checkOnly) {
104
118
  const result = await request(invite, 'status', {});
@@ -130,7 +144,7 @@ export async function agentSetup(options) {
130
144
  settings = { agentId: invite.agentId, port, origin };
131
145
  save(manifest, settings);
132
146
  }
133
- await setupHermes({ file: bundleFile, directory: profile, python: options.python, home: options.home, container: options.container, automatic: settings });
147
+ await setupAgent({ framework: options.framework, managed: !!managedRoot, file: bundleFile, directory: profile, python: options.python, home: options.home, container: options.container, automatic: settings });
134
148
  }
135
149
  if (lstatSync(profile).isSymbolicLink() || (lstatSync(profile).mode & 0o077))
136
150
  throw new CommerceError('setup_directory', 'The runtime must be a private directory.');
@@ -144,26 +158,33 @@ export async function agentSetup(options) {
144
158
  const policy = privateJson(join(profile, 'envar.json')).policy;
145
159
  if (policy.agentId !== invite.agentId || policy.platformOrigin !== invite.platformOrigin)
146
160
  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;
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 });
153
187
  }
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
188
  }
168
189
  let result;
169
190
  for (let attempt = 0; attempt < 12; attempt++) {
@@ -181,7 +202,7 @@ export async function agentSetup(options) {
181
202
  result = await request(invite, 'status', {});
182
203
  if (result.status !== 'connected' || result.agent_id !== invite.agentId || !result.verified_at)
183
204
  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 };
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 };
185
206
  if (existsSync(join(root, 'receipt.json')))
186
207
  privateJson(join(root, 'receipt.json'));
187
208
  writeFileSync(join(root, 'receipt.json'), JSON.stringify(output, null, 2) + '\n', { mode: 0o600 });
@@ -14,7 +14,7 @@ export interface ExpiredUnusedProof {
14
14
  authorizationUsed: false;
15
15
  }
16
16
  /** Checks the canonical chain receipt and exact official-token Transfer. */
17
- export declare function evmReceiptVerifier(rpcUrls: Readonly<Record<string, string>>, confirmations?: number): (receipt: SettleResponse, payload: PaymentPayload, requirements: PaymentRequirements) => Promise<boolean>;
17
+ export declare function evmReceiptVerifier(rpcUrls: Readonly<Record<string, string>>, confirmations?: number, confirmationWaitMs?: number): (receipt: SettleResponse, payload: PaymentPayload, requirements: PaymentRequirements) => Promise<boolean>;
18
18
  /** Locate an original EIP-3009 settlement even when facilitator lost its tx response. */
19
19
  export declare function evmSettlementRecovery(rpcUrls: Readonly<Record<string, string>>): {
20
20
  expiredUnusedProof: (payload: PaymentPayload, requirements: PaymentRequirements) => Promise<ExpiredUnusedProof | undefined>;
@@ -1,4 +1,4 @@
1
- import { createPublicClient, http, decodeEventLog, parseAbi } from 'viem';
1
+ import { createPublicClient, http, decodeEventLog, parseAbi, TransactionReceiptNotFoundError } from 'viem';
2
2
  import { CommerceError } from './types.js';
3
3
  import { validateUrl } from './config.js';
4
4
  const abi = parseAbi(['event Transfer(address indexed from, address indexed to, uint256 value)']);
@@ -9,9 +9,11 @@ const officialUsdc = {
9
9
  'eip155:8453': '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913',
10
10
  };
11
11
  /** Checks the canonical chain receipt and exact official-token Transfer. */
12
- export function evmReceiptVerifier(rpcUrls, confirmations = 2) {
12
+ export function evmReceiptVerifier(rpcUrls, confirmations = 2, confirmationWaitMs = 12000) {
13
13
  if (!Number.isInteger(confirmations) || confirmations < 1)
14
14
  throw new CommerceError('confirmation_policy', 'Positive confirmation depth required');
15
+ if (!Number.isInteger(confirmationWaitMs) || confirmationWaitMs < 0 || confirmationWaitMs > 30000)
16
+ throw new CommerceError('confirmation_policy', 'Confirmation wait must be between 0 and 30000 milliseconds');
15
17
  return async (receipt, payload, requirements) => {
16
18
  const rpc = rpcUrls[requirements.network];
17
19
  if (!rpc)
@@ -23,15 +25,38 @@ export function evmReceiptVerifier(rpcUrls, confirmations = 2) {
23
25
  const client = createPublicClient({ transport: http(rpc, { timeout: 15000, retryCount: 0 }) });
24
26
  if (await client.getChainId() !== chainId)
25
27
  return false;
26
- const mined = await client.getTransactionReceipt({ hash: receipt.transaction });
27
- if (mined.status !== 'success')
28
- return false;
29
- const block = await client.getBlock({ blockNumber: mined.blockNumber });
30
- if (block.hash !== mined.blockHash)
31
- return false;
32
- const latest = await client.getBlockNumber();
33
- if (latest - mined.blockNumber + 1n < BigInt(confirmations))
34
- return false;
28
+ const deadline = Date.now() + confirmationWaitMs;
29
+ const wait = async () => {
30
+ const remaining = deadline - Date.now();
31
+ if (remaining <= 0)
32
+ return false;
33
+ await new Promise(resolve => setTimeout(resolve, Math.min(1000, remaining)));
34
+ return Date.now() < deadline;
35
+ };
36
+ let mined;
37
+ for (;;) {
38
+ try {
39
+ mined = await client.getTransactionReceipt({ hash: receipt.transaction });
40
+ }
41
+ catch (error) {
42
+ if (error instanceof TransactionReceiptNotFoundError) {
43
+ if (await wait())
44
+ continue;
45
+ return false;
46
+ }
47
+ throw error;
48
+ }
49
+ if (mined.status !== 'success' || mined.transactionHash.toLowerCase() !== receipt.transaction.toLowerCase())
50
+ return false;
51
+ const block = await client.getBlock({ blockNumber: mined.blockNumber });
52
+ if (block.hash !== mined.blockHash)
53
+ return false;
54
+ const latest = await client.getBlockNumber({ cacheTime: 0 });
55
+ if (latest - mined.blockNumber + 1n >= BigInt(confirmations))
56
+ break;
57
+ if (!await wait())
58
+ return false;
59
+ }
35
60
  const auth = payload.payload.authorization;
36
61
  if (!auth?.from || !auth.nonce || auth.to?.toLowerCase() !== requirements.payTo.toLowerCase() || auth.value !== requirements.amount)
37
62
  return false;
@@ -131,8 +156,9 @@ export function evmSettlementRecovery(rpcUrls) {
131
156
  // RPC lookup; never reinterpret incomplete search as proof of non-payment.
132
157
  if (latest - BigInt(fromBlock) > 100000n)
133
158
  throw new CommerceError('archive_reconciliation_required', 'Original authorization requires an archive log lookup');
134
- for (let start = BigInt(fromBlock); start <= latest; start += 2000n) {
135
- const end = start + 1999n < latest ? start + 1999n : latest;
159
+ // The default public Base RPCs limit eth_getLogs to 200-block ranges.
160
+ for (let start = BigInt(fromBlock); start <= latest; start += 200n) {
161
+ const end = start + 199n < latest ? start + 199n : latest;
136
162
  const logs = await client.getLogs({ address: requirements.asset, event: authorizationAbi[0], args: { authorizer: auth.from, nonce: auth.nonce }, fromBlock: start, toBlock: end, strict: true });
137
163
  const settled = logs.filter(log => !log.removed && log.transactionHash);
138
164
  if (settled.length > 1)
@@ -21,7 +21,8 @@ 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';
27
28
  import { agentSetup } from './agent-setup.js';
@@ -30,9 +31,9 @@ const { values, positionals } = parseArgs({ allowPositionals: true, options: {
30
31
  version: { type: 'boolean' }, help: { type: 'boolean', short: 'h' }, state: { type: 'string' }, credentials: { type: 'string' }, host: { type: 'string' },
31
32
  port: { type: 'string' }, directory: { type: 'string' }, 'envar-config': { type: 'string' }, 'envar-credentials': { type: 'string' },
32
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' },
33
- file: { type: 'string' }, 'docker-container': { type: 'string' }, python: { type: 'string' }, 'hermes-home': { type: 'string' }, check: { type: 'boolean' },
34
+ framework: { type: 'string' }, file: { type: 'string' }, 'docker-container': { type: 'string' }, python: { type: 'string' }, 'hermes-home': { type: 'string' }, check: { type: 'boolean' },
34
35
  } });
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';
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';
36
37
  const closers = [];
37
38
  function privateFile(path) {
38
39
  const stat = lstatSync(path);
@@ -92,10 +93,18 @@ async function run() {
92
93
  return;
93
94
  }
94
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
+ }
95
104
  if (command === 'onboard') {
96
105
  if (!values.file)
97
106
  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 });
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 });
99
108
  return;
100
109
  }
101
110
  if (command === 'upgrade' && values.directory) {
@@ -109,7 +118,7 @@ async function run() {
109
118
  if (command === 'setup') {
110
119
  if (!values.file)
111
120
  throw new CommerceError('setup_file', 'Download the setup file from your Agent service page and pass --file PATH');
112
- 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'] });
113
122
  return;
114
123
  }
115
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,7 +9,9 @@ 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;
@@ -20,3 +22,4 @@ export declare function setupHermes(options: {
20
22
  origin: string;
21
23
  };
22
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,7 +63,7 @@ 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");
@@ -83,65 +84,75 @@ export async function setupHermes(options) {
83
84
  const directory = resolve(options.directory ?? join(homedir(), ".envarpay", bundle.agentId));
84
85
  if (existsSync(directory))
85
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");
86
89
  let python = options.python;
87
- 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"])';
88
- if (!python && options.container) {
89
- for (const candidate of [
90
- "/opt/hermes-latest/bin/python",
91
- "/opt/hermes/.venv/bin/python",
92
- "python3",
93
- ]) {
94
- try {
95
- execFileSync("docker", ["exec", options.container, candidate, "-c", compatibility], { stdio: "pipe", timeout: 60000 });
96
- python = candidate;
97
- break;
98
- }
99
- catch {
100
- /* try the next compatible installed interpreter */
90
+ let probe;
91
+ if (framework === "openclaw") {
92
+ if (options.container)
93
+ throw new CommerceError("setup_runtime", "Run OpenClaw setup inside its own runtime");
94
+ python ??= "python3";
95
+ probe = probeOpenClaw(options.home, python);
96
+ }
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
+ }
101
113
  }
114
+ if (!python)
115
+ throw new CommerceError("setup_runtime", "No compatible Hermes interpreter found in this container. Update Hermes or pass --python PATH.");
102
116
  }
103
- if (!python)
104
- throw new CommerceError("setup_runtime", "No compatible Hermes interpreter found in this container. Update Hermes or pass --python PATH.");
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;
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 { }
112
125
  }
113
- catch { }
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.");
114
155
  }
115
- if (!python)
116
- throw new CommerceError("setup_runtime", "No compatible Hermes Python found. Pass its executable using --python.");
117
- }
118
- python ??= await ask("Hermes Python executable", join(homedir(), "hermes-agent", ".venv", "bin", "python"));
119
- const probeSource = readFileSync(new URL("../../examples/hermes-setup-probe.py", import.meta.url), "utf8");
120
- let probe;
121
- try {
122
- if (options.container)
123
- execFileSync("docker", ["exec", options.container, python, "-c", compatibility], { stdio: "pipe", timeout: 60000 });
124
- else
125
- execFileSync(python, ["-c", compatibility], {
126
- stdio: "pipe",
127
- timeout: 60000,
128
- });
129
- const args = ["-c", probeSource, ...(options.home ? [options.home] : [])];
130
- const data = options.container
131
- ? execFileSync("docker", ["exec", options.container, python, ...args], {
132
- stdio: "pipe",
133
- timeout: 30000,
134
- maxBuffer: 2 * 1024 * 1024,
135
- })
136
- : execFileSync(python, args, {
137
- stdio: "pipe",
138
- timeout: 30000,
139
- maxBuffer: 2 * 1024 * 1024,
140
- });
141
- probe = JSON.parse(data.toString());
142
- }
143
- catch {
144
- throw new CommerceError("setup_model", "Could not read Hermes. Check the container/Python path and configure an OpenAI-compatible model in Hermes first.");
145
156
  }
146
157
  const candidates = probe.skills.filter((s) => s.textPackage && ID.test(s.name));
147
158
  output(`Model: ${probe.model}\nInstalled text packages (review their instructions before selecting):`);
@@ -154,7 +165,7 @@ export async function setupHermes(options) {
154
165
  new Set(selected).size !== selected.length ||
155
166
  selected.some((name) => !candidates.some((s) => s.name === name)))
156
167
  throw new CommerceError("setup_skills", "Choose installed skill names from the list");
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.");
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.");
158
169
  if (selected.length &&
159
170
  (await ask("Do these selected skills work with text input and text output only? (yes/no)", "no")).toLowerCase() !== "yes")
160
171
  return;
@@ -231,6 +242,13 @@ export async function setupHermes(options) {
231
242
  readInstalledSkills(skillsDirectory, [], origin + "/.well-known/agent-card.json");
232
243
  const access = randomBytes(36).toString("base64url");
233
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
+ }
234
252
  if (!apiKeyFile) {
235
253
  if (options.container) {
236
254
  apiKeyFile = join(probe.home, "envarpay-secrets", bundle.agentId, "model.key");
@@ -279,8 +297,9 @@ export async function setupHermes(options) {
279
297
  callers: { [access]: "owner" },
280
298
  upstreams: {},
281
299
  skills: {
282
- framework: "hermes",
283
- 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 } : {}) },
284
303
  skillsDirectory: join(directory, "skills"),
285
304
  freeSkills: [],
286
305
  stateDirectory,
@@ -352,3 +371,5 @@ export async function setupHermes(options) {
352
371
  rmSync(staging, { recursive: true, force: true });
353
372
  }
354
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.12",
3
+ "version": "0.2.0-alpha.14",
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",