agent402-openclaw 0.1.0 → 0.1.2
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 +9 -4
- package/cli.js +22 -5
- package/package.json +1 -1
- package/proxy.js +22 -4
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ Guide: https://agent402.tools/guides/openclaw-model-provider
|
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
13
|
openclaw plugins install agent402-openclaw
|
|
14
|
-
agent402-openclaw setup --credits-key
|
|
14
|
+
AGENT402_CREDITS_KEY=a402_... agent402-openclaw setup --write # key by env (or `--credits-key -` on stdin); buy one by card at https://agent402.tools/credits
|
|
15
15
|
openclaw gateway restart
|
|
16
16
|
```
|
|
17
17
|
|
|
@@ -38,12 +38,17 @@ wallet. The key never leaves the machine.
|
|
|
38
38
|
`auto` (routed per prompt) plus every id from `GET https://agent402.tools/v1/models`,
|
|
39
39
|
each routed to its home tier. Prices are per call, not per token, so OpenClaw's
|
|
40
40
|
per-token cost fields are zero. A model sent to the wrong tier is answered with a
|
|
41
|
-
400 naming the right one; nothing is charged.
|
|
42
|
-
|
|
41
|
+
400 naming the right one; nothing is charged. A client-supplied `Idempotency-Key`
|
|
42
|
+
is passed through (an x402 retry with the same key replays the paid answer);
|
|
43
|
+
without one, each call is its own payment.
|
|
44
|
+
|
|
45
|
+
The proxy answers native clients on loopback only: requests carrying a browser
|
|
46
|
+
`Origin` header or a non-loopback `Host` are refused, so a web page cannot spend
|
|
47
|
+
the key.
|
|
43
48
|
|
|
44
49
|
## Commands
|
|
45
50
|
|
|
46
|
-
- `agent402-openclaw setup [--credits-key K] [--write] [--port N]`
|
|
51
|
+
- `agent402-openclaw setup [--credits-key K | --credits-key - (stdin) | AGENT402_CREDITS_KEY env] [--write] [--port N]`
|
|
47
52
|
- `agent402-openclaw proxy [--port N] [--upstream URL]`
|
|
48
53
|
- `agent402-openclaw doctor`
|
|
49
54
|
|
package/cli.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// setup [--credits-key a402_...] [--write] store the key, print (or merge) the openclaw.json block
|
|
4
4
|
// proxy [--port N] [--upstream URL] run the local proxy on its own (no OpenClaw needed)
|
|
5
5
|
// doctor show what is configured and whether the gateway answers
|
|
6
|
-
import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync } from "node:fs";
|
|
6
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, statSync, realpathSync, renameSync } from "node:fs";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
7
8
|
import { homedir } from "node:os";
|
|
8
9
|
import { join } from "node:path";
|
|
9
10
|
import { STATE_DIR, CREDITS_KEY_FILE, resolveCreditsKey, resolvePayFetch } from "./index.js";
|
|
@@ -43,7 +44,11 @@ async function main() {
|
|
|
43
44
|
const port = Number(opt("--port")) || DEFAULT_PORT;
|
|
44
45
|
|
|
45
46
|
if (cmd === "setup") {
|
|
46
|
-
|
|
47
|
+
// The key may arrive on argv (lands in shell history), via AGENT402_CREDITS_KEY,
|
|
48
|
+
// or on stdin with `--credits-key -` (the form the docs recommend).
|
|
49
|
+
let key = (opt("--credits-key") || "").trim();
|
|
50
|
+
if (key === "-") key = readFileSync(0, "utf8").trim();
|
|
51
|
+
if (!key && process.env.AGENT402_CREDITS_KEY && !has("--no-env")) key = process.env.AGENT402_CREDITS_KEY.trim();
|
|
47
52
|
if (key) {
|
|
48
53
|
if (!/^a402_[A-Za-z0-9_-]{16,80}$/.test(key)) { console.error("that does not look like an Agent402 credits key (a402_...)"); return 2; }
|
|
49
54
|
mkdirSync(STATE_DIR(), { recursive: true });
|
|
@@ -64,9 +69,14 @@ async function main() {
|
|
|
64
69
|
catch { console.error(`${cfgPath} is not plain JSON (JSON5 comments?); not touching it. Merge this block by hand:`); out(JSON.stringify(block, null, 2)); return 2; }
|
|
65
70
|
}
|
|
66
71
|
mkdirSync(join(cfgPath, ".."), { recursive: true });
|
|
72
|
+
// Preserve the file's mode (openclaw.json holds other providers' keys and
|
|
73
|
+
// is often 0600); a fresh file is created 0600. Never loosen it.
|
|
74
|
+
let mode = 0o600;
|
|
75
|
+
try { if (existsSync(cfgPath)) mode = statSync(cfgPath).mode & 0o777; } catch { /* keep 0600 */ }
|
|
67
76
|
const tmp = `${cfgPath}.${process.pid}.tmp`;
|
|
68
|
-
writeFileSync(tmp, JSON.stringify(mergeInto(existing, block), null, 2) + "\n");
|
|
69
|
-
|
|
77
|
+
writeFileSync(tmp, JSON.stringify(mergeInto(existing, block), null, 2) + "\n", { mode });
|
|
78
|
+
try { chmodSync(tmp, mode); } catch { /* best effort */ }
|
|
79
|
+
renameSync(tmp, cfgPath);
|
|
70
80
|
out(`wrote provider "${PROVIDER_ID}" + primary model ${PROVIDER_ID}/auto into ${cfgPath}; run: openclaw gateway restart`);
|
|
71
81
|
} else {
|
|
72
82
|
out(`add to ${cfgPath} (or rerun with --write):`);
|
|
@@ -102,6 +112,13 @@ async function main() {
|
|
|
102
112
|
console.error(`unknown command: ${cmd}`); return 2;
|
|
103
113
|
}
|
|
104
114
|
|
|
105
|
-
|
|
115
|
+
// Entry guard that survives npm's bin SYMLINK and paths with spaces: Node
|
|
116
|
+
// resolves import.meta.url to the real path but leaves process.argv[1] as the
|
|
117
|
+
// symlink, so a plain string compare is false for every `npx`/global install
|
|
118
|
+
// and the CLI silently did nothing (0.1.0 and 0.1.1 shipped that way).
|
|
119
|
+
function invokedDirectly() {
|
|
120
|
+
try { return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; } catch { return false; }
|
|
121
|
+
}
|
|
122
|
+
if (invokedDirectly()) {
|
|
106
123
|
main().then((c) => process.exit(c ?? 0), (e) => { console.error(e?.message || e); process.exit(1); });
|
|
107
124
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent402-openclaw",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "OpenClaw model provider for Agent402: auto-routed and explicit LLM models at a flat per-call price, paid by card (prepaid credits key) or in USDC over x402 through a local proxy. Zero dependencies; x402 wallet payment is an optional peer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
package/proxy.js
CHANGED
|
@@ -14,14 +14,24 @@
|
|
|
14
14
|
// gateway's own GET /v1/models at start (never a hand-typed table). "auto"
|
|
15
15
|
// goes to the routed tier with the model field omitted.
|
|
16
16
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
17
|
+
// Idempotency: a client-supplied Idempotency-Key is passed through so an
|
|
18
|
+
// x402 retry with the same key replays the paid answer server-side; without
|
|
19
|
+
// one each forwarded call gets a fresh key (a safety for the fetch layer's
|
|
20
|
+
// own 402->pay retry), which does NOT make two client calls one payment.
|
|
21
|
+
// Streams pass through byte for byte.
|
|
22
|
+
//
|
|
23
|
+
// Loopback only, and browser-hostile on purpose: any web page can POST to
|
|
24
|
+
// 127.0.0.1 with a "simple" no-cors request, and this proxy spends the
|
|
25
|
+
// user's key, so a request carrying an Origin header (browsers always send
|
|
26
|
+
// one on cross-origin POSTs; native clients send none) or a Host that is
|
|
27
|
+
// not loopback is refused before anything is forwarded.
|
|
20
28
|
import { createServer } from "node:http";
|
|
29
|
+
import { readFileSync } from "node:fs";
|
|
21
30
|
import { randomUUID } from "node:crypto";
|
|
22
31
|
import { AUTO_ID, routesFromCatalog, stripTrailingSlashes } from "./models.js";
|
|
23
32
|
|
|
24
33
|
export const DEFAULT_UPSTREAM = "https://agent402.tools";
|
|
34
|
+
export const PKG_VERSION = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).version;
|
|
25
35
|
const MAX_BODY = 2 * 1024 * 1024;
|
|
26
36
|
|
|
27
37
|
export async function loadRoutes(upstream, fetchImpl = fetch) {
|
|
@@ -67,6 +77,13 @@ export async function startProxy({ upstream = DEFAULT_UPSTREAM, creditsKey = nul
|
|
|
67
77
|
stats.requests++;
|
|
68
78
|
const url = new URL(req.url, "http://localhost");
|
|
69
79
|
try {
|
|
80
|
+
if (req.headers.origin !== undefined) {
|
|
81
|
+
return json(res, 403, { error: { message: "Browser-origin requests are refused: this proxy spends a payment key and answers native clients only.", type: "forbidden" } });
|
|
82
|
+
}
|
|
83
|
+
const hostName = String(req.headers.host || "").replace(/:\d+$/, "").replace(/^\[|\]$/g, "");
|
|
84
|
+
if (hostName && !["127.0.0.1", "localhost", "::1"].includes(hostName)) {
|
|
85
|
+
return json(res, 403, { error: { message: `Host "${hostName.slice(0, 64)}" is not loopback; refused.`, type: "forbidden" } });
|
|
86
|
+
}
|
|
70
87
|
if (req.method === "GET" && url.pathname === "/health") {
|
|
71
88
|
return json(res, 200, { ok: true, upstream, mode, models: table.size, stats });
|
|
72
89
|
}
|
|
@@ -87,7 +104,8 @@ export async function startProxy({ upstream = DEFAULT_UPSTREAM, creditsKey = nul
|
|
|
87
104
|
}
|
|
88
105
|
const outbound = { ...body };
|
|
89
106
|
if (requested === AUTO_ID) delete outbound.model; else outbound.model = route.id;
|
|
90
|
-
const
|
|
107
|
+
const clientIdem = typeof req.headers["idempotency-key"] === "string" && /^[\w.:-]{8,128}$/.test(req.headers["idempotency-key"]) ? req.headers["idempotency-key"] : null;
|
|
108
|
+
const headers = { "content-type": "application/json", accept: req.headers.accept || "application/json", "idempotency-key": clientIdem || randomUUID(), "user-agent": `agent402-openclaw/${PKG_VERSION}` };
|
|
91
109
|
if (key) headers.authorization = `Bearer ${key}`;
|
|
92
110
|
const up = await paid(`${upstream}${route.endpoint}`, { method: "POST", headers, body: JSON.stringify(outbound), signal: AbortSignal.timeout(300_000) });
|
|
93
111
|
stats.forwarded++;
|