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.
Files changed (4) hide show
  1. package/README.md +9 -4
  2. package/cli.js +22 -5
  3. package/package.json +1 -1
  4. 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 a402_... # buy a pack by card at https://agent402.tools/credits
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. Every forwarded call carries an
42
- `Idempotency-Key`, so retries replay the paid answer instead of paying twice.
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
- const key = (opt("--credits-key") || "").trim();
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
- const { renameSync } = await import("node:fs"); renameSync(tmp, cfgPath);
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
- if (import.meta.url === `file://${process.argv[1]}`) {
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.0",
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
- // Retries are safe: every forwarded request carries a fresh Idempotency-Key,
18
- // so a client retry of the same request replays the paid response instead of
19
- // paying twice. Streams pass through byte for byte.
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 headers = { "content-type": "application/json", accept: req.headers.accept || "application/json", "idempotency-key": randomUUID(), "user-agent": "agent402-openclaw/0.1.0" };
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++;