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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ interface Invitation {
2
+ version: 1;
3
+ agentId: string;
4
+ platformOrigin: string;
5
+ setupToken: string;
6
+ }
7
+ export declare function invitation(value: any): Invitation;
8
+ export declare function agentSetup(options: {
9
+ file: string;
10
+ directory?: string;
11
+ python?: string;
12
+ home?: string;
13
+ container?: string;
14
+ origin?: string;
15
+ port?: number;
16
+ checkOnly?: boolean;
17
+ }): Promise<void>;
18
+ export {};
@@ -0,0 +1,189 @@
1
+ /** Agent-driven installation. Secrets stay in local files; receipts come from Envar. */
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync, openSync, closeSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join, resolve } from 'node:path';
5
+ import { createServer } from 'node:net';
6
+ import { spawn } from 'node:child_process';
7
+ import { setupHermes, parseSetupBundle } from './setup.js';
8
+ import { CommerceError } from './types.js';
9
+ function privateJson(file) {
10
+ const s = lstatSync(file);
11
+ if (!s.isFile() || s.isSymbolicLink() || s.size > 16384 || (s.mode & 0o077))
12
+ throw new CommerceError('setup_private_file', 'Save the setup invitation in an owner-only file (chmod600).');
13
+ return JSON.parse(readFileSync(file, 'utf8'));
14
+ }
15
+ export function invitation(value) {
16
+ if (value?.version !== 1 || !/^[0-9a-f-]{36}$/.test(value.agentId) || !/^envar_setup_[A-Za-z0-9_-]{43}$/.test(value.setupToken))
17
+ throw new CommerceError('setup_invitation', 'Copy a fresh setup prompt.');
18
+ const url = new URL(value.platformOrigin);
19
+ if (url.protocol !== 'https:' || url.origin !== value.platformOrigin || url.username || url.password)
20
+ throw new CommerceError('setup_origin', 'Use the exact Envar HTTPS origin.');
21
+ return value;
22
+ }
23
+ async function request(invite, path, data) {
24
+ const r = await fetch(invite.platformOrigin + '/api/v1/agent-setup/' + path, { method: 'POST', redirect: 'error', headers: { Authorization: 'Bearer ' + invite.setupToken, 'Content-Type': 'application/json' }, body: JSON.stringify(data), signal: AbortSignal.timeout(45000) });
25
+ if (!r.ok)
26
+ throw new CommerceError('setup_' + path, `Envar ${path} returned ${r.status}. Check connection or copy a fresh prompt; do not report success.`);
27
+ const text = await r.text();
28
+ if (text.length > 65536)
29
+ throw new CommerceError('setup_response', 'Envar returned an oversized response.');
30
+ return JSON.parse(text);
31
+ }
32
+ async function freePort() {
33
+ const socket = createServer();
34
+ await new Promise((resolve, reject) => { socket.once('error', reject); socket.listen(0, '127.0.0.1', resolve); });
35
+ const port = socket.address().port;
36
+ await new Promise(resolve => socket.close(() => resolve()));
37
+ return port;
38
+ }
39
+ function save(file, data) { writeFileSync(file, JSON.stringify(data, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); }
40
+ async function publicOrigin(root, port, agentId, supplied) {
41
+ if (supplied) {
42
+ const u = new URL(supplied);
43
+ if (u.protocol !== 'https:' || u.origin !== supplied)
44
+ throw Error('Use a public HTTPS origin');
45
+ return supplied;
46
+ }
47
+ // Use an available existing tunnel session, without deleting or rebinding any endpoint.
48
+ for (const apiPort of [4040, 4041, 4042, 4043]) {
49
+ try {
50
+ const api = `http://127.0.0.1:${apiPort}`;
51
+ const r = await fetch(api + '/api/tunnels', { signal: AbortSignal.timeout(1000) });
52
+ if (!r.ok)
53
+ continue;
54
+ const list = (await r.json());
55
+ const name = 'envarpay-' + agentId, addr = `http://127.0.0.1:${port}`;
56
+ const prior = list.tunnels?.find((t) => t.name === name);
57
+ if (prior) {
58
+ if (prior.config.addr !== addr)
59
+ continue;
60
+ return prior.public_url;
61
+ }
62
+ if (list.tunnels?.length >= 3)
63
+ continue;
64
+ const created = await fetch(api + '/api/tunnels', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, proto: 'http', addr, schemes: ['https'], inspect: false }), signal: AbortSignal.timeout(12000) });
65
+ if (created.ok)
66
+ return (await created.json()).public_url;
67
+ }
68
+ catch { }
69
+ }
70
+ // Outbound HTTPS works inside Docker too; never requires a Docker socket or host port mapping.
71
+ const log = join(root, 'tunnel.log');
72
+ if (existsSync(log))
73
+ privateLog(log);
74
+ const offset = existsSync(log) ? readFileSync(log, 'utf8').length : 0, fd = openSync(log, 'a', 0o600);
75
+ const tunnel = spawn('cloudflared', ['tunnel', '--url', `http://127.0.0.1:${port}`, '--no-autoupdate'], { detached: true, stdio: ['ignore', fd, fd] });
76
+ closeSync(fd);
77
+ let failed = false;
78
+ tunnel.on('error', () => { failed = true; });
79
+ tunnel.unref();
80
+ for (let i = 0; i < 45; i++) {
81
+ if (failed)
82
+ break;
83
+ await new Promise(resolve => setTimeout(resolve, 1000));
84
+ const url = readFileSync(log, 'utf8').slice(offset).match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com\b/)?.[0];
85
+ if (url) {
86
+ writeFileSync(join(root, 'tunnel-process.json'), JSON.stringify({ pid: tunnel.pid, origin: url, temporary: true }), { mode: 0o600 });
87
+ return url;
88
+ }
89
+ }
90
+ if (tunnel.pid)
91
+ try {
92
+ tunnel.kill();
93
+ }
94
+ catch { }
95
+ throw new CommerceError('setup_https', 'Install cloudflared from its official distribution, or supply --origin with an existing HTTPS forwarder. Preserve other tunnels.');
96
+ }
97
+ function privateLog(file) { const s = lstatSync(file); if (!s.isFile() || s.isSymbolicLink() || (s.mode & 0o077))
98
+ throw new CommerceError('setup_private_file', 'Runtime log must be an owner-only regular file.'); }
99
+ export async function agentSetup(options) {
100
+ const invite = invitation(privateJson(resolve(options.file)));
101
+ const profile = resolve(options.directory ?? join(homedir(), '.envarpay', invite.agentId));
102
+ const root = profile + '.onboarding';
103
+ if (options.checkOnly) {
104
+ const result = await request(invite, 'status', {});
105
+ if (result.agent_id !== invite.agentId)
106
+ throw new CommerceError('setup_identity', 'Receipt belongs to another Agent.');
107
+ console.log(JSON.stringify(result, null, 2));
108
+ if (result.status !== 'connected')
109
+ process.exitCode = 1;
110
+ return;
111
+ }
112
+ mkdirSync(root, { recursive: true, mode: 0o700 });
113
+ if (lstatSync(root).isSymbolicLink() || (lstatSync(root).mode & 0o077))
114
+ throw new CommerceError('setup_directory', 'Use a private directory owned by this user.');
115
+ const manifest = join(root, 'setup.json');
116
+ if (existsSync(manifest) && privateJson(manifest).agentId !== invite.agentId)
117
+ throw new CommerceError('setup_identity', 'This directory belongs to another Agent.');
118
+ const bundle = parseSetupBundle(await request(invite, 'claim', {}));
119
+ if (bundle.agentId !== invite.agentId || bundle.platformOrigin !== invite.platformOrigin)
120
+ throw new CommerceError('setup_identity', 'Envar returned another Agent identity.');
121
+ const bundleFile = join(root, 'bundle.json');
122
+ if (existsSync(bundleFile))
123
+ privateJson(bundleFile);
124
+ writeFileSync(bundleFile, JSON.stringify(bundle), { mode: 0o600 });
125
+ if (!existsSync(profile)) {
126
+ let settings = existsSync(manifest) ? privateJson(manifest) : undefined;
127
+ if (!settings) {
128
+ const port = options.port ?? await freePort();
129
+ const origin = await publicOrigin(root, port, invite.agentId, options.origin);
130
+ settings = { agentId: invite.agentId, port, origin };
131
+ save(manifest, settings);
132
+ }
133
+ await setupHermes({ file: bundleFile, directory: profile, python: options.python, home: options.home, container: options.container, automatic: settings });
134
+ }
135
+ if (lstatSync(profile).isSymbolicLink() || (lstatSync(profile).mode & 0o077))
136
+ throw new CommerceError('setup_directory', 'The runtime must be a private directory.');
137
+ const connection = privateJson(join(profile, 'connection.json'));
138
+ privateLog(join(profile, 'envar.token'));
139
+ const launcher = lstatSync(join(profile, 'start.mjs'));
140
+ if (!launcher.isFile() || launcher.isSymbolicLink() || (launcher.mode & 0o077))
141
+ throw new CommerceError('setup_private_file', 'Use the original private runtime launcher.');
142
+ if (connection.agentId !== invite.agentId)
143
+ throw new CommerceError('setup_identity', 'Existing runtime belongs to another Agent.');
144
+ const policy = privateJson(join(profile, 'envar.json')).policy;
145
+ if (policy.agentId !== invite.agentId || policy.platformOrigin !== invite.platformOrigin)
146
+ throw new CommerceError('setup_identity', 'Existing integration belongs to another Agent.');
147
+ const running = join(root, 'process.json');
148
+ let started = false;
149
+ if (existsSync(running))
150
+ try {
151
+ process.kill(privateJson(running).pid, 0);
152
+ started = true;
153
+ }
154
+ catch { }
155
+ if (started && readFileSync(join(profile, 'envar.token'), 'utf8') !== bundle.token)
156
+ throw new CommerceError('setup_running', 'Existing runtime uses an earlier credential. Stop its recorded process, keep its ledger, then retry this invitation.');
157
+ if (!started) {
158
+ privateLog(join(profile, 'envar.token'));
159
+ writeFileSync(join(profile, 'envar.token'), bundle.token, { mode: 0o600 });
160
+ const log = join(root, 'runtime.log');
161
+ if (existsSync(log))
162
+ privateLog(log);
163
+ const fd = openSync(log, 'a', 0o600), child = spawn(process.execPath, [join(profile, 'start.mjs')], { detached: true, stdio: ['ignore', fd, fd] });
164
+ closeSync(fd);
165
+ child.unref();
166
+ writeFileSync(running, JSON.stringify({ pid: child.pid }), { mode: 0o600 });
167
+ }
168
+ let result;
169
+ for (let attempt = 0; attempt < 12; attempt++) {
170
+ try {
171
+ result = await request(invite, 'connect', { agentId: invite.agentId, url: connection.url, authToken: connection.authToken });
172
+ if (result.status === 'connected')
173
+ break;
174
+ }
175
+ catch (e) {
176
+ if (attempt === 11)
177
+ throw e;
178
+ }
179
+ await new Promise(resolve => setTimeout(resolve, 3000));
180
+ }
181
+ result = await request(invite, 'status', {});
182
+ if (result.status !== 'connected' || result.agent_id !== invite.agentId || !result.verified_at)
183
+ throw new CommerceError('setup_unverified', 'Platform did not verify this setup. Keep files and report the blocker.');
184
+ const output = { ...result, local_directory: profile, receipt_file: join(root, 'receipt.json'), scope: 'connected; not published; no payment', restart: `node ${JSON.stringify(join(profile, 'start.mjs'))}`, temporary_tunnel: !options.origin, automatic_restart: false };
185
+ if (existsSync(join(root, 'receipt.json')))
186
+ privateJson(join(root, 'receipt.json'));
187
+ writeFileSync(join(root, 'receipt.json'), JSON.stringify(output, null, 2) + '\n', { mode: 0o600 });
188
+ console.log(JSON.stringify(output, null, 2));
189
+ }
@@ -24,14 +24,15 @@ import { inspectLedger, inspectLocalAlerts } from './operations.js';
24
24
  import { setupHermes } from './setup.js';
