@ola-893/agentpay-cli 0.2.0 → 0.4.0

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,369 @@
1
+ import { open, unlink } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { setTimeout as sleep } from 'node:timers/promises';
5
+ import { assessQuote } from '@ola-893/agentpay-core';
6
+ import { gatewayFor } from './config.mjs';
7
+ import { settings, setService } from './settings.mjs';
8
+ import { agentSetupState, readAgentState, withAgentState } from './agents.mjs';
9
+ import { checkQuote, operationKey, proposedOrder, rawAmount, spot } from './copy-activity.mjs';
10
+ import { credential, intentsAdapter, minimumOutput } from './copy-intents.mjs';
11
+ import { appendFill, checkListingQuote, fetchFeed, feedPath, getListing, healthPath, originOf, quotePath, readJsonResponse, serveFeed, verifyPage } from './copy-feed.mjs';
12
+
13
+ const addHistory = (state, item) => {
14
+ state.history.push({ at: new Date().toISOString(), ...item });
15
+ if (state.history.length > 500) state.history.shift();
16
+ };
17
+
18
+ function current(state) {
19
+ if (!state.follow) throw new Error('No copy subscription. Run agentpay copy start first.');
20
+ return state.follow;
21
+ }
22
+
23
+ async function gateway(agent, work) {
24
+ const client = await gatewayFor(agent.path);
25
+ try { return await work(client); }
26
+ finally { await client.dispose(); }
27
+ }
28
+
29
+ async function offer(agent, state, id, url) {
30
+ if (id === agent.id) throw new Error('Choose another agent to copy.');
31
+ const source = url ?? state.directory[id];
32
+ if (!source) throw new Error('Supply --url with the publisher’s feed origin on first lookup.');
33
+ return getListing(id, originOf(source));
34
+ }
35
+
36
+ async function readQuote(listing, config) {
37
+ const quote = await fetchFeed(listing.origin, `${quotePath(config.vaultId)}?tier=basic`);
38
+ return { quote, hash: checkListingQuote(listing, config.vaultId, config.tokenId, quote) };
39
+ }
40
+
41
+ export async function previewCopy(agent, id, url) {
42
+ const state = await readAgentState(agent);
43
+ const listing = await offer(agent, state, id, url);
44
+ const maxEscrowRawUSDC = (BigInt(listing.rate) * BigInt(listing.duration)).toString();
45
+ let admission = { decision: 'DENY', reason: 'VAULT_SETUP_REQUIRED' };
46
+ if ((await agentSetupState(agent)).configured) {
47
+ const { config } = await settings(agent.path);
48
+ admission = await gateway(agent, async client => {
49
+ const [{ vault }, { quote }, health] = await Promise.all([client.status(), readQuote(listing, config), fetchFeed(listing.origin, healthPath)]);
50
+ return assessQuote({ vault: { ...vault, vault_id: config.vaultId }, quote, health });
51
+ });
52
+ }
53
+ return { listing, maxEscrowRawUSDC, admission, signedTransactions: 0, next: 'agentpay copy start ' + id + ' --url ' + listing.origin };
54
+ }
55
+
56
+ async function openCopy(agent, state, save, listing, mode) {
57
+ const { profile, config } = await settings(agent.path);
58
+ if (!profile.paymentsEnabled) throw new Error('PAYMENTS_DISABLED: run agentpay payments --enable after checking the subscription limits.');
59
+ const { hash, quote } = await readQuote(listing, config);
60
+ if (!config.runtimePolicy.reuseStream) throw new Error('Enable reuse with agentpay config set --reuse true.');
61
+ await setService(agent.path, { url: listing.origin, recipient: listing.recipient, healthPath, quotePath: quotePath(config.vaultId, quote.expires_at), requestPath: feedPath, tiers: ['basic'] });
62
+ return gateway(agent, async client => {
63
+ const checked = await client.check({ tier: 'basic', expectedQuoteHash: hash });
64
+ if (checked.decision !== 'OPEN') throw new Error(checked.reason ?? 'COPY_OPEN_DENIED');
65
+ state.directory[listing.id] = listing.origin;
66
+ state.follow = { listing, status: 'opening', mode, cursor: state.cursors?.[listing.id] ?? 0, failures: 0, startedAt: Date.now() };
67
+ await save();
68
+ const opened = await client.open({ approvalId: checked.approvalId });
69
+ Object.assign(state.follow, { status: 'active', sessionId: opened.sessionId, openHash: opened.receipt.hash });
70
+ return { ...state.follow, payment: opened.receipt, explorerUrl: `https://testnet.nearblocks.io/txns/${opened.receipt.hash}` };
71
+ });
72
+ }
73
+
74
+ export async function startCopy(agent, { id, url, activity, resume = false }) {
75
+ return withAgentState(agent, async (state, save) => {
76
+ const follow = state.follow;
77
+ if (resume ? follow?.status !== 'paused' : ['active', 'opening', 'closing'].includes(follow?.status)) throw new Error('Resolve or close the current subscription before opening another.');
78
+ if (activity && activity !== spot) throw new Error('Only spot-trading can be copied in this release.');
79
+ const listing = await offer(agent, state, resume ? follow.listing.id : id, resume ? follow.listing.origin : url);
80
+ if (resume && ['recipient', 'rate', 'duration'].some(field => follow.listing[field] !== listing[field])) throw new Error('Offer changed. Preview it and start a new subscription to accept new terms.');
81
+ return openCopy(agent, state, save, listing, 'observe');
82
+ });
83
+ }
84
+
85
+ async function closeCopy(agent, state, save, status, reason, client) {
86
+ const follow = current(state);
87
+ if (!['active', 'opening', 'closing'].includes(follow.status)) {
88
+ follow.status = status;
89
+ return follow;
90
+ }
91
+ follow.status = 'closing';
92
+ follow.closeTarget = status;
93
+ follow.reason = reason;
94
+ await save();
95
+ const close = async activeClient => {
96
+ const context = await activeClient.status();
97
+ if (context.vault.active_session && (!context.reusable || (follow.sessionId && context.vault.active_session !== follow.sessionId))) throw new Error('ACTIVE_SESSION_RECOVERY_REQUIRED');
98
+ return context.vault.active_session ? activeClient.close({ sessionId: context.vault.active_session }) : { alreadyClosed: true };
99
+ };
100
+ const result = client ? await close(client) : await gateway(agent, close);
101
+ follow.status = status;
102
+ follow.closeHash = result.receipt?.hash ?? null;
103
+ for (const item of Object.values(state.pending ?? {})) {
104
+ if (item.status === 'pending' || item.status === 'approved') item.status = 'cancelled';
105
+ }
106
+ addHistory(state, { type: 'subscription', status, reason, sessionId: follow.sessionId, transactionHash: follow.closeHash });
107
+ return { ...follow, ...(result.receipt ? { receipt: result.receipt, explorerUrl: `https://testnet.nearblocks.io/txns/${result.receipt.hash}` } : {}) };
108
+ }
109
+
110
+ export async function stopCopy(agent, status, reason = 'OPERATOR_REQUEST') {
111
+ return withAgentState(agent, (state, save) => closeCopy(agent, state, save, status, reason));
112
+ }
113
+
114
+ export async function changeMode(agent, mode) {
115
+ if (!['observe', 'approve', 'auto'].includes(mode)) throw new Error('Choose observe, approve or auto.');
116
+ return withAgentState(agent, state => {
117
+ const follow = current(state);
118
+ if (follow.status !== 'active') throw new Error('Subscription is not active.');
119
+ if (mode !== 'observe' && (!state.tasks[spot]?.live || !state.executionPolicy)) throw new Error('Enable the live Intents adapter and set an execution policy first.');
120
+ if (mode !== 'approve') for (const item of Object.values(state.pending ?? {})) {
121
+ if (item.status === 'pending' || item.status === 'approved') item.status = 'cancelled';
122
+ }
123
+ follow.mode = mode;
124
+ return { mode, sessionId: follow.sessionId };
125
+ });
126
+ }
127
+
128
+ export async function decideAction(agent, id, decision) {
129
+ return withAgentState(agent, state => {
130
+ const follow = current(state);
131
+ const item = state.pending?.[id];
132
+ if (follow.status !== 'active' || !item || item.sessionId !== follow.sessionId || item.status !== 'pending') throw new Error('Action is not pending for the active subscription.');
133
+ if (decision === 'approved' && follow.mode !== 'approve') throw new Error('Use copy mode approve before approving actions.');
134
+ if (Date.now() >= item.action.expiresAt) throw new Error('ACTION_EXPIRED');
135
+ item.status = decision;
136
+ addHistory(state, { type: 'decision', actionId: id, status: decision });
137
+ return { actionId: id, status: decision };
138
+ });
139
+ }
140
+
141
+ async function executeSwap(agent, state, save, adapter, order, record) {
142
+ if (state.tradePending) throw new Error('TRADE_RECONCILIATION_REQUIRED');
143
+ credential(state.tasks[spot].grantEnv);
144
+ const key = operationKey(agent.id, record.id);
145
+ state.tradePending = { ...record, order, key, createdAt: Date.now() };
146
+ await save();
147
+ const first = await adapter.execute(order, key);
148
+ if (first.correlation_id) {
149
+ state.tradePending.correlationId = first.correlation_id;
150
+ await save();
151
+ }
152
+ return finishSwap(agent, state, save, await adapter.settle(first, order));
153
+ }
154
+
155
+ async function finishSwap(agent, state, save, result) {
156
+ const trade = state.tradePending;
157
+ if (!result.final) return { status: result.status, correlationId: result.correlationId, reconciliationRequired: true };
158
+ if (result.status === 'SUCCESS') {
159
+ const fill = result.details;
160
+ if (!rawAmount(fill?.amount_out)) throw new Error('SETTLED_OUTPUT_MISSING: reconcile the recorded trade before another execution.');
161
+ if (!trade.settled) {
162
+ const at = result.updatedAt;
163
+ trade.settled = { id: trade.id, at, expiresAt: at + 60000, from: trade.order.origin_asset, to: trade.order.destination_asset, amountIn: trade.order.amount, amountOut: fill.amount_out,
164
+ settlementRef: fill.tx_hash ?? fill.destination_tx_hash ?? result.correlationId, ...(trade.source ? { copiedFrom: trade.source } : {}) };
165
+ await save();
166
+ }
167
+ const publication = await appendFill(agent, state, trade.settled);
168
+ addHistory(state, { type: 'trade', status: result.status, actionId: trade.id, correlationId: result.correlationId, publication });
169
+ } else addHistory(state, { type: 'trade', status: result.status, actionId: trade.id, correlationId: result.correlationId });
170
+ if (state.pending?.[trade.id]) state.pending[trade.id].status = result.status.toLowerCase();
171
+ state.tradePending = null;
172
+ return { status: result.status, correlationId: result.correlationId, amountOut: result.details?.amount_out ?? null, transactionHash: result.details?.tx_hash ?? null };
173
+ }
174
+
175
+ export async function runSpotTask(agent, order, execute) {
176
+ return withAgentState(agent, async (state, save) => {
177
+ const config = state.tasks[spot];
178
+ if (!config) throw new Error('Enable spot-trading with an Agent Connect account first.');
179
+ const adapter = intentsAdapter(config);
180
+ const quote = await adapter.quote(order);
181
+ if (!execute) return { mode: 'preview', quote, signedTransactions: 0 };
182
+ if (!config.live) throw new Error('INTENTS_LIVE_DISABLED');
183
+ const p = state.executionPolicy;
184
+ if (!p || order.origin_asset !== p.inputAsset || !p.assets.includes(order.destination_asset) || BigInt(order.amount) > BigInt(p.maxInput)) throw new Error('ORDER_OUTSIDE_EXECUTION_POLICY');
185
+ const refusal = checkQuote(quote, order, { amountOut: quote.amountOut, amountIn: quote.amountIn }, p);
186
+ if (refusal || quote.warnings.length) throw new Error(refusal ?? 'INTENTS_QUOTE_WARNING');
187
+ const min = minimumOutput(quote, order, p);
188
+ return executeSwap(agent, state, save, adapter, { ...order, min_amount_out: min }, { id: operationKey(agent.id, randomUUID()) });
189
+ });
190
+ }
191
+
192
+ export async function reconcileCopy(agent) {
193
+ return withAgentState(agent, async (state, save) => {
194
+ const report = {};
195
+ if (state.tradePending) {
196
+ const trade = state.tradePending;
197
+ if (!trade.correlationId) report.trade = { resolved: false, reason: 'CORRELATION_ID_UNKNOWN', idempotencyKey: trade.key };
198
+ else {
199
+ const adapter = intentsAdapter(state.tasks[spot]);
200
+ report.trade = await finishSwap(agent, state, save, await adapter.settle(await adapter.status(trade.correlationId), trade.order, { attempts: 0 }));
201
+ }
202
+ }
203
+ if (['opening', 'closing'].includes(state.follow?.status)) {
204
+ await gateway(agent, async client => {
205
+ report.payment = await client.reconcile();
206
+ if (!report.payment.resolved) return;
207
+ const { vault, reusable } = await client.status();
208
+ if (state.follow.status === 'opening' && vault.active_session && reusable) Object.assign(state.follow, { status: 'active', sessionId: vault.active_session });
209
+ else if (!vault.active_session) state.follow.status = state.follow.closeTarget ?? 'paused';
210
+ else report.payment = { resolved: false, reason: 'ACTIVE_SESSION_RECOVERY_REQUIRED' };
211
+ });
212
+ }
213
+ return { resolved: !state.tradePending && !['opening', 'closing'].includes(state.follow?.status), ...report };
214
+ });
215
+ }
216
+
217
+ async function observeAction(agent, state, save, action, emit, client, approved = false) {
218
+ const follow = current(state), policy = state.executionPolicy;
219
+ const result = proposedOrder(action, policy);
220
+ const record = data => {
221
+ const item = { type: 'action', actionId: action.id, sequence: action.sequence, publisher: action.publisher, ...data };
222
+ addHistory(state, item);
223
+ emit(item);
224
+ };
225
+ if (result.reason) {
226
+ if (approved) state.pending[action.id].status = 'skipped';
227
+ return record({ status: 'skipped', reason: result.reason, action });
228
+ }
229
+ if (action.copiedFrom) {
230
+ if (approved) state.pending[action.id].status = 'skipped';
231
+ return record({ status: 'skipped', reason: 'COPIED_ACTION', action });
232
+ }
233
+ const config = state.tasks[spot];
234
+ if (!config) return record({ status: 'observed', reason: 'INTENTS_ADAPTER_REQUIRED', order: result.order, action });
235
+ const adapter = intentsAdapter(config);
236
+ const quote = await adapter.quote(result.order);
237
+ const reason = checkQuote(quote, result.order, action, policy) || (quote.warnings.length ? 'INTENTS_QUOTE_WARNING' : null);
238
+ if (reason) {
239
+ if (approved) state.pending[action.id].status = 'skipped';
240
+ return record({ status: 'skipped', reason, quote, action });
241
+ }
242
+ if (follow.mode === 'observe') return record({ status: 'observed', quote, order: result.order, action });
243
+ if (follow.mode === 'approve' && !approved) {
244
+ (state.pending ??= {})[action.id] = { status: 'pending', action, quote, sessionId: follow.sessionId };
245
+ return record({ status: 'pending', quote, order: result.order });
246
+ }
247
+ if (!config.live || state.tradePending) return record({ status: 'skipped', reason: state.tradePending ? 'TRADE_RECONCILIATION_REQUIRED' : 'INTENTS_LIVE_DISABLED' });
248
+ const context = await client.status();
249
+ if (context.vault.active_session !== follow.sessionId || !context.reusable || !Object.keys(await client.sessionHeaders()).length || proposedOrder(action, policy).reason) {
250
+ if (approved) state.pending[action.id].status = 'skipped';
251
+ return record({ status: 'skipped', reason: 'COPY_AUTHORITY_EXPIRED' });
252
+ }
253
+ const order = { ...result.order, min_amount_out: minimumOutput(action, result.order, policy) };
254
+ if (approved) state.pending[action.id].status = 'executing';
255
+ emit({ type: 'execution', actionId: action.id, ...await executeSwap(agent, state, save, adapter, order, { id: action.id, source: follow.listing.id }) });
256
+ }
257
+
258
+ export async function copyTick(agent, emit = () => {}) {
259
+ return withAgentState(agent, async (state, save) => {
260
+ if (state.follow?.status !== 'active') return;
261
+ const follow = state.follow;
262
+ const guard = state.feedGuard ?? { staleSeconds: 45, failures: 3 };
263
+ return gateway(agent, async client => {
264
+ const { vault } = await client.status();
265
+ if (vault.active_session !== follow.sessionId) return closeCopy(agent, state, save, 'paused', 'STREAM_ENDED', client);
266
+ const checked = await client.check({ tier: 'basic' });
267
+ if (checked.decision === 'CLOSE') return closeCopy(agent, state, save, 'paused', checked.reason, client);
268
+ if (checked.decision !== 'CONTINUE') throw new Error(checked.reason ?? 'COPY_PAYMENT_UNAVAILABLE');
269
+ const headers = await client.sessionHeaders();
270
+ if (!Object.keys(headers).length) return closeCopy(agent, state, save, 'paused', 'LEASE_EXPIRED', client);
271
+ let page;
272
+ try {
273
+ const response = await fetch(`${follow.listing.origin}${feedPath}?after=${follow.cursor}`, { headers, redirect: 'error', signal: AbortSignal.timeout(10000) }).catch(async () => {
274
+ const transport = await client.observeResult({ kind: 'transport' });
275
+ if (transport.decision === 'CLOSE') return transport;
276
+ throw new Error('FEED_TRANSPORT_ERROR');
277
+ });
278
+ if (response.decision === 'CLOSE') return closeCopy(agent, state, save, 'paused', response.reason, client);
279
+ const observed = await client.observeResult({ kind: 'response', status: response.status });
280
+ if (observed.decision === 'CLOSE') return closeCopy(agent, state, save, 'paused', observed.reason, client);
281
+ if (!response.ok) throw new Error(`FEED_HTTP_${response.status}`);
282
+ page = verifyPage(await readJsonResponse(response), follow.listing, follow.sessionId, follow.cursor, guard.staleSeconds);
283
+ } catch (error) {
284
+ follow.failures++;
285
+ const stale = Date.now() - (follow.lastFeedAt ?? follow.startedAt) >= guard.staleSeconds * 1000;
286
+ if (stale || follow.failures >= guard.failures) return closeCopy(agent, state, save, 'paused', stale ? 'FEED_STALE' : 'FEED_FAILURE_LIMIT', client);
287
+ emit({ type: 'feed-error', reason: error.message, failures: follow.failures });
288
+ return;
289
+ }
290
+ follow.lastFeedAt = Date.now();
291
+ follow.failures = 0;
292
+ for (const action of page.actions) {
293
+ // Persist the cursor before any external execution. A lost reply cannot replay the order.
294
+ follow.cursor = action.sequence;
295
+ (state.cursors ??= {})[follow.listing.id] = action.sequence;
296
+ const seen = state.seen ??= {};
297
+ if (seen[action.id]) continue;
298
+ seen[action.id] = true;
299
+ await save();
300
+ try { await observeAction(agent, state, save, action, emit, client); }
301
+ catch (error) { addHistory(state, { type: 'action', actionId: action.id, status: 'error', reason: error.message }); emit({ type: 'action-error', actionId: action.id, reason: error.message }); }
302
+ }
303
+ follow.cursor = page.cursor;
304
+ (state.cursors ??= {})[follow.listing.id] = page.cursor;
305
+ if (follow.mode === 'approve') for (const item of Object.values(state.pending ?? {})) {
306
+ if (item.status === 'approved' && item.sessionId === follow.sessionId) await observeAction(agent, state, save, item.action, emit, client, true);
307
+ }
308
+ return { cursor: page.cursor, received: page.actions.length };
309
+ });
310
+ });
311
+ }
312
+
313
+ export async function runCopyAgent(agent, { once, rpc, emit }) {
314
+ if (once) {
315
+ const events = [];
316
+ const result = (await copyTick(agent, item => events.push(item))) ?? { received: 0, status: 'idle' };
317
+ return { ...result, ...(events.length ? { events } : {}) };
318
+ }
319
+ const lockPath = join(agent.path, 'runner.lock');
320
+ const lock = await open(lockPath, 'wx', 0o600).catch(error => {
321
+ if (error.code === 'EEXIST') throw new Error(`AGENT_RUNNER_BUSY: inspect ${lockPath} for its PID.`);
322
+ throw error;
323
+ });
324
+ const controller = new AbortController();
325
+ const stop = () => controller.abort();
326
+ process.once('SIGINT', stop);
327
+ process.once('SIGTERM', stop);
328
+ let server, listening;
329
+ try {
330
+ await lock.writeFile(`${process.pid}\n`);
331
+ emit({ running: true, agentId: agent.id });
332
+ do {
333
+ try {
334
+ const state = await readAgentState(agent);
335
+ const target = state.publish?.enabled ? `${state.publish.origin}|${state.publish.port}` : null;
336
+ if (target !== listening) {
337
+ await server?.close();
338
+ server = undefined;
339
+ listening = null;
340
+ if (target) {
341
+ server = await serveFeed(agent, { rpcUrl: rpc });
342
+ listening = target;
343
+ emit({ publisher: state.publish.origin, port: server.port });
344
+ }
345
+ }
346
+ } catch (error) { emit({ type: 'publisher-error', reason: error.message }); }
347
+ try {
348
+ const result = await copyTick(agent, emit);
349
+ if (result?.status === 'paused') emit({ type: 'subscription', ...result });
350
+ } catch (error) {
351
+ emit({ type: 'runner-error', reason: error.message });
352
+ }
353
+ const state = await readAgentState(agent);
354
+ const seconds = state.follow?.status === 'active' ? (await settings(agent.path)).config.runtimePolicy.healthPollSeconds : 2;
355
+ await sleep(seconds * 1000, undefined, { signal: controller.signal }).catch(error => { if (error.name !== 'AbortError') throw error; });
356
+ } while (!controller.signal.aborted);
357
+ } finally {
358
+ process.removeListener('SIGINT', stop);
359
+ process.removeListener('SIGTERM', stop);
360
+ try {
361
+ await server?.close();
362
+ if ((await readAgentState(agent)).follow?.status === 'active') {
363
+ const { config } = await settings(agent.path);
364
+ if (config.runtimePolicy.closeOnExit) emit(await stopCopy(agent, 'paused', 'RUNNER_EXIT'));
365
+ }
366
+ } catch (error) { emit({ type: 'closure-unconfirmed', reason: error.message }); process.exitCode = 1; }
367
+ finally { await lock.close(); await unlink(lockPath); }
368
+ }
369
+ }
@@ -0,0 +1,69 @@
1
+ export const NEAR_AI_BASE_URL = 'https://cloud-api.near.ai/v1';
2
+ export const NEAR_AI_CATALOG_URL = `${NEAR_AI_BASE_URL}/model/list`;
3
+
4
+ export function inferenceConfig(input) {
5
+ if (!input || typeof input.model !== 'string' || !input.model.trim() || input.model.length > 256) throw new Error('Provide --model.');
6
+ const provider = input.provider ?? 'openai-compatible';
7
+ if (!['openai-compatible', 'near-ai'].includes(provider)) throw new Error('Choose --provider near-ai or openai-compatible.');
8
+ const url = new URL(input.baseUrl ?? (provider === 'near-ai' ? NEAR_AI_BASE_URL : undefined));
9
+ if (url.username || url.password || url.search || url.hash || !(url.protocol === 'https:' || (url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)))) throw new Error('Inference URL must use HTTPS or loopback without embedded credentials.');
10
+ const baseUrl = url.href.replace(/\/$/, '');
11
+ if (provider === 'near-ai' && baseUrl !== NEAR_AI_BASE_URL) throw new Error('The near-ai provider uses https://cloud-api.near.ai/v1. Use openai-compatible for a custom endpoint.');
12
+ const apiKeyEnv = input.apiKeyEnv ?? (provider === 'near-ai' ? 'NEAR_AI_API_KEY' : undefined);
13
+ if (apiKeyEnv !== undefined && !/^[A-Z][A-Z0-9_]{0,127}$/.test(apiKeyEnv)) throw new Error('Provide an environment variable NAME via --api-key-env, never the key itself.');
14
+ const maxSteps = input.maxSteps ?? 12;
15
+ const timeoutSeconds = input.timeoutSeconds ?? 120;
16
+ if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 10 || timeoutSeconds > 600) throw new Error('timeoutSeconds must be between 10 and 600.');
17
+ if (!Number.isInteger(maxSteps) || maxSteps < 1 || maxSteps > 100) throw new Error('maxSteps must be between 1 and 100.');
18
+ if (input.thinking !== undefined && typeof input.thinking !== 'boolean') throw new Error('thinking must be true or false.');
19
+ if (provider === 'near-ai' && input.thinking !== undefined && /^openai\/gpt-oss-/i.test(input.model)) throw new Error('GPT-OSS reasoning is always on; omit --thinking and use --reasoning-effort.');
20
+ if (input.reasoningEffort !== undefined && !['low', 'medium', 'high'].includes(input.reasoningEffort)) throw new Error('reasoningEffort must be low, medium or high.');
21
+ const maxOutputTokens = input.maxOutputTokens ?? (provider === 'near-ai' ? 2048 : undefined);
22
+ if (maxOutputTokens !== undefined && (!Number.isInteger(maxOutputTokens) || maxOutputTokens < 1 || maxOutputTokens > 131072)) throw new Error('maxOutputTokens must be between 1 and 131072.');
23
+ return { provider, protocol: 'openai-chat', baseUrl, model: input.model, ...(apiKeyEnv ? { apiKeyEnv } : {}),
24
+ ...(input.thinking !== undefined ? { thinking: input.thinking } : {}),
25
+ ...(input.reasoningEffort !== undefined ? { reasoningEffort: input.reasoningEffort } : {}),
26
+ ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), timeoutSeconds, maxSteps };
27
+ }
28
+
29
+ export function completionBody(config, messages, tools) {
30
+ const thinking = config.thinking === undefined ? {} : config.provider === 'near-ai'
31
+ ? { chat_template_kwargs: { [/^moonshotai\/kimi-/i.test(config.model) ? 'thinking' : 'enable_thinking']: config.thinking } }
32
+ : { enable_thinking: config.thinking };
33
+ return { model: config.model, messages, tools, tool_choice: 'auto', stream: false, ...thinking,
34
+ ...(config.reasoningEffort !== undefined ? { reasoning_effort: config.reasoningEffort } : {}),
35
+ ...(config.maxOutputTokens !== undefined ? { max_tokens: config.maxOutputTokens } : {}) };
36
+ }
37
+
38
+ export async function listInferenceModels({ provider = 'near-ai', fetchImpl = fetch } = {}) {
39
+ if (provider !== 'near-ai') throw new Error('Model discovery currently supports --provider near-ai.');
40
+ const models = [], seen = new Set();
41
+ let offset = 0, total;
42
+ do {
43
+ const url = new URL(NEAR_AI_CATALOG_URL);
44
+ if (offset) { url.searchParams.set('offset', String(offset)); url.searchParams.set('limit', '100'); }
45
+ // The public catalog does not need a key. Never forward a configured model key here.
46
+ const response = await fetchImpl(url.href, { redirect: 'error', signal: AbortSignal.timeout(20000) });
47
+ if (!response.ok) throw new Error(`NEAR_AI_CATALOG_HTTP_${response.status}`);
48
+ const page = await response.json();
49
+ if (!Array.isArray(page.models) || !Number.isSafeInteger(page.total) || page.total < 0 || page.total > 10000 || page.offset !== offset || (total !== undefined && page.total !== total)) throw new Error('INVALID_NEAR_AI_CATALOG');
50
+ total = page.total;
51
+ if ((!page.models.length && offset < total) || offset + page.models.length > total) throw new Error('INVALID_NEAR_AI_CATALOG');
52
+ for (const entry of page.models) {
53
+ if (typeof entry.modelId !== 'string' || !entry.modelId || seen.has(entry.modelId)) throw new Error('INVALID_NEAR_AI_CATALOG');
54
+ seen.add(entry.modelId);
55
+ const m = entry.metadata ?? {};
56
+ const features = Array.isArray(m.supportedFeatures) ? m.supportedFeatures.filter(s => typeof s === 'string') : [];
57
+ const textOutput = m.architecture?.outputModalities?.includes('text') === true;
58
+ models.push({ id: entry.modelId, name: m.modelDisplayName ?? entry.modelId,
59
+ agentCompatible: features.includes('tools') && textOutput, supportedFeatures: features,
60
+ providerReportedVerifiable: m.verifiable === true, attestationSupported: m.attestationSupported === true,
61
+ providerType: typeof m.providerType === 'string' ? m.providerType : null,
62
+ maxOutputTokens: Number.isSafeInteger(m.maxOutputLength) ? m.maxOutputLength : null });
63
+ }
64
+ offset += page.models.length;
65
+ } while (offset < total);
66
+ return { provider: 'near-ai', baseUrl: NEAR_AI_BASE_URL, catalogUrl: NEAR_AI_CATALOG_URL,
67
+ fetchedAt: new Date().toISOString(), total, models, metadataSource: 'provider public catalog',
68
+ attestationVerified: false, policySignerLocation: 'local host' };
69
+ }
package/src/install.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { parse, stringify } from 'smol-toml';
2
- import { relative } from 'node:path';
2
+ import { relative, resolve } from 'node:path';
3
3
  import { realpath } from 'node:fs/promises';
