@ola-893/agentpay-cli 0.1.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.
- package/LICENSE +0 -2
- package/README.md +129 -20
- package/artifacts/agentpay_vault.wasm +0 -0
- package/artifacts/manifest.json +6 -0
- package/package.json +33 -8
- package/src/agent.mjs +61 -0
- package/src/agents.mjs +106 -0
- package/src/cli.mjs +239 -108
- package/src/config.mjs +8 -14
- package/src/copy-activity.mjs +56 -0
- package/src/copy-commands.mjs +220 -0
- package/src/copy-feed.mjs +278 -0
- package/src/copy-intents.mjs +83 -0
- package/src/copy-runtime.mjs +369 -0
- package/src/inference.mjs +69 -0
- package/src/install.mjs +9 -5
- package/src/mcp.mjs +15 -6
- package/src/repository-analysis.mjs +150 -0
- package/src/repository-service.mjs +17 -0
- package/src/settings.mjs +95 -0
- package/src/setup.mjs +73 -0
- package/src/ui.mjs +6 -1
- package/src/vault-money.mjs +69 -0
- package/ui/assets/index-Bl23n-ZK.js +331 -0
- package/ui/assets/{index-D7tPv-yG.css → index-BzAc-LMS.css} +1 -1
- package/ui/index.html +2 -2
- package/ui/near/testnet.json +741 -347
- package/ui/assets/index-B-PcdIha.js +0 -315
|
@@ -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
|
+
}
|
package/src/settings.mjs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { resolve, join } from 'node:path';
|
|
2
|
+
import { mkdir, open, unlink } from 'node:fs/promises';
|
|
3
|
+
import { liveProfile } from './config.mjs';
|
|
4
|
+
import { atomicWrite, readOptional } from './files.mjs';
|
|
5
|
+
import { readConfig, readKey } from '@ola-893/agentpay-core/internal-config';
|
|
6
|
+
import { runtimePolicy, serviceConfig } from '@ola-893/agentpay-core/internal-runtime-policy';
|
|
7
|
+
import { inspectNearProfile, NearAgentPayClient } from '@ola-893/agentpay-core/near';
|
|
8
|
+
import { inferenceConfig } from './inference.mjs';
|
|
9
|
+
export { inferenceConfig } from './inference.mjs';
|
|
10
|
+
|
|
11
|
+
export function usdc(value) {
|
|
12
|
+
if (typeof value !== 'string' || !/^(0|[1-9][0-9]{0,24})(\.[0-9]{1,6})?$/.test(value)) throw new Error('Use a nonnegative USDC amount with at most six decimal places.');
|
|
13
|
+
const [whole, fraction = ''] = value.split('.');
|
|
14
|
+
return (BigInt(whole) * 1000000n + BigInt(fraction.padEnd(6, '0'))).toString();
|
|
15
|
+
}
|
|
16
|
+
export async function settings(project) {
|
|
17
|
+
const profile = await liveProfile(project);
|
|
18
|
+
return { profile, config: await readConfig(profile.configPath) };
|
|
19
|
+
}
|
|
20
|
+
export async function showSettings(project) {
|
|
21
|
+
const { profile, config } = await settings(project);
|
|
22
|
+
const { client, summary } = await inspectNearProfile(profile.configPath, profile.stateRoot);
|
|
23
|
+
return { network: config.network, vaultId: config.vaultId, agentId: config.agentId, recipientId: config.recipientId, paymentsEnabled: profile.paymentsEnabled,
|
|
24
|
+
financialPolicy: (await client.getVault()).policy, financialUnits: 'raw USDC (1 USDC = 1000000)', runtimePolicy: config.runtimePolicy,
|
|
25
|
+
service: { url: config.serviceUrl, ...config.service }, inference: profile.inference || null, ...summary };
|
|
26
|
+
}
|
|
27
|
+
export async function setRuntime(project, patch) {
|
|
28
|
+
if (!Object.keys(patch).length) throw new Error('Specify at least one runtime guardrail. See --help.');
|
|
29
|
+
const { profile, config } = await settings(project);
|
|
30
|
+
config.runtimePolicy = runtimePolicy({ ...config.runtimePolicy, ...patch });
|
|
31
|
+
await atomicWrite(profile.configPath, JSON.stringify(config, null, 2));
|
|
32
|
+
return { runtimePolicy: config.runtimePolicy, enforcement: 'local payment worker; thresholds reload on the next checkpoint', restartRequired: 'Restart running agents/MCP servers after changing reuse, polling or exit behavior.' };
|
|
33
|
+
}
|
|
34
|
+
export async function setService(project, patch) {
|
|
35
|
+
const { profile, config } = await settings(project);
|
|
36
|
+
const { summary, client } = await inspectNearProfile(profile.configPath, profile.stateRoot);
|
|
37
|
+
if (summary.activeSession || summary.pendingIntent || summary.executorBusyOrInterrupted) throw new Error('CLOSE_OR_RECONCILE_STREAM_BEFORE_SERVICE_CHANGE');
|
|
38
|
+
const { url, recipient, ...paths } = patch;
|
|
39
|
+
const next = { ...config, serviceUrl: url || config.serviceUrl, recipientId: recipient || config.recipientId, service: serviceConfig({ ...config.service, ...paths }) };
|
|
40
|
+
const temp = profile.configPath + '.candidate.json';
|
|
41
|
+
await atomicWrite(temp, JSON.stringify(next));
|
|
42
|
+
let validated;
|
|
43
|
+
try { validated = await readConfig(temp); } finally { await unlink(temp); }
|
|
44
|
+
if (!(await client.getVault()).policy.recipients.includes(validated.recipientId)) throw new Error('RECIPIENT_NOT_IN_ONCHAIN_POLICY');
|
|
45
|
+
await atomicWrite(profile.configPath, JSON.stringify(validated, null, 2));
|
|
46
|
+
return { service: { url: validated.serviceUrl, recipient: validated.recipientId, ...validated.service }, restartRequired: 'Restart running agents/MCP servers after service changes.' };
|
|
47
|
+
}
|
|
48
|
+
export async function setInference(project, input) {
|
|
49
|
+
const profile = await liveProfile(project);
|
|
50
|
+
const inference = inferenceConfig(input);
|
|
51
|
+
const { file, configPath, stateRoot, ...saved } = profile;
|
|
52
|
+
await atomicWrite(file, JSON.stringify({ ...saved, inference }, null, 2));
|
|
53
|
+
return { inference, credentialsStored: false,
|
|
54
|
+
...(inference.provider === 'near-ai' ? { attestationVerified: false, policySignerLocation: 'local host' } : {}),
|
|
55
|
+
next: 'agentpay agent run --prompt "Use the configured paid API to answer my question."' };
|
|
56
|
+
}
|
|
57
|
+
export async function setLimits(project, patch, ownerKeyFile) {
|
|
58
|
+
if (!ownerKeyFile) throw new Error('--owner-key is required for an on-chain policy change; it is never saved in the agent profile.');
|
|
59
|
+
if (!Object.keys(patch).length) throw new Error('Specify at least one financial limit.');
|
|
60
|
+
const { profile, config } = await settings(project);
|
|
61
|
+
const { client: reader, stateDir } = await inspectNearProfile(profile.configPath, profile.stateRoot);
|
|
62
|
+
const owner = await readKey(resolve(ownerKeyFile));
|
|
63
|
+
if (owner.accountId !== config.ownerId) throw new Error('OWNER_CREDENTIAL_MISMATCH');
|
|
64
|
+
const client = new NearAgentPayClient({ vaultId: config.vaultId, accountId: owner.accountId, privateKey: owner.privateKey, rpcUrl: config.rpcUrl });
|
|
65
|
+
await mkdir(stateDir, { recursive: true, mode: 0o700 });
|
|
66
|
+
const lock = await open(join(stateDir, 'operation.lock'), 'wx', 0o600);
|
|
67
|
+
try {
|
|
68
|
+
if (await readOptional(join(stateDir, 'pending.json'))) throw new Error('RECONCILIATION_REQUIRED');
|
|
69
|
+
const vault = await reader.getVault();
|
|
70
|
+
if (vault.active_session) throw new Error('CLOSE_STREAM_BEFORE_POLICY_CHANGE');
|
|
71
|
+
const policy = { ...vault.policy, ...patch };
|
|
72
|
+
if (BigInt(policy.budget) < BigInt(vault.spent) || BigInt(policy.max_deposit) <= 0n || BigInt(policy.max_deposit) > BigInt(policy.budget) || BigInt(policy.max_rate) <= 0n || !Number.isInteger(policy.max_duration_seconds) || policy.max_duration_seconds < 1 || policy.max_duration_seconds > 3600 || !policy.recipients.includes(config.recipientId) || policy.recipients.length > 32 || policy.recipients.some(id => !/^[a-z0-9]+(?:[-_.][a-z0-9]+)*$/.test(id) || id.length < 2 || id.length > 64)) throw new Error('INVALID_FINANCIAL_POLICY');
|
|
73
|
+
const pending = join(stateDir, 'policy-pending.json');
|
|
74
|
+
const journal = await open(pending, 'wx', 0o600);
|
|
75
|
+
try { await journal.writeFile(JSON.stringify({ policy, vaultId: config.vaultId, nonceBefore: vault.next_nonce, createdAt: new Date().toISOString() })); await journal.sync(); } finally { await journal.close(); }
|
|
76
|
+
const receipt = await client.execute('configure', { policy });
|
|
77
|
+
await atomicWrite(join(stateDir, `policy-${receipt.hash}.receipt.json`), JSON.stringify(receipt));
|
|
78
|
+
const actual = (await reader.getVault()).policy;
|
|
79
|
+
if (JSON.stringify(actual) !== JSON.stringify(policy)) throw new Error('POLICY_RECONCILIATION_REQUIRED');
|
|
80
|
+
await unlink(pending);
|
|
81
|
+
return { financialPolicy: actual, units: 'raw USDC', transactionHash: receipt.hash, explorerUrl: `https://testnet.nearblocks.io/txns/${receipt.hash}` };
|
|
82
|
+
} finally { await lock.close(); await unlink(join(stateDir, 'operation.lock')); }
|
|
83
|
+
}
|
|
84
|
+
export async function reconcileLimits(project) {
|
|
85
|
+
const { profile } = await settings(project);
|
|
86
|
+
const { client, stateDir } = await inspectNearProfile(profile.configPath, profile.stateRoot);
|
|
87
|
+
const file = join(stateDir, 'policy-pending.json');
|
|
88
|
+
const text = await readOptional(file);
|
|
89
|
+
if (!text) return { resolved: true, reason: 'NO_PENDING_POLICY' };
|
|
90
|
+
const pending = JSON.parse(text), vault = await client.getVault(), actual = vault.policy;
|
|
91
|
+
if (JSON.stringify(actual) !== JSON.stringify(pending.policy) || pending.nonceBefore === undefined || BigInt(vault.next_nonce) <= BigInt(pending.nonceBefore)) return { resolved: false, reason: 'POLICY_OUTCOME_UNCONFIRMED' };
|
|
92
|
+
await atomicWrite(file.replace('.json', '.reconciled.json'), JSON.stringify({ ...pending, verifiedAt: new Date().toISOString() }));
|
|
93
|
+
await unlink(file);
|
|
94
|
+
return { resolved: true, financialPolicy: actual, receiptRecovered: false };
|
|
95
|
+
}
|
package/src/setup.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { mkdir, readFile, stat, open, unlink } from 'node:fs/promises';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { JsonRpcProvider } from 'near-api-js';
|
|
6
|
+
import { configHome, initProject, liveProfile } from './config.mjs';
|
|
7
|
+
import { atomicWrite, readOptional, safeTarget } from './files.mjs';
|
|
8
|
+
import { readKey, readConfig } from '@ola-893/agentpay-core/internal-config';
|
|
9
|
+
import { NearAgentPayClient } from '@ola-893/agentpay-core/near';
|
|
10
|
+
import { deployTestnet } from '@ola-893/agentpay-core/internal-deployment';
|
|
11
|
+
|
|
12
|
+
const setupId = project => createHash('sha256').update(project).digest('hex');
|
|
13
|
+
export const bundledWasm = fileURLToPath(new URL('../artifacts/agentpay_vault.wasm', import.meta.url));
|
|
14
|
+
export const publicRecording = fileURLToPath(new URL('../ui/near/testnet.json', import.meta.url));
|
|
15
|
+
|
|
16
|
+
export async function setupTestnet(project, options) {
|
|
17
|
+
if (!options.ownerId || !options.ownerKeyFile) return {
|
|
18
|
+
status: 'OWNER_SETUP_REQUIRED', network: 'testnet', signedTransactions: 0,
|
|
19
|
+
next: (options.setupCommand ?? 'agentpay init') + ' --network testnet --owner-id YOUR.testnet --owner-key /absolute/path/owner.json',
|
|
20
|
+
steps: ['Create/export a named NEAR testnet owner account using your wallet or near-cli-rs. Keep its Ed25519 credential JSON private (0600).', 'Fund it with at least 6 test NEAR after storage reserve. The setup creates a vault and restricted agent; recipient defaults to the owner for the local fixture.', 'Run the command above for read-only preflight, then add --deploy to submit setup transactions. No deployment.json or Rust build is required.', 'After setup, use the Circle faucet (Near Testnet) for the printed owner address, then agentpay fund --amount 100000 --owner-key /absolute/path/owner.json.', 'Run Configure the service, inference and financial/runtime guardrails, then enable payments and run agentpay agent run --prompt "...".'],
|
|
21
|
+
nearAccounts: 'https://docs.near.org/tools/cli', usdcFaucet: 'https://faucet.circle.com/',
|
|
22
|
+
noSponsor: true,
|
|
23
|
+
};
|
|
24
|
+
const { deploymentPlan, inspectTestnet } = await import('@ola-893/agentpay-core/internal-preflight');
|
|
25
|
+
const key = await readKey(resolve(options.ownerKeyFile));
|
|
26
|
+
if (key.accountId !== options.ownerId) throw new Error('OWNER_CREDENTIAL_MISMATCH');
|
|
27
|
+
const plan = deploymentPlan({ ownerId: options.ownerId, recipientId: options.recipientId || options.ownerId, vaultLabel: options.vaultLabel, agentLabel: options.agentLabel, rpcUrl: options.rpcUrl, serviceUrl: options.serviceUrl });
|
|
28
|
+
const provider = new JsonRpcProvider({ url: plan.rpcUrl });
|
|
29
|
+
const preflight = await inspectTestnet(plan, { provider, readWasm: () => readFile(bundledWasm), ownerPublicKey: key.publicKey });
|
|
30
|
+
if (!options.deploy) return { ...preflight, next: preflight.readyToDeploy ? 'Repeat this init command with --deploy. Setup uses up to 6 test NEAR, installs owner-controlled vault/admin keys, and does not fund USDC or enable payments.' : 'Resolve the reported checks, then repeat init. No transaction was signed.' };
|
|
31
|
+
if (!preflight.readyToDeploy) throw new Error('TESTNET_PREFLIGHT_FAILED: ' + preflight.checks.filter(c => c.status !== 'pass').map(c => `${c.id}: ${c.detail}`).join('; '));
|
|
32
|
+
const home = configHome();
|
|
33
|
+
await mkdir(home, { recursive: true, mode: 0o700 });
|
|
34
|
+
if (((await stat(home)).mode & 0o077) !== 0) throw new Error('AGENTPAY_HOME must have permissions 0700.');
|
|
35
|
+
const directory = await safeTarget(home, join('setups', setupId(project)));
|
|
36
|
+
const configPath = join(directory, 'deployment.json');
|
|
37
|
+
if (await readOptional(await safeTarget(project, '.agentpay/config.json'))) throw new Error('PROJECT_ALREADY_INITIALIZED');
|
|
38
|
+
const result = await deployTestnet({ ...plan, ownerKeyFile: resolve(options.ownerKeyFile), vaultLabel: options.vaultLabel, agentLabel: options.agentLabel, directory, wasmFile: bundledWasm, onProgress: event => process.stderr.write(`NEAR setup ${event.step}: ${event.transactionHash}\n`) });
|
|
39
|
+
await initProject(project, { deployment: configPath });
|
|
40
|
+
return {
|
|
41
|
+
status: 'DEPLOYED', network: 'testnet', ownerId: result.config.ownerId, vaultId: result.config.vaultId, agentId: result.config.agentId, recipientId: result.config.recipientId,
|
|
42
|
+
paymentsEnabled: false, ownerUSDCFundingAddress: result.config.ownerId, usdcFaucet: 'https://faucet.circle.com/',
|
|
43
|
+
transactions: result.record.steps.map(s => ({ step: s.name, hash: s.hash, explorerUrl: `https://testnet.nearblocks.io/txns/${s.hash}` })),
|
|
44
|
+
privateSetupDirectory: directory, next: 'Get Circle test USDC for the owner (Near Testnet). Then agentpay fund --amount 100000 --owner-key /absolute/path/owner.json. Owner/admin setup credentials are kept separately from the runtime profile.',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function fundProject(project, { ownerKeyFile, amount, decimal = false }) {
|
|
49
|
+
if (!/^[1-9][0-9]{0,20}$/.test(amount ?? '') || (!decimal && BigInt(amount) > 100000n)) throw new Error(decimal ? 'Provide a positive test USDC amount.' : 'Provide --amount in raw six-decimal USDC units, at most 100000 (0.1 test USDC) per funding call.');
|
|
50
|
+
if (!ownerKeyFile) throw new Error('--owner-key is required only for this explicit funding operation.');
|
|
51
|
+
const profile = await liveProfile(project);
|
|
52
|
+
const config = await readConfig(profile.configPath);
|
|
53
|
+
const owner = await readKey(resolve(ownerKeyFile));
|
|
54
|
+
if (owner.accountId !== config.ownerId) throw new Error('OWNER_CREDENTIAL_MISMATCH');
|
|
55
|
+
const provider = new JsonRpcProvider({ url: config.rpcUrl });
|
|
56
|
+
if ((await provider.viewNodeStatus()).chain_id !== 'testnet') throw new Error('RPC_NETWORK_MISMATCH');
|
|
57
|
+
const accessKey = await provider.viewAccessKey({ accountId: config.ownerId, publicKey: owner.publicKey, blockQuery: { finality: 'final' } });
|
|
58
|
+
if (accessKey.permission !== 'FullAccess') throw new Error('OWNER_FULL_ACCESS_REQUIRED');
|
|
59
|
+
const client = new NearAgentPayClient({ vaultId: config.vaultId, accountId: owner.accountId, privateKey: owner.privateKey, provider });
|
|
60
|
+
const vault = await client.getVault();
|
|
61
|
+
if (vault.owner_id !== owner.accountId || vault.token_id !== config.tokenId) throw new Error('VAULT_IDENTITY_MISMATCH');
|
|
62
|
+
if (BigInt(await client.tokenBalance(owner.accountId, config.tokenId)) < BigInt(amount)) throw new Error(`OWNER_USDC_INSUFFICIENT: request Near Testnet USDC at https://faucet.circle.com/ for ${owner.accountId}`);
|
|
63
|
+
const pending = join(configHome(), 'funding', createHash('sha256').update(config.vaultId).digest('hex') + '.json');
|
|
64
|
+
await mkdir(join(configHome(), 'funding'), { recursive: true, mode: 0o700 });
|
|
65
|
+
let journal;
|
|
66
|
+
try { journal = await open(pending, 'wx', 0o600); } catch (e) { if (e.code === 'EEXIST') throw new Error(`FUNDING_RECONCILIATION_REQUIRED: inspect ${pending}. No transaction was resubmitted.`); throw e; }
|
|
67
|
+
try { await journal.writeFile(JSON.stringify({ network: config.network, vaultId: config.vaultId, ownerId: owner.accountId, amount, createdAt: new Date().toISOString() })); await journal.sync(); } finally { await journal.close(); }
|
|
68
|
+
const receipt = await client.fund(amount);
|
|
69
|
+
await atomicWrite(pending.replace('.json', `-${receipt.hash}.receipt.json`), JSON.stringify(receipt));
|
|
70
|
+
if (receipt.receiptFailures.length || receipt.value !== amount) throw new Error(`FUNDING_NOT_CONFIRMED: ${receipt.hash}; inspect ${pending}.`);
|
|
71
|
+
await unlink(pending);
|
|
72
|
+
return { network: 'testnet', amountRawUSDC: amount, transactionHash: receipt.hash, explorerUrl: `https://testnet.nearblocks.io/txns/${receipt.hash}`, next: 'agentpay config show; configure service/inference and guardrails, then agentpay payments --enable and agentpay agent run --prompt "..."' };
|
|
73
|
+
}
|
package/src/ui.mjs
CHANGED
|
@@ -3,11 +3,15 @@ import { readFile, realpath } from 'node:fs/promises';
|
|
|
3
3
|
import { resolve, extname, sep } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
|
+
import { verifyRun } from '@ola-893/agentpay-core/internal-evidence-audit';
|
|
6
7
|
|
|
7
8
|
const uiRoot = fileURLToPath(new URL('../ui/', import.meta.url));
|
|
8
9
|
const mime = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2', '.ico': 'image/x-icon' };
|
|
9
|
-
export async function startUi({ port = 0, recording = 'testnet' } = {}) {
|
|
10
|
+
export async function startUi({ port = 0, recording = 'testnet', runFile } = {}) {
|
|
10
11
|
if (!['testnet', 'sandbox'].includes(recording)) throw new Error('Choose --recording testnet or sandbox.');
|
|
12
|
+
const captured = JSON.parse(await readFile(runFile ? resolve(runFile) : resolve(uiRoot, 'near', `${recording}.json`), 'utf8'));
|
|
13
|
+
verifyRun(captured);
|
|
14
|
+
if ((!runFile && captured.network !== recording) || captured.simulated) throw new Error('REPLAY_PROVENANCE_MISMATCH');
|
|
11
15
|
const server = createServer(async (req, res) => {
|
|
12
16
|
const send = (status, body, type = 'text/plain') => {
|
|
13
17
|
res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'x-frame-options': 'DENY', 'referrer-policy': 'no-referrer' });
|
|
@@ -19,6 +23,7 @@ export async function startUi({ port = 0, recording = 'testnet' } = {}) {
|
|
|
19
23
|
const decoded = decodeURIComponent(req.url.split('?')[0]);
|
|
20
24
|
if (decoded.split('/').some(x => x === '..' || x.startsWith('.')) || decoded.includes('\\') || decoded.includes('\0')) return send(403, 'Path denied.');
|
|
21
25
|
if (decoded.startsWith('/api/') || decoded === '/api') return send(404, 'No signing API is exposed by this viewer.');
|
|
26
|
+
if (runFile && ['/near/latest.json', '/near/testnet.json', '/near/sandbox.json'].includes(decoded)) return send(200, JSON.stringify(captured), 'application/json');
|
|
22
27
|
let path = ['/', '/docs'].includes(decoded) ? '/index.html' : decoded;
|
|
23
28
|
if (path === '/near/latest.json') path = `/near/${recording === 'sandbox' ? 'sandbox' : 'testnet'}.json`;
|
|
24
29
|
const file = await realpath(resolve(uiRoot, `.${path}`));
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, open, readdir, unlink } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { actions, baseEncode, encodeTransaction, JsonRpcProvider } from 'near-api-js';
|
|
5
|
+
import { atomicWrite, readOptional } from './files.mjs';
|
|
6
|
+
import { fingerprint } from './agents.mjs';
|
|
7
|
+
|
|
8
|
+
const explorer = hash => `https://testnet.nearblocks.io/txns/${hash}`;
|
|
9
|
+
|
|
10
|
+
async function finalize(file, intent, outcome) {
|
|
11
|
+
const tx = outcome.transaction;
|
|
12
|
+
if (outcome.final_execution_status !== 'FINAL' || tx?.hash !== intent.hash || tx.signer_id !== intent.accountId || tx.receiver_id !== intent.vaultId) throw new Error('VAULT_PAYOUT_OUTCOME_UNCONFIRMED');
|
|
13
|
+
const status = outcome.status;
|
|
14
|
+
const ok = status && typeof status === 'object' && 'SuccessValue' in status &&
|
|
15
|
+
Buffer.from(status.SuccessValue, 'base64').toString() === 'true' &&
|
|
16
|
+
!outcome.receipts_outcome?.some(receipt => receipt.outcome.status?.Failure);
|
|
17
|
+
const result = { resolved: true, paid: Boolean(ok), method: intent.method, transactionHash: intent.hash, explorerUrl: explorer(intent.hash) };
|
|
18
|
+
await atomicWrite(join(file, '..', `receipt-${intent.hash}.json`), JSON.stringify(result));
|
|
19
|
+
await unlink(file);
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function payout(agent, client, method, args = {}) {
|
|
24
|
+
const root = join(agent.path, 'payouts');
|
|
25
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
26
|
+
const file = join(root, fingerprint(`${client.vaultId}:${client.accountId}`) + '.pending');
|
|
27
|
+
const lockFile = file + '.lock';
|
|
28
|
+
const lock = await open(lockFile, 'wx', 0o600).catch(error => {
|
|
29
|
+
if (error.code === 'EEXIST') throw new Error('VAULT_PAYOUT_BUSY');
|
|
30
|
+
throw error;
|
|
31
|
+
});
|
|
32
|
+
try {
|
|
33
|
+
const existing = await readOptional(file);
|
|
34
|
+
if (existing) throw new Error(`VAULT_PAYOUT_RECONCILIATION_REQUIRED: ${explorer(JSON.parse(existing).hash)}`);
|
|
35
|
+
const signed = await client.account.createSignedTransaction({ receiverId: client.vaultId, actions: [actions.functionCall(method, args, 100000000000000n)] });
|
|
36
|
+
const hash = baseEncode(createHash('sha256').update(encodeTransaction(signed.transaction)).digest());
|
|
37
|
+
const intent = { hash, method, accountId: client.accountId, vaultId: client.vaultId, rpcUrl: client.provider.connection.url };
|
|
38
|
+
await atomicWrite(file, JSON.stringify(intent));
|
|
39
|
+
let result;
|
|
40
|
+
try { result = await finalize(file, intent, await client.provider.sendTransactionUntil(signed, 'FINAL')); }
|
|
41
|
+
catch { throw new Error(`VAULT_PAYOUT_RECONCILIATION_REQUIRED: ${explorer(hash)}`); }
|
|
42
|
+
if (!result.paid) throw new Error(`VAULT_PAYOUT_FAILED: ${result.explorerUrl}`);
|
|
43
|
+
return result;
|
|
44
|
+
} finally { await lock.close(); await unlink(lockFile); }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function reconcilePayouts(agent) {
|
|
48
|
+
const root = join(agent.path, 'payouts');
|
|
49
|
+
let files;
|
|
50
|
+
try { files = await readdir(root); } catch (error) { if (error.code === 'ENOENT') return { resolved: true, transactions: [] }; throw error; }
|
|
51
|
+
const transactions = [];
|
|
52
|
+
for (const name of files.filter(item => /^[a-f0-9]{64}\.pending$/.test(item))) {
|
|
53
|
+
const file = join(root, name), lockFile = file + '.lock';
|
|
54
|
+
let lock;
|
|
55
|
+
try { lock = await open(lockFile, 'wx', 0o600); }
|
|
56
|
+
catch (error) { if (error.code !== 'EEXIST') throw error; transactions.push({ resolved: false, reason: 'VAULT_PAYOUT_BUSY' }); continue; }
|
|
57
|
+
try {
|
|
58
|
+
const pending = await readOptional(file);
|
|
59
|
+
if (!pending) continue;
|
|
60
|
+
const intent = JSON.parse(pending);
|
|
61
|
+
const provider = new JsonRpcProvider({ url: intent.rpcUrl });
|
|
62
|
+
try {
|
|
63
|
+
const outcome = await provider.viewTransactionStatus({ txHash: intent.hash, accountId: intent.accountId, waitUntil: 'FINAL' });
|
|
64
|
+
transactions.push(await finalize(file, intent, outcome));
|
|
65
|
+
} catch { transactions.push({ resolved: false, transactionHash: intent.hash, explorerUrl: explorer(intent.hash) }); }
|
|
66
|
+
} finally { await lock.close(); await unlink(lockFile); }
|
|
67
|
+
}
|
|
68
|
+
return { resolved: transactions.every(x => x.resolved), transactions };
|
|
69
|
+
}
|