25
25
  import { SkillDiscovery } from './discovery.js';
26
26
  import { upgradeDiscovery, configurePayments } from './configure.js';
27
+ import { agentSetup } from './agent-setup.js';
27
28
  const { values, positionals } = parseArgs({ allowPositionals: true, options: {
28
29
  config: { type: 'string' }, service: { type: 'string' }, offer: { type: 'string' }, origin: { type: 'string' },
29
30
  version: { type: 'boolean' }, help: { type: 'boolean', short: 'h' }, state: { type: 'string' }, credentials: { type: 'string' }, host: { type: 'string' },
30
31
  port: { type: 'string' }, directory: { type: 'string' }, 'envar-config': { type: 'string' }, 'envar-credentials': { type: 'string' },
31
32
  after: { type: 'string' }, limit: { type: 'string' }, 'refund-review': { type: 'boolean' }, alerts: { type: 'boolean' }, 'fail-on-alert': { type: 'boolean' }, 'integration-state': { type: 'string' }, 'no-integration': { type: 'boolean' },
32
- file: { type: 'string' }, 'docker-container': { type: 'string' }, python: { type: 'string' }, 'hermes-home': { type: 'string' },
33
+ file: { type: 'string' }, 'docker-container': { type: 'string' }, python: { type: 'string' }, 'hermes-home': { type: 'string' }, check: { type: 'boolean' },
33
34
  } });
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';
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';
35
36
  const closers = [];