4
4
  import { atomicWrite, readOptional, safeTarget } from './files.mjs';
5
5
 
@@ -16,10 +16,14 @@ function managed(text, start, end, block) {
16
16
  return a < 0 ? `${text}${text && !text.endsWith('\n') ? '\n' : ''}\n${start}\n${block}\n${end}\n` : text.slice(0, a) + `${start}\n${block}\n${end}` + text.slice(b + end.length);
17
17
  }
18
18
 
19
- export async function installAgent(target, project, binPath, { dryRun = false } = {}) {
19
+ export function mcpServer(project, binPath, { home = process.env.AGENTPAY_HOME, agent } = {}) {
20
+ return { command: process.execPath, args: [binPath, 'mcp', '--project', project, ...(agent ? ['--agent', agent] : [])], ...(home ? { env: { AGENTPAY_HOME: resolve(home) } } : {}) };
21
+ }
22
+
23
+ export async function installAgent(target, project, binPath, { dryRun = false, home = process.env.AGENTPAY_HOME, agent } = {}) {
20
24
  project = await realpath(project);
21
25
  if (!['cursor', 'codex'].includes(target)) throw new Error('Choose cursor or codex.');
22
- const server = { command: process.execPath, args: [binPath, 'mcp', '--project', project] };
26
+ const server = mcpServer(project, binPath, { home, agent });
23
27
  const writes = [];
24
28
  const stage = async (file, transform) => {
25
29
  const path = await safeTarget(project, file);
@@ -0,0 +1,150 @@
1
+ import { opendir, open, realpath } from 'node:fs/promises';
2
+ import { resolve, relative, extname, sep } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import ts from 'typescript';
5
+
6
+ const excluded = new Set(['.git', '.local', '.agentpay', '.aws', '.ssh', '.codex', '.agents', 'node_modules', 'target', 'dist', 'build', 'coverage', 'vendor', 'artifacts', 'cache']);
7
+ const extensions = new Set(['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.rs', '.sol', '.py']);
8
+ const rules = [
9
+ { id: 'dynamic-eval', severity: 'high', pattern: /\beval\s*\(/, title: 'Dynamic evaluation', recommendation: 'Replace eval with a parser or an explicit operation allowlist; check whether external input reaches this call.' },
10
+ { id: 'tls-verification-disabled', severity: 'high', pattern: /rejectUnauthorized\s*:\s*false|NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*['"]?0/, title: 'TLS certificate verification disabled', recommendation: 'Enable certificate verification and configure the required CA instead.' },
11
+ { id: 'shell-execution', severity: 'medium', pattern: /\b(?:exec|execSync)\s*\(/, title: 'Shell execution needs input review', recommendation: 'Prefer execFile with a fixed executable and separate arguments; inspect any user-controlled command interpolation.' },
12
+ { id: 'raw-html', severity: 'medium', pattern: /dangerouslySetInnerHTML|\.innerHTML\s*=/, title: 'Raw HTML insertion', recommendation: 'Use text rendering or a maintained sanitizer before inserting untrusted content.' },
13
+ { id: 'fetch-deadline', severity: 'medium', title: 'Fetch deadline needs review', recommendation: 'No explicit AbortSignal is present at this call. Check whether the Request already carries a deadline; otherwise add AbortSignal.timeout or an owner-controlled cancellation signal so a stalled service cannot hold the operation indefinitely.' },
14
+ { id: 'wildcard-cors', severity: 'medium', pattern: /['"]access-control-allow-origin['"]\s*[:,]\s*['"]\*['"]/i, title: 'Wildcard cross-origin access', recommendation: 'Check whether this endpoint should expose data to every origin; use an origin allowlist when required.' },
15
+ { id: 'unfinished-work', severity: 'info', pattern: /\b(?:TODO|FIXME)\b/, title: 'Unfinished work marker', recommendation: 'Review this marker and track required work before release.' },
16
+ ];
17
+ const hash = text => createHash('sha256').update(text).digest('hex');
18
+
19
+ function sourceFindings(text, file) {
20
+ if (!['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx'].includes(extname(file))) return [];
21
+ const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, file.endsWith('.tsx') ? ts.ScriptKind.TSX : file.endsWith('.jsx') ? ts.ScriptKind.JSX : file.endsWith('.ts') ? ts.ScriptKind.TS : ts.ScriptKind.JS);
22
+ const shellNames = new Set(), shellNamespaces = new Set(), found = [];
23
+ const isChildProcess = value => ts.isStringLiteral(value) && ['child_process', 'node:child_process'].includes(value.text);
24
+ const propertyName = node => node && (ts.isIdentifier(node) || ts.isStringLiteral(node)) ? node.text : '';
25
+ function bindings(node) {
26
+ if (ts.isImportDeclaration(node) && isChildProcess(node.moduleSpecifier)) {
27
+ const imports = node.importClause;
28
+ if (imports?.name) shellNamespaces.add(imports.name.text);
29
+ if (imports?.namedBindings && ts.isNamespaceImport(imports.namedBindings)) shellNamespaces.add(imports.namedBindings.name.text);
30
+ if (imports?.namedBindings && ts.isNamedImports(imports.namedBindings)) for (const item of imports.namedBindings.elements) if (['exec', 'execSync'].includes((item.propertyName || item.name).text)) shellNames.add(item.name.text);
31
+ }
32
+ if (ts.isVariableDeclaration(node) && node.initializer && ts.isCallExpression(node.initializer) && node.initializer.expression.getText(source) === 'require' && node.initializer.arguments[0] && isChildProcess(node.initializer.arguments[0])) {
33
+ if (ts.isIdentifier(node.name)) shellNamespaces.add(node.name.text);
34
+ if (ts.isObjectBindingPattern(node.name)) for (const item of node.name.elements) if (['exec', 'execSync'].includes((item.propertyName || item.name).getText(source))) shellNames.add(item.name.getText(source));
35
+ }
36
+ ts.forEachChild(node, bindings);
37
+ }
38
+ bindings(source);
39
+ const add = (id, node) => {
40
+ const rule = rules.find(rule => rule.id === id), line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1;
41
+ if (!found.some(item => item.rule === id && item.line === line)) found.push({ id: hash(`${file}:${line}:${id}`).slice(0, 16), rule: id, severity: rule.severity, file, line, title: rule.title, recommendation: rule.recommendation });
42
+ };
43
+ function visit(node) {
44
+ if (ts.isCallExpression(node)) {
45
+ const expression = node.expression;
46
+ if (ts.isIdentifier(expression) && expression.text === 'eval') add('dynamic-eval', node);
47
+ if (ts.isIdentifier(expression) && expression.text === 'fetch' && node.arguments.length && !(ts.isNewExpression(node.arguments[0]) && node.arguments[0].expression.getText(source) === 'Request')) {
48
+ const options = node.arguments[1];
49
+ if (!options || (ts.isObjectLiteralExpression(options) && !options.properties.some(property => ts.isSpreadAssignment(property) || propertyName(property.name) === 'signal'))) add('fetch-deadline', node);
50
+ }
51
+ if ((ts.isIdentifier(expression) && shellNames.has(expression.text)) || (ts.isPropertyAccessExpression(expression) && shellNamespaces.has(expression.expression.getText(source)) && ['exec', 'execSync'].includes(expression.name.text))) add('shell-execution', node);
52
+ if (ts.isPropertyAccessExpression(expression) && ['setHeader', 'header', 'set'].includes(expression.name.text) && node.arguments.length >= 2 && ts.isStringLiteral(node.arguments[0]) && node.arguments[0].text.toLowerCase() === 'access-control-allow-origin' && ts.isStringLiteral(node.arguments[1]) && node.arguments[1].text === '*') add('wildcard-cors', node);
53
+ }
54
+ if (ts.isPropertyAssignment(node)) {
55
+ const name = propertyName(node.name);
56
+ if (name === 'rejectUnauthorized' && node.initializer.kind === ts.SyntaxKind.FalseKeyword) add('tls-verification-disabled', node);
57
+ if (name.toLowerCase() === 'access-control-allow-origin' && ts.isStringLiteral(node.initializer) && node.initializer.text === '*') add('wildcard-cors', node);
58
+ }
59
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
60
+ if (ts.isPropertyAccessExpression(node.left) && node.left.name.text === 'innerHTML') add('raw-html', node);
61
+ if (node.left.getText(source) === 'process.env.NODE_TLS_REJECT_UNAUTHORIZED' && (ts.isStringLiteral(node.right) && node.right.text === '0')) add('tls-verification-disabled', node);
62
+ }
63
+ if (ts.isJsxAttribute(node) && propertyName(node.name) === 'dangerouslySetInnerHTML') add('raw-html', node);
64
+ ts.forEachChild(node, visit);
65
+ }
66
+ visit(source);
67
+ const scanner = ts.createScanner(ts.ScriptTarget.Latest, false, ts.LanguageVariant.Standard, text);
68
+ for (let token = scanner.scan(); token !== ts.SyntaxKind.EndOfFileToken; token = scanner.scan()) if ([ts.SyntaxKind.SingleLineCommentTrivia, ts.SyntaxKind.MultiLineCommentTrivia].includes(token) && /\b(?:TODO|FIXME)\b/.test(scanner.getTokenText())) {
69
+ const line = source.getLineAndCharacterOfPosition(scanner.getTokenPos()).line + 1, rule = rules.find(r => r.id === 'unfinished-work');
70
+ found.push({ id: hash(`${file}:${line}:${rule.id}`).slice(0, 16), rule: rule.id, severity: rule.severity, file, line, title: rule.title, recommendation: rule.recommendation });
71
+ }
72
+ return found.sort((a, b) => a.line - b.line);
73
+ }
74
+
75
+ // Read only operator-selected regular files. Never execute repository code or follow symlinks.
76
+ export async function analyzeRepository(repository, { maxFiles = 500, maxBytes = 3000000, maxFileBytes = 200000, maxEntries = 10000 } = {}) {
77
+ const root = await realpath(resolve(repository));
78
+ const files = [], findings = [], dependencies = [];
79
+ const languages = {};
80
+ let bytes = 0, lines = 0, skipped = 0, entriesVisited = 0, limited = false;
81
+ async function walk(directory) {
82
+ const entries = [];
83
+ for await (const entry of await opendir(directory)) {
84
+ if (++entriesVisited > maxEntries) { limited = true; break; }
85
+ entries.push(entry);
86
+ }
87
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
88
+ if (entry.name.startsWith('.') || excluded.has(entry.name)) continue;
89
+ const path = resolve(directory, entry.name);
90
+ const name = relative(root, path).split(sep).join('/');
91
+ if (entry.isDirectory()) { if (!limited) await walk(path); continue; }
92
+ if (!entry.isFile() || (!extensions.has(extname(entry.name)) && entry.name !== 'package.json')) continue;
93
+ if (files.length >= maxFiles || bytes >= maxBytes) { limited = true; continue; }
94
+ // O_NOFOLLOW closes the symlink-swap window for the final file; verify canonical ancestry too.
95
+ const canonical = await realpath(path);
96
+ if (!canonical.startsWith(root + sep)) { skipped++; continue; }
97
+ const { constants } = await import('node:fs');
98
+ let handle;
99
+ try { handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); }
100
+ catch (error) { if (['ELOOP', 'ENOENT'].includes(error.code)) { skipped++; continue; } throw error; }
101
+ let text;
102
+ try {
103
+ const info = await handle.stat();
104
+ if (!info.isFile() || info.size > maxFileBytes || bytes + info.size > maxBytes) { skipped++; limited ||= bytes + info.size > maxBytes; continue; }
105
+ const buffer = Buffer.alloc(Math.min(maxFileBytes, maxBytes - bytes) + 1);
106
+ const read = await handle.read(buffer, 0, buffer.length, 0);
107
+ if (read.bytesRead > maxFileBytes || bytes + read.bytesRead > maxBytes) { skipped++; limited = true; continue; }
108
+ text = buffer.subarray(0, read.bytesRead).toString('utf8');
109
+ bytes += read.bytesRead;
110
+ } finally { await handle.close(); }
111
+ if (text.includes('\0')) { skipped++; continue; }
112
+ const fileLines = text.split(/\r?\n/);
113
+ files.push({ path: name, lines: fileLines.length, sha256: hash(text) });
114
+ lines += fileLines.length;
115
+ const extension = extname(entry.name) || 'json';
116
+ languages[extension] = (languages[extension] || 0) + 1;
117
+ if (entry.name === 'package.json') {
118
+ try {
119
+ const pkg = JSON.parse(text);
120
+ for (const [scope, values] of Object.entries({ dependencies: pkg.dependencies, devDependencies: pkg.devDependencies })) {
121
+ if (!values || typeof values !== 'object' || Array.isArray(values)) continue;
122
+ // Do not return dependency values: URLs can carry registry credentials.
123
+ for (const dependency of Object.keys(values)) if (/^(?:@[a-z0-9_.-]+\/)?[a-z0-9_.-]+$/i.test(dependency)) dependencies.push({ manifest: name, name: dependency, scope });
124
+ }
125
+ } catch { findings.push({ id: hash(name + ':invalid-manifest').slice(0, 16), rule: 'invalid-manifest', severity: 'medium', file: name, line: 1, title: 'Invalid package manifest', recommendation: 'Correct package.json syntax so dependency tooling can read it.' }); }
126
+ continue;
127
+ }
128
+ findings.push(...sourceFindings(text, name).slice(0, Math.max(0, 300 - findings.length)));
129
+ }
130
+ }
131
+ await walk(root);
132
+ return { schemaVersion: 1, source: 'AgentPay repository analysis', generatedAt: new Date().toISOString(), fingerprint: hash(JSON.stringify(files)), scope: 'Bounded syntax-based JS/TS heuristics, not a security audit or dataflow analysis. Other source languages contribute inventory only. No source code is executed. Findings need human review; absence of findings does not establish safety.', coverage: { files: files.length, lines, bytes, skipped, limited, maxFiles, maxBytes, maxFileBytes, entriesVisited, maxEntries, maxFindings: 300, findingLimitReached: findings.length === 300 }, languages, files, findings, dependencies };
133
+ }
134
+
135
+ export function repositoryResponse(snapshot, params) {
136
+ if ([...params.keys()].some(key => !['view', 'severity', 'file', 'limit', 'offset'].includes(key)) || [...params.keys()].some(key => params.getAll(key).length !== 1)) return { status: 400, body: { error: 'Use view=overview, findings or dependencies; optional severity, exact relative file, limit (1-30) and offset.' } };
137
+ const limit = Number(params.get('limit') || 5), offset = Number(params.get('offset') || 0);
138
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 30 || !Number.isSafeInteger(offset) || offset < 0 || offset > 10000) return { status: 400, body: { error: 'INVALID_ANALYSIS_PAGINATION' } };
139
+ const view = params.get('view') || 'overview', severity = params.get('severity'), file = params.get('file');
140
+ if (!['overview', 'findings', 'dependencies'].includes(view) || (severity && !['high', 'medium', 'info'].includes(severity)) || (file && (file.startsWith('/') || file.includes('\\') || file.split('/').some(part => part === '..' || part.startsWith('.'))))) return { status: 400, body: { error: 'INVALID_ANALYSIS_QUERY' } };
141
+ const { source, generatedAt, fingerprint, scope, coverage, languages } = snapshot;
142
+ const body = { source, generatedAt, fingerprint, scope, coverage, view };
143
+ if (view === 'overview') Object.assign(body, { languages, findingCounts: Object.fromEntries(['high', 'medium', 'info'].map(s => [s, snapshot.findings.filter(f => f.severity === s).length])), dependencyCount: snapshot.dependencies.length });
144
+ if (view === 'findings') {
145
+ const matching = snapshot.findings.filter(f => (!severity || f.severity === severity) && (!file || f.file === file));
146
+ Object.assign(body, { findings: matching.slice(offset, offset + limit), matching: matching.length, offset, limit, truncated: matching.length > offset + limit });
147
+ }
148
+ if (view === 'dependencies') Object.assign(body, { dependencies: snapshot.dependencies.slice(0, 80), total: snapshot.dependencies.length, truncated: snapshot.dependencies.length > 80, vulnerabilityDatabaseChecked: false });
149
+ return { status: 200, body };
150
+ }
@@ -0,0 +1,17 @@
1
+ import { startProvider } from '@ola-893/agentpay-core/internal-provider';
2
+ import { analyzeRepository, repositoryResponse } from './repository-analysis.mjs';
3
+
4
+ export async function startRepositoryService(config, { repository, port = 5380, rates, durationSeconds } = {}) {
5
+ if (!repository) throw new Error('Specify --repository with the operator-selected repository directory.');
6
+ let cached = await analyzeRepository(repository), refresh;
7
+ const providerConfig = { ...config };
8
+ const service = await startProvider(providerConfig, { port, rates, durationSeconds, handleRequest: async params => {
9
+ if (Date.now() - new Date(cached.generatedAt).getTime() > 5000) {
10
+ refresh ??= analyzeRepository(repository).then(next => { cached = next; }).finally(() => { refresh = undefined; });
11
+ await refresh;
12
+ }
13
+ return repositoryResponse(cached, params);
14
+ } });
15
+ providerConfig.serviceUrl = service.origin;
16
+ return { ...service, summary: { source: cached.source, fingerprint: cached.fingerprint, coverage: cached.coverage, pricing: { rates: rates || { basic: '10', premium: '200' }, durationSeconds: durationSeconds || 1000, units: 'raw USDC per second; 1000000 raw = 1 USDC', settlement: 'Time-based access, not per-request billing. Accrual continues until closure or lease expiry, including idle time and time before a failure threshold closes the stream.' }, protocol: 'agentpay-near-v1', path: config.service?.requestPath || '/research', views: ['overview', 'findings', 'dependencies'], privateKeysRequired: false } };
17
+ }