@jaw.id/cli 0.1.26 → 0.2.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.
Files changed (72) hide show
  1. package/dist/base-command.js +3 -1
  2. package/dist/base-command.js.map +1 -1
  3. package/dist/commands/config/set.js +140 -12
  4. package/dist/commands/config/set.js.map +1 -1
  5. package/dist/commands/config/show.js +3 -1
  6. package/dist/commands/config/show.js.map +1 -1
  7. package/dist/commands/config/write.js +6 -4
  8. package/dist/commands/config/write.js.map +1 -1
  9. package/dist/commands/disconnect.js +24 -10
  10. package/dist/commands/disconnect.js.map +1 -1
  11. package/dist/commands/mcp/index.js +2426 -94
  12. package/dist/commands/mcp/index.js.map +1 -1
  13. package/dist/commands/rpc/call.js +197 -45
  14. package/dist/commands/rpc/call.js.map +1 -1
  15. package/dist/commands/session/add.js +1547 -0
  16. package/dist/commands/session/add.js.map +1 -0
  17. package/dist/commands/session/revoke.js +181 -54
  18. package/dist/commands/session/revoke.js.map +1 -1
  19. package/dist/commands/session/setup.js +516 -65
  20. package/dist/commands/session/setup.js.map +1 -1
  21. package/dist/commands/session/status.js +315 -6
  22. package/dist/commands/session/status.js.map +1 -1
  23. package/dist/commands/version.js +3 -1
  24. package/dist/commands/version.js.map +1 -1
  25. package/dist/commands/x402/log.js +344 -0
  26. package/dist/commands/x402/log.js.map +1 -0
  27. package/dist/commands/x402/pay.js +2122 -0
  28. package/dist/commands/x402/pay.js.map +1 -0
  29. package/dist/commands/x402/status.js +1047 -0
  30. package/dist/commands/x402/status.js.map +1 -0
  31. package/dist/index.js +41 -14
  32. package/dist/index.js.map +1 -1
  33. package/dist/lib/bridge-singleton.js +41 -14
  34. package/dist/lib/bridge-singleton.js.map +1 -1
  35. package/dist/lib/config.js +26 -3
  36. package/dist/lib/config.js.map +1 -1
  37. package/dist/lib/keystore.js +13 -2
  38. package/dist/lib/keystore.js.map +1 -1
  39. package/dist/lib/paths.js +3 -1
  40. package/dist/lib/paths.js.map +1 -1
  41. package/dist/lib/payment-lock.js +121 -0
  42. package/dist/lib/payment-lock.js.map +1 -0
  43. package/dist/lib/session-bridge.js +148 -24
  44. package/dist/lib/session-bridge.js.map +1 -1
  45. package/dist/lib/session-config.js +78 -11
  46. package/dist/lib/session-config.js.map +1 -1
  47. package/dist/lib/terminal.js +22 -0
  48. package/dist/lib/terminal.js.map +1 -0
  49. package/dist/lib/validation.js +3 -3
  50. package/dist/lib/validation.js.map +1 -1
  51. package/dist/lib/ws-bridge.js +22 -10
  52. package/dist/lib/ws-bridge.js.map +1 -1
  53. package/dist/mcp/handlers/config.js +73 -6
  54. package/dist/mcp/handlers/config.js.map +1 -1
  55. package/dist/mcp/handlers/daemon.js +43 -12
  56. package/dist/mcp/handlers/daemon.js.map +1 -1
  57. package/dist/mcp/handlers/resources.js +119 -0
  58. package/dist/mcp/handlers/resources.js.map +1 -1
  59. package/dist/mcp/handlers/rpc.js +269 -60
  60. package/dist/mcp/handlers/rpc.js.map +1 -1
  61. package/dist/mcp/helpers.js +50 -3
  62. package/dist/mcp/helpers.js.map +1 -1
  63. package/dist/mcp/server.js +2426 -94
  64. package/dist/mcp/server.js.map +1 -1
  65. package/dist/mcp/tools.js +43 -3
  66. package/dist/mcp/tools.js.map +1 -1
  67. package/dist/x402/log-view.js +160 -0
  68. package/dist/x402/log-view.js.map +1 -0
  69. package/dist/x402/status-report.js +90 -0
  70. package/dist/x402/status-report.js.map +1 -0
  71. package/oclif.manifest.json +405 -11
  72. package/package.json +5 -2