36
37
  function privateFile(path) {
37
38
  const stat = lstatSync(path);
@@ -91,6 +92,12 @@ async function run() {
91
92
  return;
92
93
  }
93
94
  const command = positionals[0];
95
+ if (command === 'onboard') {
96
+ if (!values.file)
97
+ throw new CommerceError('setup_file', 'Save the private invitation and pass --file PATH.');
98
+ await agentSetup({ file: values.file, directory: values.directory, python: values.python, home: values['hermes-home'], container: values['docker-container'], origin: values.origin, port: values.port ? port(4020) : undefined, checkOnly: values.check });
99
+ return;
100
+ }
94
101
  if (command === 'upgrade' && values.directory) {
95
102
  upgradeDiscovery(values.directory, values['hermes-home']);
96
103
  return;
@@ -15,4 +15,8 @@ export declare function setupHermes(options: {
15
15
  container?: string;
16
16
  python?: string;
17
17
  home?: string;
18
+ automatic?: {
19
+ port: number;
20
+ origin: string;
21
+ };
18
22
  }): Promise<void>;
@@ -69,18 +69,16 @@ export async function setupHermes(options) {
69
69
  const bundle = parseSetupBundle(JSON.parse(readFileSync(file, "utf8")));
70
70
  if (options.container && !CONTAINER.test(options.container))
71
71
  throw new CommerceError("setup_container", "Use a Docker container name");
72
- if (!process.stdin.isTTY)
72
+ if (!process.stdin.isTTY && !options.automatic)
73
73
  throw new CommerceError("setup_terminal", "Run setup in an interactive terminal to select skills");
74
74
  chmodSync(file, 0o600);
75
- const prompt = createInterface({
76
- input: process.stdin,
77
- output: process.stdout,
78
- });
75
+ const prompt = options.automatic ? undefined : createInterface({ input: process.stdin, output: process.stdout });
76
+ const output = options.automatic ? (..._args) => { } : console.log;
79
77
  const ask = async (label, fallback = "") => (await prompt.question(`${label}${fallback ? ` [${fallback}]` : ""}: `)).trim() || fallback;
80
78
  let staging;
81
79
  try {
82
- console.log(`\nConnect ${bundle.agentName} to ${bundle.platformOrigin}\nModel credentials and tasks remain on this machine. Start with free services; no wallet is created.\n`);
83
- if ((await ask("Continue? (yes/no)", "no")).toLowerCase() !== "yes")
80
+ output(`\nConnect ${bundle.agentName} to ${bundle.platformOrigin}\nModel credentials and tasks remain on this machine. Start with free services; no wallet is created.\n`);
81
+ if (!options.automatic && (await ask("Continue? (yes/no)", "no")).toLowerCase() !== "yes")
84
82
  return;
85
83
  const directory = resolve(options.directory ?? join(homedir(), ".envarpay", bundle.agentId));
86
84
  if (existsSync(directory))
@@ -105,6 +103,18 @@ export async function setupHermes(options) {
105
103
  if (!python)
106
104
  throw new CommerceError("setup_runtime", "No compatible Hermes interpreter found in this container. Update Hermes or pass --python PATH.");
107
105
  }
106
+ if (!python && options.automatic) {
107
+ for (const candidate of [process.env.VIRTUAL_ENV && join(process.env.VIRTUAL_ENV, "bin/python"), "/opt/hermes-latest/bin/python", "/opt/hermes/.venv/bin/python", join(homedir(), "hermes-agent/.venv/bin/python"), "python3"].filter(Boolean)) {
108
+ try {
109
+ execFileSync(candidate, ["-c", compatibility], { stdio: "pipe", timeout: 30000 });
110
+ python = candidate;
111
+ break;
112
+ }
113
+ catch { }
114
+ }
115
+ if (!python)
116
+ throw new CommerceError("setup_runtime", "No compatible Hermes Python found. Pass its executable using --python.");
117
+ }
108
118
  python ??= await ask("Hermes Python executable", join(homedir(), "hermes-agent", ".venv", "bin", "python"));
109
119
  const probeSource = readFileSync(new URL("../../examples/hermes-setup-probe.py", import.meta.url), "utf8");
110
120
  let probe;
@@ -134,25 +144,25 @@ export async function setupHermes(options) {
134
144
  throw new CommerceError("setup_model", "Could not read Hermes. Check the container/Python path and configure an OpenAI-compatible model in Hermes first.");
135
145
  }
136
146
  const candidates = probe.skills.filter((s) => s.textPackage && ID.test(s.name));
137
- console.log(`Model: ${probe.model}\nInstalled text packages (review their instructions before selecting):`);
138
- candidates.forEach((skill, i) => console.log(`${i + 1}. ${skill.name} — ${skill.description.replace(/[\r\n\x1b]/g, " ").slice(0, 160)}`));
139
- 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)"))
147
+ output(`Model: ${probe.model}\nInstalled text packages (review their instructions before selecting):`);
148
+ candidates.forEach((skill, i) => output(`${i + 1}. ${skill.name} — ${skill.description.replace(/[\r\n\x1b]/g, " ").slice(0, 160)}`));
149
+ output('Newly installed Skills will be discovered privately. Enable each one from the Envar service page after reviewing text-only compatibility.');
150
+ const selected = (options.automatic ? "" : await ask("Optional: enable existing text Skills now (comma separated, Enter to skip)"))
141
151
  .split(",")
142
152
  .map((s) => s.trim()).filter(Boolean);
143
153
  if (selected.length > 32 ||
144
154
  new Set(selected).size !== selected.length ||
145
155
  selected.some((name) => !candidates.some((s) => s.name === name)))
146
156
  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.");
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.");
148
158
  if (selected.length &&
149
159
  (await ask("Do these selected skills work with text input and text output only? (yes/no)", "no")).toLowerCase() !== "yes")
150
160
  return;
151
- const port = Number(await ask("Local service port", "4020"));
161
+ const port = options.automatic?.port ?? Number(await ask("Local service port", "4020"));
152
162
  if (!Number.isInteger(port) || port < 1024 || port > 65535)
153
163
  throw new CommerceError("setup_port", "Choose a local port from 1024 to 65535");
154
164
  let origin;
155
- const tunnelMode = await ask("Public HTTPS: use an existing ngrok session or your own reverse proxy? (ngrok/url)", "ngrok");
165
+ const tunnelMode = options.automatic ? "url" : await ask("Public HTTPS: use an existing ngrok session or your own reverse proxy? (ngrok/url)", "ngrok");
156
166
  if (tunnelMode === "ngrok") {
157
167
  const api = new URL(await ask("Local ngrok API", "http://127.0.0.1:4040"));
158
168
  if (api.protocol !== "http:" ||
@@ -192,7 +202,7 @@ export async function setupHermes(options) {
192
202
  }
193
203
  }
194
204
  else if (tunnelMode === "url") {
195
- origin = await ask(`Public HTTPS origin (forward to http://127.0.0.1:${port})`);
205
+ origin = options.automatic?.origin ?? await ask(`Public HTTPS origin (forward to http://127.0.0.1:${port})`);
196
206
  }
197
207
  else
198
208
  throw new CommerceError("setup_tunnel", "Choose ngrok or url");
@@ -334,10 +344,10 @@ export async function setupHermes(options) {
334
344
  writeFileSync(join(staging, "README.md"), `# ${bundle.agentName}\n\nStart: node ${JSON.stringify(start)}\n\nKeep the terminal, Hermes, and HTTPS tunnel running. Import connection.json in the Envar setup page, then check the connection. Choose a skill and save a free service. Request publication; this process applies the selected service automatically. Return to Envar and check/publish.\n\nOnly free service updates are authorized. Receiving and payment setup is separate. Platform token expires ${bundle.expiresAt}. Keep the original files and ledger when restarting.\n`, { mode: 0o600, flag: "wx" });
335
345
  renameSync(staging, directory);
336
346
  staging = undefined;
337
- console.log(`\nReady. Start the local service:\n${process.execPath} ${JSON.stringify(start)}\n\nThen import this connection file in Envar:\n${join(directory, "connection.json")}\n\nKeep the service and HTTPS tunnel online. No task or payment was performed.`);
347
+ output(`\nReady. Start the local service:\n${process.execPath} ${JSON.stringify(start)}\n\nThen import this connection file in Envar:\n${join(directory, "connection.json")}\n\nKeep the service and HTTPS tunnel online. No task or payment was performed.`);
338
348
  }
339
349
  finally {
340
- prompt.close();
350
+ prompt?.close();
341
351
  if (staging)
342
352
  rmSync(staging, { recursive: true, force: true });
343
353
  }
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.12",
4
4
  "description": "Independent A2A service commerce, budgeted buyers and x402/MPP payments",
5
5
  "type": "module",
6
6
  "license": "MIT",