@envarai/envarpay 0.2.0-alpha.8 → 2026.1025.1
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/README.md +18 -56
- package/dist/commerce/agent-setup.d.ts +19 -0
- package/dist/commerce/agent-setup.js +210 -0
- package/dist/commerce/alerts.d.ts +5 -15
- package/dist/commerce/alerts.js +21 -77
- package/dist/commerce/card.js +1 -1
- package/dist/commerce/cli.js +74 -137
- package/dist/commerce/config.d.ts +1 -4
- package/dist/commerce/config.js +5 -81
- package/dist/commerce/configure.d.ts +2 -0
- package/dist/commerce/configure.js +78 -0
- package/dist/commerce/discovery.d.ts +20 -0
- package/dist/commerce/discovery.js +162 -0
- package/dist/commerce/envar-balance.d.ts +25 -0
- package/dist/commerce/envar-balance.js +65 -0
- package/dist/commerce/envar-runtime.d.ts +1 -0
- package/dist/commerce/envar-runtime.js +7 -0
- package/dist/commerce/envar.d.ts +13 -31
- package/dist/commerce/envar.js +35 -83
- package/dist/commerce/external-agent.d.ts +26 -0
- package/dist/commerce/external-agent.js +129 -0
- package/dist/commerce/external-events.d.ts +76 -0
- package/dist/commerce/external-events.js +273 -0
- package/dist/commerce/external-mcp.d.ts +10 -0
- package/dist/commerce/external-mcp.js +199 -0
- package/dist/commerce/external-oauth.d.ts +12 -0
- package/dist/commerce/external-oauth.js +230 -0
- package/dist/commerce/external-runtime.d.ts +25 -0
- package/dist/commerce/external-runtime.js +244 -0
- package/dist/commerce/managed.d.ts +3 -0
- package/dist/commerce/managed.js +143 -0
- package/dist/commerce/native-inventory.d.ts +9 -0
- package/dist/commerce/native-inventory.js +75 -0
- package/dist/commerce/native-skills.d.ts +8 -0
- package/dist/commerce/native-skills.js +11 -8
- package/dist/commerce/openclaw-setup.d.ts +28 -0
- package/dist/commerce/openclaw-setup.js +43 -0
- package/dist/commerce/operations.d.ts +1 -2
- package/dist/commerce/operations.js +17 -20
- package/dist/commerce/pricing.d.ts +0 -1
- package/dist/commerce/pricing.js +1 -3
- package/dist/commerce/runtime.d.ts +2 -2
- package/dist/commerce/runtime.js +2 -2
- package/dist/commerce/server.d.ts +0 -1
- package/dist/commerce/server.js +2 -3
- package/dist/commerce/setup.d.ts +25 -0
- package/dist/commerce/setup.js +379 -0
- package/dist/commerce/store.d.ts +11 -1
- package/dist/commerce/store.js +36 -2
- package/dist/commerce/types.d.ts +1 -56
- package/examples/envar-integration-policy.json +1 -9
- package/examples/seller.json +143 -26
- package/package.json +11 -20
- package/runtime/hermes-setup-probe.py +109 -0
- package/runtime/hermes-skill-inventory.py +108 -0
- package/schemas/envarpay.config.schema.json +7 -73
- package/dist/commerce/buyer-store.d.ts +0 -101
- package/dist/commerce/buyer-store.js +0 -168
- package/dist/commerce/buyer.d.ts +0 -138
- package/dist/commerce/buyer.js +0 -775
- package/dist/commerce/chain.d.ts +0 -24
- package/dist/commerce/chain.js +0 -146
- package/dist/commerce/client-runtime.d.ts +0 -8
- package/dist/commerce/client-runtime.js +0 -7
- package/dist/commerce/management.d.ts +0 -14
- package/dist/commerce/management.js +0 -140
- package/dist/commerce/mpp-client.d.ts +0 -70
- package/dist/commerce/mpp-client.js +0 -29
- package/dist/commerce/mpp-runtime.d.ts +0 -4
- package/dist/commerce/mpp-runtime.js +0 -4
- package/dist/commerce/mpp.d.ts +0 -53
- package/dist/commerce/mpp.js +0 -163
- package/dist/commerce/peer-proxy.d.ts +0 -44
- package/dist/commerce/peer-proxy.js +0 -673
- package/dist/commerce/stripe-issuer.d.ts +0 -41
- package/dist/commerce/stripe-issuer.js +0 -351
- package/dist/commerce/stripe-provider.d.ts +0 -11
- package/dist/commerce/stripe-provider.js +0 -132
- package/dist/commerce/x402.d.ts +0 -37
- package/dist/commerce/x402.js +0 -174
- package/dist/index.d.ts +0 -95
- package/dist/index.js +0 -163
- package/examples/buyer-policy.json +0 -18
- package/schemas/buyer-policy.schema.json +0 -253
- /package/{examples → runtime}/native-skill-runner.py +0 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/** Single-user OAuth pairing for an owner-operated external Agent. PKCE, exact redirects and audience. */
|
|
2
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
import { chmodSync } from "node:fs";
|
|
5
|
+
import { CommerceError } from "./types.js";
|
|
6
|
+
const hash = (s) => createHash("sha256").update(s).digest("hex");
|
|
7
|
+
const escape = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
8
|
+
const random = () => randomBytes(32).toString("base64url");
|
|
9
|
+
export class ExternalAgentOAuth {
|
|
10
|
+
origin;
|
|
11
|
+
principal;
|
|
12
|
+
pairingKey;
|
|
13
|
+
db;
|
|
14
|
+
constructor(path, origin, principal, pairingKey) {
|
|
15
|
+
this.origin = origin;
|
|
16
|
+
this.principal = principal;
|
|
17
|
+
this.pairingKey = pairingKey;
|
|
18
|
+
this.db = new DatabaseSync(path);
|
|
19
|
+
chmodSync(path, 0o600);
|
|
20
|
+
this.db.exec("PRAGMA synchronous=FULL;CREATE TABLE IF NOT EXISTS clients(id TEXT PRIMARY KEY,redirects TEXT NOT NULL);CREATE TABLE IF NOT EXISTS consent(id TEXT PRIMARY KEY,request_json TEXT NOT NULL,expires INTEGER NOT NULL);CREATE TABLE IF NOT EXISTS codes(hash TEXT PRIMARY KEY,request_json TEXT NOT NULL,expires INTEGER NOT NULL);CREATE TABLE IF NOT EXISTS tokens(hash TEXT PRIMARY KEY,principal TEXT NOT NULL,expires INTEGER NOT NULL)");
|
|
21
|
+
if (pairingKey.length < 32)
|
|
22
|
+
throw new CommerceError("pairing_key", "Use an owner-only pairing key");
|
|
23
|
+
}
|
|
24
|
+
challenge() {
|
|
25
|
+
return ('Bearer resource_metadata="' +
|
|
26
|
+
this.origin +
|
|
27
|
+
'/.well-known/oauth-protected-resource/mcp", scope="envar:tasks"');
|
|
28
|
+
}
|
|
29
|
+
authenticate(request) {
|
|
30
|
+
const token = request.headers.get("Authorization")?.replace(/^Bearer /, "") ?? "";
|
|
31
|
+
const row = this.db
|
|
32
|
+
.prepare("SELECT principal,expires FROM tokens WHERE hash=?")
|
|
33
|
+
.get(hash(token));
|
|
34
|
+
return row && Number(row.expires) > Date.now()
|
|
35
|
+
? String(row.principal)
|
|
36
|
+
: null;
|
|
37
|
+
}
|
|
38
|
+
validRedirect(value) {
|
|
39
|
+
const u = new URL(value);
|
|
40
|
+
if (u.protocol !== "https:" || u.username || u.password || u.hash)
|
|
41
|
+
throw new CommerceError("oauth_redirect", "Use an exact registered HTTPS callback");
|
|
42
|
+
return u.href;
|
|
43
|
+
}
|
|
44
|
+
async handle(request) {
|
|
45
|
+
const url = new URL(request.url), path = url.pathname;
|
|
46
|
+
const json = (v, status = 200) => Response.json(v, { status, headers: { "Cache-Control": "no-store" } });
|
|
47
|
+
if (path.startsWith("/.well-known/oauth-protected-resource"))
|
|
48
|
+
return json({
|
|
49
|
+
resource: this.origin + "/mcp",
|
|
50
|
+
authorization_servers: [this.origin],
|
|
51
|
+
scopes_supported: ["envar:tasks"],
|
|
52
|
+
bearer_methods_supported: ["header"],
|
|
53
|
+
});
|
|
54
|
+
if (path.startsWith("/.well-known/oauth-authorization-server") ||
|
|
55
|
+
path.startsWith("/.well-known/openid-configuration"))
|
|
56
|
+
return json({
|
|
57
|
+
issuer: this.origin,
|
|
58
|
+
authorization_endpoint: this.origin + "/authorize",
|
|
59
|
+
token_endpoint: this.origin + "/token",
|
|
60
|
+
registration_endpoint: this.origin + "/register",
|
|
61
|
+
response_types_supported: ["code"],
|
|
62
|
+
grant_types_supported: ["authorization_code"],
|
|
63
|
+
code_challenge_methods_supported: ["S256"],
|
|
64
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
65
|
+
scopes_supported: ["envar:tasks"],
|
|
66
|
+
});
|
|
67
|
+
if (!["/register", "/authorize", "/token"].includes(path))
|
|
68
|
+
return undefined;
|
|
69
|
+
try {
|
|
70
|
+
if (path === "/register" && request.method === "POST") {
|
|
71
|
+
const text = await request.text();
|
|
72
|
+
if (Buffer.byteLength(text) > 8192)
|
|
73
|
+
throw Error();
|
|
74
|
+
const v = JSON.parse(text);
|
|
75
|
+
if (!Array.isArray(v.redirect_uris) ||
|
|
76
|
+
!v.redirect_uris.length ||
|
|
77
|
+
v.redirect_uris.length > 5 ||
|
|
78
|
+
(v.token_endpoint_auth_method &&
|
|
79
|
+
v.token_endpoint_auth_method !== "none"))
|
|
80
|
+
throw Error();
|
|
81
|
+
const redirects = v.redirect_uris.map((u) => this.validRedirect(u)), id = "client_" + random();
|
|
82
|
+
this.db
|
|
83
|
+
.prepare("INSERT INTO clients VALUES (?,?)")
|
|
84
|
+
.run(id, JSON.stringify(redirects));
|
|
85
|
+
return json({
|
|
86
|
+
client_id: id,
|
|
87
|
+
redirect_uris: redirects,
|
|
88
|
+
token_endpoint_auth_method: "none",
|
|
89
|
+
grant_types: ["authorization_code"],
|
|
90
|
+
response_types: ["code"],
|
|
91
|
+
}, 201);
|
|
92
|
+
}
|
|
93
|
+
if (path === "/authorize" && request.method === "GET") {
|
|
94
|
+
const v = Object.fromEntries(url.searchParams), client = this.db
|
|
95
|
+
.prepare("SELECT redirects FROM clients WHERE id=?")
|
|
96
|
+
.get(v.client_id ?? "");
|
|
97
|
+
if (!client ||
|
|
98
|
+
!JSON.parse(String(client.redirects)).includes(v.redirect_uri) ||
|
|
99
|
+
v.response_type !== "code" ||
|
|
100
|
+
v.code_challenge_method !== "S256" ||
|
|
101
|
+
!v.code_challenge ||
|
|
102
|
+
!/^[A-Za-z0-9_-]{43}$/.test(v.code_challenge) ||
|
|
103
|
+
(v.scope && v.scope !== "envar:tasks") ||
|
|
104
|
+
(v.resource && v.resource !== this.origin + "/mcp"))
|
|
105
|
+
throw Error();
|
|
106
|
+
const id = random();
|
|
107
|
+
this.db
|
|
108
|
+
.prepare("INSERT INTO consent VALUES (?,?,?)")
|
|
109
|
+
.run(id, JSON.stringify(v), Date.now() + 600000);
|
|
110
|
+
const body = '<!doctype html><meta name="viewport" content="width=device-width"><title>Connect Germey to Envar</title><main style="font:18px/1.6 system-ui;max-width:600px;margin:60px auto"><h1>Connect this external Agent</h1><p>Scope: claim and return bounded free text tasks for <b>' +
|
|
111
|
+
escape(this.principal) +
|
|
112
|
+
'</b>.</p><p>No payment, wallet, other Agent, or unrelated account access.</p><form method="post" action="/authorize"><input type="hidden" name="consent" value="' +
|
|
113
|
+
id +
|
|
114
|
+
'"><label>Owner pairing key <input type="password" name="pairing_key" required autocomplete="off"></label><p><button>Allow this task connection</button></p></form></main>';
|
|
115
|
+
return new Response(body, {
|
|
116
|
+
headers: {
|
|
117
|
+
"Content-Type": "text/html",
|
|
118
|
+
"Cache-Control": "no-store",
|
|
119
|
+
"Set-Cookie": "envar_pair_csrf=" +
|
|
120
|
+
id +
|
|
121
|
+
"; Secure; HttpOnly; SameSite=Strict; Path=/authorize; Max-Age=600",
|
|
122
|
+
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self' " +
|
|
123
|
+
new URL(v.redirect_uri).origin +
|
|
124
|
+
new URL(v.redirect_uri).pathname +
|
|
125
|
+
"; frame-ancestors 'none'",
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
if (path === "/authorize" && request.method === "POST") {
|
|
130
|
+
if (request.headers.get("Origin") !== this.origin)
|
|
131
|
+
throw Error();
|
|
132
|
+
const text = await request.text();
|
|
133
|
+
if (Buffer.byteLength(text) > 4096)
|
|
134
|
+
throw Error();
|
|
135
|
+
const form = new URLSearchParams(text), id = form.get("consent") ?? "", cookie = request.headers.get("Cookie") ?? "";
|
|
136
|
+
if (!cookie.split(";").some((c) => c.trim() === "envar_pair_csrf=" + id))
|
|
137
|
+
throw Error();
|
|
138
|
+
const provided = form.get("pairing_key") ?? "";
|
|
139
|
+
if (provided.length !== this.pairingKey.length ||
|
|
140
|
+
!timingSafeEqual(Buffer.from(provided), Buffer.from(this.pairingKey)))
|
|
141
|
+
throw Error();
|
|
142
|
+
const row = this.db
|
|
143
|
+
.prepare("SELECT request_json,expires FROM consent WHERE id=?")
|
|
144
|
+
.get(id);
|
|
145
|
+
if (!row || Number(row.expires) < Date.now())
|
|
146
|
+
throw Error();
|
|
147
|
+
const v = JSON.parse(String(row.request_json)), code = random();
|
|
148
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
149
|
+
try {
|
|
150
|
+
this.db.prepare("DELETE FROM consent WHERE id=?").run(id);
|
|
151
|
+
this.db
|
|
152
|
+
.prepare("INSERT INTO codes VALUES (?,?,?)")
|
|
153
|
+
.run(hash(code), JSON.stringify(v), Date.now() + 120000);
|
|
154
|
+
this.db.exec("COMMIT");
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
this.db.exec("ROLLBACK");
|
|
158
|
+
throw e;
|
|
159
|
+
}
|
|
160
|
+
const callback = new URL(v.redirect_uri);
|
|
161
|
+
callback.searchParams.set("code", code);
|
|
162
|
+
if (v.state)
|
|
163
|
+
callback.searchParams.set("state", v.state);
|
|
164
|
+
return new Response(null, {
|
|
165
|
+
status: 302,
|
|
166
|
+
headers: {
|
|
167
|
+
Location: callback.href,
|
|
168
|
+
"Cache-Control": "no-store",
|
|
169
|
+
"Set-Cookie": "envar_pair_csrf=; Secure; HttpOnly; SameSite=Strict; Path=/authorize; Max-Age=0",
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (path === "/token" && request.method === "POST") {
|
|
174
|
+
const text = await request.text();
|
|
175
|
+
if (Buffer.byteLength(text) > 8192)
|
|
176
|
+
throw Error();
|
|
177
|
+
const v = request.headers
|
|
178
|
+
.get("Content-Type")
|
|
179
|
+
?.includes("application/json")
|
|
180
|
+
? JSON.parse(text)
|
|
181
|
+
: Object.fromEntries(new URLSearchParams(text));
|
|
182
|
+
if (v.grant_type !== "authorization_code")
|
|
183
|
+
throw Error();
|
|
184
|
+
const row = this.db
|
|
185
|
+
.prepare("SELECT request_json,expires FROM codes WHERE hash=?")
|
|
186
|
+
.get(hash(v.code ?? ""));
|
|
187
|
+
if (!row || Number(row.expires) < Date.now())
|
|
188
|
+
throw Error();
|
|
189
|
+
const saved = JSON.parse(String(row.request_json));
|
|
190
|
+
if (v.client_id !== saved.client_id ||
|
|
191
|
+
v.redirect_uri !== saved.redirect_uri ||
|
|
192
|
+
(v.resource && v.resource !== this.origin + "/mcp") ||
|
|
193
|
+
typeof v.code_verifier !== "string" ||
|
|
194
|
+
v.code_verifier.length < 43 ||
|
|
195
|
+
v.code_verifier.length > 128 ||
|
|
196
|
+
createHash("sha256").update(v.code_verifier).digest("base64url") !==
|
|
197
|
+
saved.code_challenge)
|
|
198
|
+
throw Error();
|
|
199
|
+
const token = random();
|
|
200
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
201
|
+
try {
|
|
202
|
+
if (!this.db.prepare("DELETE FROM codes WHERE hash=?").run(hash(v.code))
|
|
203
|
+
.changes)
|
|
204
|
+
throw Error();
|
|
205
|
+
this.db
|
|
206
|
+
.prepare("INSERT INTO tokens VALUES (?,?,?)")
|
|
207
|
+
.run(hash(token), this.principal, Date.now() + 86400000);
|
|
208
|
+
this.db.exec("COMMIT");
|
|
209
|
+
}
|
|
210
|
+
catch (e) {
|
|
211
|
+
this.db.exec("ROLLBACK");
|
|
212
|
+
throw e;
|
|
213
|
+
}
|
|
214
|
+
return json({
|
|
215
|
+
access_token: token,
|
|
216
|
+
token_type: "Bearer",
|
|
217
|
+
expires_in: 86400,
|
|
218
|
+
scope: "envar:tasks",
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return json({ error: "method_not_allowed" }, 405);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return json({ error: "invalid_request" }, 400);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
close() {
|
|
228
|
+
this.db.close();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { CommerceStore } from "./store.js";
|
|
2
|
+
import { CommerceServer } from "./server.js";
|
|
3
|
+
import { ExternalAgentEvents, type CallbackLookup } from "./external-events.js";
|
|
4
|
+
export declare function setupExternalAgent(directory: string, origin: string): {
|
|
5
|
+
directory: string;
|
|
6
|
+
cardUrl: string;
|
|
7
|
+
mcpUrl: string;
|
|
8
|
+
credentialsFile: string;
|
|
9
|
+
paymentsEnabled: boolean;
|
|
10
|
+
};
|
|
11
|
+
export declare function serveExternalAgent(options: {
|
|
12
|
+
configFile: string;
|
|
13
|
+
credentialsFile: string;
|
|
14
|
+
stateFile: string;
|
|
15
|
+
origin: string;
|
|
16
|
+
host: string;
|
|
17
|
+
port: number;
|
|
18
|
+
callbackLookup?: CallbackLookup;
|
|
19
|
+
}): {
|
|
20
|
+
http: import("http").Server<typeof import("http").IncomingMessage, typeof import("http").ServerResponse>;
|
|
21
|
+
store: CommerceStore;
|
|
22
|
+
events: ExternalAgentEvents;
|
|
23
|
+
server: CommerceServer;
|
|
24
|
+
close: () => Promise<void>;
|
|
25
|
+
};
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { ExternalAgentOAuth } from "./external-oauth.js";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { Readable } from "node:stream";
|
|
4
|
+
import { join, dirname, resolve } from "node:path";
|
|
5
|
+
import { readFileSync, lstatSync, chmodSync, mkdirSync, writeFileSync, } from "node:fs";
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
7
|
+
import { CommerceStore } from "./store.js";
|
|
8
|
+
import { CommerceServer, bearerAuthenticator } from "./server.js";
|
|
9
|
+
import { loadCommerceConfig } from "./config.js";
|
|
10
|
+
import { ExternalAgentExecutor } from "./external-agent.js";
|
|
11
|
+
import { ExternalAgentEvents, publicWebhook, } from "./external-events.js";
|
|
12
|
+
import { ExternalAgentMcp, externalAgentSkill } from "./external-mcp.js";
|
|
13
|
+
import { CredentialVault } from "./vault.js";
|
|
14
|
+
import { CommerceError } from "./types.js";
|
|
15
|
+
export function setupExternalAgent(directory, origin) {
|
|
16
|
+
directory = resolve(directory);
|
|
17
|
+
const u = new URL(origin);
|
|
18
|
+
if (u.protocol !== "https:" || u.origin !== origin)
|
|
19
|
+
throw new CommerceError("external_origin", "Provide the exact public HTTPS origin");
|
|
20
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
21
|
+
const config = {
|
|
22
|
+
configVersion: 1,
|
|
23
|
+
agent: { id: "external-agent", name: "External Agent" },
|
|
24
|
+
paymentProfiles: {},
|
|
25
|
+
services: [
|
|
26
|
+
{
|
|
27
|
+
id: "text-answer",
|
|
28
|
+
name: "Bounded text answer",
|
|
29
|
+
revision: 1,
|
|
30
|
+
execution: { type: "a2a", cardUrl: origin + "/agent-card.json" },
|
|
31
|
+
contract: {
|
|
32
|
+
inputSchema: {
|
|
33
|
+
type: "object",
|
|
34
|
+
properties: {
|
|
35
|
+
request: { type: "string", minLength: 1, maxLength: 2000 },
|
|
36
|
+
},
|
|
37
|
+
required: ["request"],
|
|
38
|
+
additionalProperties: false,
|
|
39
|
+
},
|
|
40
|
+
deliverables: ["external-agent-result.txt"],
|
|
41
|
+
targetDurationSeconds: 600,
|
|
42
|
+
includedRevisions: 0,
|
|
43
|
+
},
|
|
44
|
+
offers: [
|
|
45
|
+
{
|
|
46
|
+
id: "free",
|
|
47
|
+
pricing: { kind: "free" },
|
|
48
|
+
collection: { kind: "none" },
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
loadCommerceConfig(config);
|
|
55
|
+
const agentToken = randomBytes(32).toString("base64url"), buyerToken = randomBytes(32).toString("base64url");
|
|
56
|
+
const credentials = {
|
|
57
|
+
principal: "external-agent",
|
|
58
|
+
services: ["text-answer"],
|
|
59
|
+
agentTokens: { [agentToken]: "external-agent" },
|
|
60
|
+
callers: { [buyerToken]: "owner" },
|
|
61
|
+
vaultKeyFile: join(directory, "events.key"),
|
|
62
|
+
};
|
|
63
|
+
for (const [name, value] of Object.entries({
|
|
64
|
+
"seller.json": JSON.stringify(config, null, 2),
|
|
65
|
+
"credentials.json": JSON.stringify(credentials, null, 2),
|
|
66
|
+
"events.key": randomBytes(32),
|
|
67
|
+
"agent.token": agentToken,
|
|
68
|
+
"pairing.key": randomBytes(32).toString("base64url"),
|
|
69
|
+
"buyer.token": buyerToken,
|
|
70
|
+
"SKILL.md": externalAgentSkill,
|
|
71
|
+
"connection.json": JSON.stringify({
|
|
72
|
+
url: origin + "/services/text-answer/v1/offers/free/agent-card.json",
|
|
73
|
+
authToken: buyerToken,
|
|
74
|
+
mcpUrl: origin + "/mcp",
|
|
75
|
+
}),
|
|
76
|
+
}))
|
|
77
|
+
writeFileSync(join(directory, name), value, { mode: 0o600, flag: "wx" });
|
|
78
|
+
return {
|
|
79
|
+
directory,
|
|
80
|
+
cardUrl: origin + "/services/text-answer/v1/offers/free/agent-card.json",
|
|
81
|
+
mcpUrl: origin + "/mcp",
|
|
82
|
+
credentialsFile: join(directory, "credentials.json"),
|
|
83
|
+
paymentsEnabled: false,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export function serveExternalAgent(options) {
|
|
87
|
+
const privateBytes = (path) => {
|
|
88
|
+
const s = lstatSync(path);
|
|
89
|
+
if (!s.isFile() || s.isSymbolicLink() || s.mode & 0o077 || s.size > 65536)
|
|
90
|
+
throw new CommerceError("external_private_file", "Use private regular config/credential files");
|
|
91
|
+
return readFileSync(path);
|
|
92
|
+
};
|
|
93
|
+
const privateJson = (path) => JSON.parse(privateBytes(path).toString("utf8"));
|
|
94
|
+
const config = loadCommerceConfig(privateJson(options.configFile)), secret = privateJson(options.credentialsFile);
|
|
95
|
+
if (config.services.some((s) => s.execution.type !== "a2a" ||
|
|
96
|
+
s.offers.some((o) => o.pricing.kind !== "free")) ||
|
|
97
|
+
Object.keys(config.paymentProfiles).length)
|
|
98
|
+
throw new CommerceError("external_free_only", "External setup currently supports explicit free task acceptance only");
|
|
99
|
+
if (Object.keys(secret.agentTokens).some((token) => Object.hasOwn(secret.callers, token)))
|
|
100
|
+
throw new CommerceError("external_token_scope", "External Agent tokens must differ from buyer credentials");
|
|
101
|
+
const store = new CommerceStore(options.stateFile), executor = new ExternalAgentExecutor(store, secret.principal, secret.services), vault = new CredentialVault(join(dirname(options.stateFile), "event-secrets"), privateBytes(secret.vaultKeyFile));
|
|
102
|
+
const pairingFile = join(dirname(options.credentialsFile), "pairing.key"), oauth = new ExternalAgentOAuth(options.stateFile + ".oauth.sqlite3", options.origin, secret.principal, privateBytes(pairingFile).toString("utf8").trim());
|
|
103
|
+
const agentAuth = bearerAuthenticator(secret.agentTokens);
|
|
104
|
+
const eventsPath = options.stateFile + ".events.sqlite3", events = new ExternalAgentEvents(eventsPath, executor, vault, (url, body, headers) => publicWebhook(url, body, headers, options.callbackLookup));
|
|
105
|
+
chmodSync(eventsPath, 0o600);
|
|
106
|
+
const mcp = new ExternalAgentMcp(executor, events, async (r) => oauth.authenticate(r) ?? (await agentAuth(r)));
|
|
107
|
+
const server = new CommerceServer({
|
|
108
|
+
config,
|
|
109
|
+
store,
|
|
110
|
+
origin: options.origin,
|
|
111
|
+
authenticate: bearerAuthenticator(secret.callers),
|
|
112
|
+
execute: executor.execute,
|
|
113
|
+
});
|
|
114
|
+
const expected = new URL(options.origin).host;
|
|
115
|
+
async function externalA2a(request) {
|
|
116
|
+
const path = new URL(request.url).pathname;
|
|
117
|
+
let normalized = request;
|
|
118
|
+
if (request.method === "POST" && path.endsWith("/a2a")) {
|
|
119
|
+
const rawText = await request.text();
|
|
120
|
+
if (Buffer.byteLength(rawText) > 65536)
|
|
121
|
+
return Response.json({ error: "request_too_large" }, { status: 413 });
|
|
122
|
+
let raw;
|
|
123
|
+
try {
|
|
124
|
+
raw = JSON.parse(rawText);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return Response.json({ error: "invalid_json" }, { status: 400 });
|
|
128
|
+
}
|
|
129
|
+
if (raw.method === "SendMessage") {
|
|
130
|
+
const message = raw.params?.message;
|
|
131
|
+
if (message?.parts?.length === 1 &&
|
|
132
|
+
typeof message.parts[0]?.text === "string" &&
|
|
133
|
+
Object.keys(message.parts[0]).every((k) => ["text", "mediaType"].includes(k))) {
|
|
134
|
+
const text = message.parts[0].text;
|
|
135
|
+
// This opt-in external service advertises a bounded plain-text request.
|
|
136
|
+
// A JSON-text request with exactly that schema is also accepted.
|
|
137
|
+
let input = { request: text };
|
|
138
|
+
try {
|
|
139
|
+
const parsed = JSON.parse(text);
|
|
140
|
+
if (parsed &&
|
|
141
|
+
!Array.isArray(parsed) &&
|
|
142
|
+
Object.keys(parsed).length === 1 &&
|
|
143
|
+
typeof parsed.request === "string")
|
|
144
|
+
input = parsed;
|
|
145
|
+
}
|
|
146
|
+
catch { }
|
|
147
|
+
message.parts = [{ data: input }];
|
|
148
|
+
}
|
|
149
|
+
raw.params.configuration = {
|
|
150
|
+
...raw.params.configuration,
|
|
151
|
+
returnImmediately: true,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
normalized = new Request(request.url, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: request.headers,
|
|
157
|
+
body: JSON.stringify(raw),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
const response = await server.handle(normalized);
|
|
161
|
+
if (response.status !== 200)
|
|
162
|
+
return response;
|
|
163
|
+
const value = await response.json();
|
|
164
|
+
if (request.method === "GET" && path.endsWith("/agent-card.json")) {
|
|
165
|
+
value.defaultInputModes = ["application/json", "text/plain"];
|
|
166
|
+
}
|
|
167
|
+
// Internally the completed dispatch queue waits for an external claim.
|
|
168
|
+
// A2A buyers see WORKING and can poll until this original Task completes.
|
|
169
|
+
for (const task of [value.result?.task, value.result]) {
|
|
170
|
+
if (task?.status?.state === "TASK_STATE_INPUT_REQUIRED" &&
|
|
171
|
+
task.metadata?.externalAgent) {
|
|
172
|
+
task.status.state = "TASK_STATE_WORKING";
|
|
173
|
+
delete task.status.message;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return Response.json(value, {
|
|
177
|
+
status: response.status,
|
|
178
|
+
headers: response.headers,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const http = createServer(async (req, res) => {
|
|
182
|
+
try {
|
|
183
|
+
if (req.headers.host !== expected) {
|
|
184
|
+
res.writeHead(421);
|
|
185
|
+
res.end();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const headers = new Headers();
|
|
189
|
+
for (const [k, v] of Object.entries(req.headers))
|
|
190
|
+
if (v !== undefined)
|
|
191
|
+
headers.set(k, Array.isArray(v) ? v.join(",") : v);
|
|
192
|
+
const request = new Request(new URL(req.url ?? "/", options.origin), {
|
|
193
|
+
method: req.method,
|
|
194
|
+
headers,
|
|
195
|
+
...(!["GET", "HEAD"].includes(req.method ?? "GET")
|
|
196
|
+
? { body: Readable.toWeb(req), duplex: "half" }
|
|
197
|
+
: {}),
|
|
198
|
+
});
|
|
199
|
+
let response = await oauth.handle(request);
|
|
200
|
+
if (!response)
|
|
201
|
+
response =
|
|
202
|
+
new URL(request.url).pathname === "/mcp"
|
|
203
|
+
? await mcp.handle(request)
|
|
204
|
+
: await externalA2a(request);
|
|
205
|
+
if (response.status === 401 && new URL(request.url).pathname === "/mcp")
|
|
206
|
+
response = new Response(response.body, {
|
|
207
|
+
status: 401,
|
|
208
|
+
headers: {
|
|
209
|
+
...Object.fromEntries(response.headers),
|
|
210
|
+
"WWW-Authenticate": oauth.challenge(),
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
res.writeHead(response.status, Object.fromEntries(response.headers));
|
|
214
|
+
if (response.body)
|
|
215
|
+
Readable.fromWeb(response.body).pipe(res);
|
|
216
|
+
else
|
|
217
|
+
res.end();
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
if (!res.headersSent)
|
|
221
|
+
res.writeHead(500);
|
|
222
|
+
res.end('{"error":"request_failed"}');
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
http.requestTimeout = 30000;
|
|
226
|
+
http.headersTimeout = 15000;
|
|
227
|
+
http.listen(options.port, options.host);
|
|
228
|
+
events.start();
|
|
229
|
+
return {
|
|
230
|
+
http,
|
|
231
|
+
store,
|
|
232
|
+
events,
|
|
233
|
+
server,
|
|
234
|
+
close: async () => {
|
|
235
|
+
oauth.close();
|
|
236
|
+
await events.close();
|
|
237
|
+
server.stop();
|
|
238
|
+
await new Promise((resolve) => http.close(() => resolve()));
|
|
239
|
+
while (server.isRunning)
|
|
240
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
241
|
+
store.close();
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare function managedProfile(root: string): string;
|
|
2
|
+
export declare function activateManagedProfile(root: string, agentId: string, profile: string): void;
|
|
3
|
+
export declare function serveManaged(root: string, origin: string, host: string, port: number): Promise<void>;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/** A persistent native-runtime host for the existing seller CLI. No wallet authority is added. */
|
|
2
|
+
import { chownSync, closeSync, existsSync, fsyncSync, linkSync, lstatSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { once } from 'node:events';
|
|
7
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
8
|
+
import { CommerceError } from './types.js';
|
|
9
|
+
const idPattern = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/;
|
|
10
|
+
function rootDirectory(root) {
|
|
11
|
+
const path = resolve(root), stat = lstatSync(path);
|
|
12
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077))
|
|
13
|
+
throw new CommerceError('managed_directory', 'Use the existing private managed EnvarPay directory');
|
|
14
|
+
return path;
|
|
15
|
+
}
|
|
16
|
+
export function managedProfile(root) {
|
|
17
|
+
const base = rootDirectory(root), file = join(base, 'active-profile.json');
|
|
18
|
+
const profiles = join(base, 'profiles');
|
|
19
|
+
if (existsSync(profiles)) {
|
|
20
|
+
const stat = lstatSync(profiles);
|
|
21
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077))
|
|
22
|
+
throw new CommerceError('managed_profile', 'Use a private profile directory without links');
|
|
23
|
+
}
|
|
24
|
+
if (!existsSync(file))
|
|
25
|
+
return base;
|
|
26
|
+
const stat = lstatSync(file);
|
|
27
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 || (stat.mode & 0o077))
|
|
28
|
+
throw new CommerceError('managed_profile', 'Invalid managed profile');
|
|
29
|
+
const value = JSON.parse(readFileSync(file, 'utf8'));
|
|
30
|
+
if (value.version !== 1 || !idPattern.test(value.agentId))
|
|
31
|
+
throw new CommerceError('managed_profile', 'Invalid managed Agent identity');
|
|
32
|
+
const path = join(base, 'profiles', value.agentId), p = lstatSync(path);
|
|
33
|
+
if (!p.isDirectory() || p.isSymbolicLink() || (p.mode & 0o077))
|
|
34
|
+
throw new CommerceError('managed_profile', 'Use the original private profile');
|
|
35
|
+
return path;
|
|
36
|
+
}
|
|
37
|
+
export function activateManagedProfile(root, agentId, profile) {
|
|
38
|
+
const base = rootDirectory(root);
|
|
39
|
+
if (!idPattern.test(agentId) || resolve(profile) !== join(base, 'profiles', agentId))
|
|
40
|
+
throw new CommerceError('managed_profile', 'Profile does not belong to this managed Agent');
|
|
41
|
+
const previous = managedProfile(base);
|
|
42
|
+
if (previous !== base && previous !== resolve(profile))
|
|
43
|
+
throw new CommerceError('managed_bound', 'This runtime is already bound to another Agent; preserve its ledger');
|
|
44
|
+
if (previous === base) {
|
|
45
|
+
const config = JSON.parse(readFileSync(join(base, 'seller.json'), 'utf8'));
|
|
46
|
+
if (config.services?.length || Object.keys(config.paymentProfiles ?? {}).length)
|
|
47
|
+
throw new CommerceError('managed_in_use', 'Existing seller configuration requires review');
|
|
48
|
+
const ledger = join(base, 'seller.sqlite3');
|
|
49
|
+
if (existsSync(ledger)) {
|
|
50
|
+
const db = new DatabaseSync(ledger, { readOnly: true });
|
|
51
|
+
try {
|
|
52
|
+
for (const table of ['commerce_orders', 'commerce_service_revisions']) {
|
|
53
|
+
if (db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(table) && Number(db.prepare(`SELECT count(*) AS n FROM ${table}`).get()?.n))
|
|
54
|
+
throw new CommerceError('managed_in_use', 'Keep the existing seller ledger and inspect its operations before connecting');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
db.close();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const owner = lstatSync(base);
|
|
63
|
+
function ownership(path) {
|
|
64
|
+
const s = lstatSync(path);
|
|
65
|
+
if (s.isSymbolicLink() || (!s.isDirectory() && !s.isFile()))
|
|
66
|
+
throw new CommerceError('managed_profile', 'Managed profiles cannot contain links or special files');
|
|
67
|
+
if (process.getuid?.() === 0)
|
|
68
|
+
chownSync(path, owner.uid, owner.gid);
|
|
69
|
+
if (s.isDirectory())
|
|
70
|
+
for (const name of readdirSync(path))
|
|
71
|
+
ownership(join(path, name));
|
|
72
|
+
}
|
|
73
|
+
ownership(join(base, 'profiles'));
|
|
74
|
+
const connection = JSON.parse(readFileSync(join(profile, 'connection.json'), 'utf8'));
|
|
75
|
+
if (connection.agentId !== agentId)
|
|
76
|
+
throw new CommerceError('managed_profile', 'Connection belongs to another Agent');
|
|
77
|
+
if (previous === resolve(profile))
|
|
78
|
+
return;
|
|
79
|
+
const temp = join(base, `.active-${process.pid}.json`);
|
|
80
|
+
writeFileSync(temp, JSON.stringify({ version: 1, agentId }), { mode: 0o600, flag: 'wx', flush: true });
|
|
81
|
+
if (process.getuid?.() === 0)
|
|
82
|
+
chownSync(temp, owner.uid, owner.gid);
|
|
83
|
+
try {
|
|
84
|
+
// Atomic create prevents two setup processes from replacing one another's binding.
|
|
85
|
+
linkSync(temp, join(base, 'active-profile.json'));
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (error.code !== 'EEXIST' || managedProfile(base) !== resolve(profile))
|
|
89
|
+
throw new CommerceError('managed_bound', 'This runtime was bound by another setup; preserve its original profile');
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
unlinkSync(temp);
|
|
93
|
+
}
|
|
94
|
+
const directory = openSync(base, 'r');
|
|
95
|
+
try {
|
|
96
|
+
fsyncSync(directory);
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
closeSync(directory);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export async function serveManaged(root, origin, host, port) {
|
|
103
|
+
const base = rootDirectory(root), cli = fileURLToPath(new URL('./cli.js', import.meta.url));
|
|
104
|
+
let child, stopping = false;
|
|
105
|
+
const stop = () => { stopping = true; child?.kill('SIGTERM'); };
|
|
106
|
+
process.once('SIGTERM', stop);
|
|
107
|
+
process.once('SIGINT', stop);
|
|
108
|
+
try {
|
|
109
|
+
while (!stopping) {
|
|
110
|
+
const profile = managedProfile(base);
|
|
111
|
+
const stamp = () => ['credentials.json', 'envar.json', 'envar.token'].map(name => { const p = join(profile, name); return existsSync(p) ? lstatSync(p).mtimeMs : 0; }).join(':');
|
|
112
|
+
const initialStamp = stamp();
|
|
113
|
+
const args = [cli, 'serve', '--config', join(profile, 'seller.json'), '--credentials', join(profile, 'credentials.json'), '--state', join(profile, 'seller.sqlite3'), '--origin', origin, '--host', host, '--port', String(port)];
|
|
114
|
+
if (profile !== base)
|
|
115
|
+
args.push('--envar-config', join(profile, 'envar.json'), '--envar-credentials', join(profile, 'envar.token'));
|
|
116
|
+
child = spawn(process.execPath, args, { stdio: 'inherit' });
|
|
117
|
+
let switching = false;
|
|
118
|
+
const timer = setInterval(() => {
|
|
119
|
+
try {
|
|
120
|
+
if (!switching && (managedProfile(base) !== profile || stamp() !== initialStamp)) {
|
|
121
|
+
switching = true;
|
|
122
|
+
child?.kill('SIGTERM');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
stop();
|
|
127
|
+
}
|
|
128
|
+
}, 1000);
|
|
129
|
+
try {
|
|
130
|
+
const [code] = await once(child, 'exit');
|
|
131
|
+
if (!stopping && !switching)
|
|
132
|
+
throw new CommerceError('managed_runtime_exit', `Seller stopped (${code}); keep its profile and inspect its configuration`);
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
clearInterval(timer);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
process.removeListener('SIGTERM', stop);
|
|
141
|
+
process.removeListener('SIGINT', stop);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const isSkillInstallerMetadata: (path: string) => path is ".clawhub/origin.json" | ".openclaw/source-origin.json";
|
|
2
|
+
export declare function readSkillInventory(roots: string[]): {
|
|
3
|
+
name: string;
|
|
4
|
+
path: string;
|
|
5
|
+
description: string;
|
|
6
|
+
supported: boolean;
|
|
7
|
+
digest: string;
|
|
8
|
+
files: number;
|
|
9
|
+
}[];
|