@@ -4,6 +4,7 @@ import * as path from 'path';
4
4
  import * as os from 'os';
5
5
  import * as crypto from 'crypto';
6
6
  import WebSocket from 'ws';
7
+ import { encodeFunctionData, maxUint256, erc20Abi } from 'viem';
7
8
 
8
9
  // src/commands/rpc/call.ts
9
10
  var JAW_DIR = path.join(os.homedir(), ".jaw");
@@ -13,7 +14,9 @@ var PATHS = {
13
14
  session: path.join(JAW_DIR, "session.json"),
14
15
  relay: path.join(JAW_DIR, "relay.json"),
15
16
  keystore: path.join(JAW_DIR, "keystore.json"),
16
- sessionConfig: path.join(JAW_DIR, "session-config.json")
17
+ sessionConfig: path.join(JAW_DIR, "session-config.json"),
18
+ x402Log: path.join(JAW_DIR, "x402-log.jsonl"),
19
+ paymentLock: path.join(JAW_DIR, "x402-payment.lock")
17
20
  };
18
21
 
19
22
  // src/lib/validation.ts
@@ -223,7 +226,18 @@ function bufferToBase64(buf) {
223
226
  }
224
227
 
225
228
  // src/lib/ws-bridge.ts
229
+ function buildInitPayload(config) {
230
+ return {
231
+ type: "init",
232
+ apiKey: config.apiKey,
233
+ chainId: config.chainId,
234
+ ens: config.ens,
235
+ paymasterUrl: config.paymasterUrl,
236
+ ...config.paymasterUrl && config.paymasterContext ? { paymasterContext: config.paymasterContext } : {}
237
+ };
238
+ }
226
239
  var DEFAULT_TIMEOUT_MS = 12e4;
240
+ var DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
227
241
  var MAX_MESSAGE_BYTES = 5 * 1024 * 1024;
228
242
  var BROWSER_REOPEN_COOLDOWN_MS = 5e3;
229
243
  var MAX_RECONNECT_ATTEMPTS = 3;
@@ -232,6 +246,7 @@ var WSBridge = class {
232
246
  relayUrl;
233
247
  session;
234
248
  timeout;
249
+ connectTimeout;
235
250
  config;
236
251
  privateKeyHex;
237
252
  publicKeyHex;
@@ -253,6 +268,7 @@ var WSBridge = class {
253
268
  this.relayUrl = options.relayUrl;
254
269
  this.session = options.session;
255
270
  this.timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
271
+ this.connectTimeout = options.connectTimeout ?? DEFAULT_CONNECT_TIMEOUT_MS;
256
272
  this.config = options.config;
257
273
  this.privateKeyHex = options.privateKeyHex;
258
274
  this.publicKeyHex = options.publicKeyHex;
@@ -281,17 +297,16 @@ var WSBridge = class {
281
297
  let expectingKeyExchange = !this.peerPublicKeyHex;
282
298
  const timer = setTimeout(() => {
283
299
  ws.close();
284
- reject(new Error("Browser did not connect in time.\nRun `jaw disconnect` then try again."));
285
- }, 3e4);
300
+ reject(
301
+ new Error(
302
+ `Browser did not connect within ${Math.round(this.connectTimeout / 1e3)}s.
303
+ Run \`jaw disconnect\` then try again, or raise JAW_BRIDGE_TIMEOUT_MS.`
304
+ )
305
+ );
306
+ }, this.connectTimeout);
286
307
  const sendEncryptedInit = async () => {
287
308
  if (!this.sharedSecret) return;
288
- const envelope = await encryptMessage(this.sharedSecret, {
289
- type: "init",
290
- apiKey: this.config.apiKey,
291
- chainId: this.config.chainId,
292
- ens: this.config.ens,
293
- paymasterUrl: this.config.paymasterUrl
294
- });
309
+ const envelope = await encryptMessage(this.sharedSecret, buildInitPayload(this.config));
295
310
  this.sendRaw(ws, JSON.stringify({ type: "encrypted", ...envelope }));
296
311
  };
297
312
  const waitForReady = () => {
@@ -569,6 +584,10 @@ var DEFAULT_KEYS_URL = "https://keys.jaw.id";
569
584
  var DEFAULT_RELAY_URL = "wss://relay.jaw.id";
570
585
  async function getBridge(options) {
571
586
  const config = loadConfig();
587
+ const envTimeout = Number(process.env["JAW_BRIDGE_TIMEOUT_MS"]);
588
+ const fromEnv = Number.isFinite(envTimeout) && envTimeout > 0 ? envTimeout : void 0;
589
+ const timeout = options.timeout ?? fromEnv;
590
+ const connectTimeout = options.connectTimeout ?? fromEnv;
572
591
  const keysUrl = options.keysUrl ?? config.keysUrl ?? DEFAULT_KEYS_URL;
573
592
  const relayUrl = options.relayUrl ?? config.relayUrl ?? DEFAULT_RELAY_URL;
574
593
  const chainId = options.chainId ?? config.defaultChain ?? 1;
@@ -581,7 +600,7 @@ async function getBridge(options) {
581
600
  let relaySession = loadRelaySession();
582
601
  if (relaySession && relaySession.relayUrl === relayUrl && relaySession.peerPublicKey) {
583
602
  try {
584
- return await connectBridge(relaySession, options, chainId, keysUrl, relayUrl, false);
603
+ return await connectBridge({ ...options, timeout }, relaySession, chainId, keysUrl, relayUrl, false);
585
604
  } catch {
586
605
  deleteRelaySession();
587
606
  relaySession = null;
@@ -591,7 +610,7 @@ async function getBridge(options) {
591
610
  }
592
611
  const session = await createNewSession(relayUrl);
593
612
  saveRelaySession(session);
594
- return await connectBridge(session, options, chainId, keysUrl, relayUrl, true);
613
+ return await connectBridge({ ...options, timeout, connectTimeout }, session, chainId, keysUrl, relayUrl, true);
595
614
  }
596
615
  async function createNewSession(relayUrl) {
597
616
  const kp = await generateKeyPair();
@@ -606,17 +625,20 @@ async function createNewSession(relayUrl) {
606
625
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
607
626
  };
608
627
  }
609
- async function connectBridge(relaySession, options, chainId, keysUrl, relayUrl, openBrowser) {
628
+ async function connectBridge(options, relaySession, chainId, keysUrl, relayUrl, openBrowser) {
610
629
  const config = loadConfig();
630
+ const paymaster = config.paymasters?.[chainId];
611
631
  const bridge = new WSBridge({
612
632
  relayUrl,
613
633
  session: relaySession.session,
614
634
  timeout: options.timeout,
635
+ connectTimeout: options.connectTimeout,
615
636
  config: {
616
637
  apiKey: options.apiKey,
617
638
  chainId,
618
639
  ens: options.ens ?? config.ens,
619
- paymasterUrl: options.paymasterUrl ?? config.paymasters?.[chainId]?.url
640
+ paymasterUrl: paymaster?.url,
641
+ paymasterContext: paymaster?.context
620
642
  },
621
643
  privateKeyHex: relaySession.privateKey,
622
644
  publicKeyHex: relaySession.publicKey,
@@ -626,6 +648,12 @@ async function connectBridge(relaySession, options, chainId, keysUrl, relayUrl,
626
648
  // onBrowserNeeded — only open a browser for new sessions
627
649
  openBrowser ? async () => {
628
650
  const bridgeUrl = buildBridgeUrl(keysUrl, relaySession.session, relayUrl, relaySession.publicKey);
651
+ if (process.env["JAW_NO_BROWSER"]) {
652
+ process.stderr.write(`Open this URL to approve:
653
+ ${bridgeUrl}
654
+ `);
655
+ return;
656
+ }
629
657
  const { default: open } = await import('open');
630
658
  await open(bridgeUrl);
631
659
  } : void 0,
@@ -668,9 +696,7 @@ var SESSION_SUPPORTED_METHODS = /* @__PURE__ */ new Set([
668
696
  "eth_requestAccounts",
669
697
  "eth_accounts",
670
698
  "wallet_sendCalls",
671
- "wallet_getCallsStatus",
672
- "personal_sign",
673
- "eth_signTypedData_v4"
699
+ "wallet_getCallsStatus"
674
700
  ]);
675
701
  function supportsSessionMode(method) {
676
702
  return SESSION_SUPPORTED_METHODS.has(method);
@@ -688,6 +714,9 @@ function loadSessionKey() {
688
714
  }
689
715
  return parsed.privateKey;
690
716
  }
717
+ function isLegacySession(config) {
718
+ return config.mode !== "eip7702";
719
+ }
691
720
  function loadSessionConfig() {
692
721
  if (!fs.existsSync(PATHS.sessionConfig)) {
693
722
  throw new Error("No session configured. Run `jaw session setup` first.");
@@ -700,20 +729,86 @@ function loadSessionConfig() {
700
729
  }
701
730
  }
702
731
 
732
+ // src/x402/asset-registry.ts
733
+ var USDC_BY_NETWORK = {
734
+ "eip155:8453": {
735
+ address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
736
+ chainId: 8453,
737
+ wireNetwork: "eip155:8453",
738
+ usdcName: "USD Coin",
739
+ usdcVersion: "2",
740
+ decimals: 6
741
+ },
742
+ "eip155:84532": {
743
+ address: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
744
+ chainId: 84532,
745
+ wireNetwork: "eip155:84532",
746
+ usdcName: "USDC",
747
+ usdcVersion: "2",
748
+ decimals: 6
749
+ },
750
+ "eip155:137": {
751
+ address: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
752
+ chainId: 137,
753
+ wireNetwork: "eip155:137",
754
+ usdcName: "USD Coin",
755
+ usdcVersion: "2",
756
+ decimals: 6
757
+ },
758
+ "eip155:80002": {
759
+ address: "0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582",
760
+ chainId: 80002,
761
+ wireNetwork: "eip155:80002",
762
+ usdcName: "USDC",
763
+ usdcVersion: "2",
764
+ decimals: 6
765
+ }
766
+ };
767
+ function usdcForNetwork(network) {
768
+ return Object.hasOwn(USDC_BY_NETWORK, network) ? USDC_BY_NETWORK[network] : void 0;
769
+ }
770
+
771
+ // src/x402/permit2.ts
772
+ var PERMIT2_ADDRESS = "0x000000000022D473030F116dDEE9F6B43aC78BA3";
773
+
703
774
  // src/lib/session-bridge.ts
775
+ var JAW_ERC20_PAYMASTER_URL = "https://api.justaname.id/proxy/v1/rpc/erc20-paymaster";
776
+ function resolvePaymaster(options) {
777
+ if (options.paymasterUrl) {
778
+ return { paymasterUrl: options.paymasterUrl, paymasterContext: options.paymasterContext };
779
+ }
780
+ const configured = loadConfig().paymasters?.[options.chainId];
781
+ if (configured) {
782
+ return { paymasterUrl: configured.url, paymasterContext: configured.context };
783
+ }
784
+ if (!options.apiKey) return {};
785
+ const asset = usdcForNetwork(`eip155:${options.chainId}`);
786
+ if (!asset) {
787
+ console.warn(
788
+ `[jaw] No USDC in the x402 asset registry for chain ${options.chainId}, so no ERC-20 paymaster can be engaged. Gas will come out of the account\u2019s native balance. Set \`paymasters\` in your config to sponsor this chain.`
789
+ );
790
+ return {};
791
+ }
792
+ const url = new URL(JAW_ERC20_PAYMASTER_URL);
793
+ url.searchParams.set("chainId", String(options.chainId));
794
+ url.searchParams.set("api-key", options.apiKey);
795
+ return { paymasterUrl: url.toString(), paymasterContext: { token: asset.address } };
796
+ }
797
+ function explainUnchargeableSender(err, sessionAddress) {
798
+ const message = err instanceof Error ? err.message : String(err);
799
+ if (!message.includes("Could not size the ERC-20 paymaster approval")) return err;
800
+ return new Error(
801
+ `${message}
802
+
803
+ If ${sessionAddress} holds no USDC, that is why: it pays for its own gas and cannot be charged with an empty balance. Send it 0.1 USDC, or run \`jaw session setup\` again.`,
804
+ { cause: err }
805
+ );
806
+ }
704
807
  var SessionBridge = class {
705
808
  options;
706
809
  session = null;
707
810
  constructor(options) {
708
- this.options = { ...options };
709
- if (!this.options.paymasterUrl) {
710
- const config = loadConfig();
711
- const pm = config.paymasters?.[this.options.chainId];
712
- if (pm) {
713
- this.options.paymasterUrl = pm.url;
714
- this.options.paymasterContext = pm.context;
715
- }
716
- }
811
+ this.options = { ...options, ...resolvePaymaster(options) };
717
812
  }
718
813
  async getSession() {
719
814
  if (this.session) {
@@ -722,6 +817,11 @@ var SessionBridge = class {
722
817
  }
723
818
  const config = loadSessionConfig();
724
819
  this.checkExpiry(config);
820
+ if (isLegacySession(config)) {
821
+ throw new Error(
822
+ "This session was created by an older CLI and uses a session address separate from the session key. Run `jaw session setup` to recreate it, which offers to revoke the old permission first. `jaw session status` still shows the old session, and `jaw session revoke` still revokes it."
823
+ );
824
+ }
725
825
  if (config.chainId !== this.options.chainId) {
726
826
  throw new Error(
727
827
  `Session was created for chain ${config.chainId}, but --chain ${this.options.chainId} was requested. Run \`jaw session setup --chain ${this.options.chainId}\` to create a session for that chain.`
@@ -739,8 +839,14 @@ var SessionBridge = class {
739
839
  paymasterUrl: this.options.paymasterUrl,
740
840
  paymasterContext: this.options.paymasterContext
741
841
  },
742
- localAccount
842
+ localAccount,
843
+ { eip7702: true }
743
844
  );
845
+ if (account.address.toLowerCase() !== config.sessionAddress.toLowerCase()) {
846
+ throw new Error(
847
+ `Session key derives ${account.address}, but the stored session address is ${config.sessionAddress}. The keystore and session config are out of sync. Run \`jaw session setup\` to recreate the session.`
848
+ );
849
+ }
744
850
  this.session = { account, config };
745
851
  return this.session;
746
852
  }
@@ -750,6 +856,46 @@ var SessionBridge = class {
750
856
  throw new Error(`Session expired on ${expiryDate}. Run \`jaw session setup\` to create a new session.`);
751
857
  }
752
858
  }
859
+ /**
860
+ * Approve Permit2 to move one of the payer's tokens, and return the batch id.
861
+ *
862
+ * The only call this session sends outside its permission, and the only one
863
+ * that can be: `JustaPermissionManager` checks every call's selector against
864
+ * the grant, and the x402 grant permits `transfer` alone, so an approval
865
+ * routed through the permission reverts before anything else happens. Sent by
866
+ * the session on its own balance it never reaches the manager at all, whose
867
+ * approval revocation and Permit2 lockdown act on the granting account and
868
+ * only within their own execution.
869
+ *
870
+ * Being outside the permission is exactly why it is not a general send. It
871
+ * takes a token and nothing else: the spender is Permit2 and the amount is
872
+ * the maximum, neither reachable by a caller, and the token has to be the
873
+ * registry's USDC for this session's chain. There is no shape of argument
874
+ * that turns this into an arbitrary transfer, which matters because an agent
875
+ * reaches the tools that reach this.
876
+ */
877
+ async approvePermit2(token) {
878
+ const { account, config } = await this.getSession();
879
+ const usdc = usdcForNetwork(`eip155:${config.chainId}`);
880
+ if (!usdc || token.toLowerCase() !== usdc.address.toLowerCase()) {
881
+ throw new Error(
882
+ `Refusing to approve Permit2 for ${token}: only the registry USDC on chain ${config.chainId} is allowed.`
883
+ );
884
+ }
885
+ const data = encodeFunctionData({
886
+ abi: erc20Abi,
887
+ functionName: "approve",
888
+ args: [PERMIT2_ADDRESS, maxUint256]
889
+ });
890
+ try {
891
+ const sent = await account.sendCalls([{ to: usdc.address, data }]);
892
+ const id = typeof sent === "string" ? sent : sent?.id;
893
+ if (!id) throw new Error("approval submitted but no call id was returned");
894
+ return id;
895
+ } catch (err) {
896
+ throw explainUnchargeableSender(err, config.sessionAddress);
897
+ }
898
+ }
753
899
  async request(method, params) {
754
900
  const { account, config } = await this.getSession();
755
901
  switch (method) {
@@ -759,24 +905,25 @@ var SessionBridge = class {
759
905
  case "wallet_sendCalls": {
760
906
  const payload = Array.isArray(params) ? params[0] : params;
761
907
  const { calls } = payload;
762
- return account.sendCalls(calls, {
763
- permissionId: config.permissionId
764
- });
908
+ const sendOptions = { permissionId: config.permissionId };
909
+ try {
910
+ return await account.sendCalls(calls, sendOptions);
911
+ } catch (err) {
912
+ throw explainUnchargeableSender(err, config.sessionAddress);
913
+ }
765
914
  }
766
915
  case "wallet_getCallsStatus": {
767
916
  const batchId = Array.isArray(params) ? params[0] : params;
768
917
  return account.getCallStatus(batchId);
769
918
  }
770
- case "personal_sign": {
771
- const message = Array.isArray(params) ? params[0] : params;
772
- return account.signMessage(message);
773
- }
774
- case "eth_signTypedData_v4": {
775
- const asArray = Array.isArray(params) ? params : [params];
776
- const raw = asArray.length > 1 ? asArray[1] : asArray[0];
777
- const typedData = typeof raw === "string" ? JSON.parse(raw) : raw;
778
- return account.signTypedData(typedData);
779
- }
919
+ // Refused rather than absent, so the reason is on screen instead of a
920
+ // caller reading "not supported in auto mode" and looking for a flag. See
921
+ // `supportsSessionMode` in rpc-classifier.ts for why.
922
+ case "personal_sign":
923
+ case "eth_signTypedData_v4":
924
+ throw new Error(
925
+ `${method} is not available in auto mode: a signature the session makes is not a call, so it never reaches the spend caps or the ledger. Run it through the browser instead.`
926
+ );
780
927
  case "wallet_grantPermissions":
781
928
  throw new Error("Requires browser \u2014 run `jaw session setup`.");
782
929
  case "wallet_revokePermissions":
@@ -814,8 +961,13 @@ var RpcCall = class _RpcCall extends BaseCommand {
814
961
  ...BaseCommand.baseFlags,
815
962
  timeout: Flags.integer({
816
963
  char: "t",
817
- description: "Request timeout in seconds",
818
- default: 120
964
+ // No default. A default is indistinguishable from an explicit value, and
965
+ // this one was passed to getBridge on every run, which made
966
+ // JAW_BRIDGE_TIMEOUT_MS dead here: the env only applies when no caller
967
+ // named a timeout. The error text tells people to raise that knob, so it
968
+ // has to work in the command most likely to hit it. Unset falls through
969
+ // to the env, then to the built-in 120 seconds.
970
+ description: "Request timeout in seconds (default 120, or JAW_BRIDGE_TIMEOUT_MS)"
819
971
  }),
820
972
  session: Flags.boolean({
821
973
  char: "s",
@@ -851,14 +1003,14 @@ var RpcCall = class _RpcCall extends BaseCommand {
851
1003
  this.log(`Sending ${method} (session mode)...`);
852
1004
  }
853
1005
  } else {
854
- const pm = config.paymasters?.[chainId];
855
1006
  bridge = await getBridge({
856
1007
  keysUrl: config.keysUrl,
857
1008
  apiKey,
858
1009
  chainId,
859
1010
  ens: config.ens,
860
- paymasterUrl: pm?.url,
861
- timeout: flags.timeout * 1e3
1011
+ // Undefined when the flag was not given, so getBridge falls through to
1012
+ // the env knob and then to its own default.
1013
+ timeout: flags.timeout === void 0 ? void 0 : flags.timeout * 1e3
862
1014
  });
863
1015
  if (!flags.quiet) {
864
1016
  if (requiresBrowser(method)) {