@tokenbuddy/tokenbuddy 1.0.6 → 1.0.7

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 (43) hide show
  1. package/dist/src/buyer-store.d.ts +28 -1
  2. package/dist/src/buyer-store.d.ts.map +1 -1
  3. package/dist/src/buyer-store.js +71 -16
  4. package/dist/src/buyer-store.js.map +1 -1
  5. package/dist/src/cli.d.ts +17 -0
  6. package/dist/src/cli.d.ts.map +1 -1
  7. package/dist/src/cli.js +201 -32
  8. package/dist/src/cli.js.map +1 -1
  9. package/dist/src/daemon.d.ts +5 -0
  10. package/dist/src/daemon.d.ts.map +1 -1
  11. package/dist/src/daemon.js +279 -72
  12. package/dist/src/daemon.js.map +1 -1
  13. package/dist/src/doctor-clawtip-wallet.d.ts +14 -0
  14. package/dist/src/doctor-clawtip-wallet.d.ts.map +1 -0
  15. package/dist/src/doctor-clawtip-wallet.js +54 -0
  16. package/dist/src/doctor-clawtip-wallet.js.map +1 -0
  17. package/dist/src/doctor-diagnostics.d.ts +2 -0
  18. package/dist/src/doctor-diagnostics.d.ts.map +1 -1
  19. package/dist/src/doctor-diagnostics.js +5 -0
  20. package/dist/src/doctor-diagnostics.js.map +1 -1
  21. package/dist/src/init-clawtip-activation.d.ts +48 -0
  22. package/dist/src/init-clawtip-activation.d.ts.map +1 -0
  23. package/dist/src/init-clawtip-activation.js +395 -0
  24. package/dist/src/init-clawtip-activation.js.map +1 -0
  25. package/dist/src/init-payment-options.d.ts +23 -1
  26. package/dist/src/init-payment-options.d.ts.map +1 -1
  27. package/dist/src/init-payment-options.js +97 -22
  28. package/dist/src/init-payment-options.js.map +1 -1
  29. package/dist/src/terminal-image.d.ts +22 -0
  30. package/dist/src/terminal-image.d.ts.map +1 -0
  31. package/dist/src/terminal-image.js +135 -0
  32. package/dist/src/terminal-image.js.map +1 -0
  33. package/package.json +1 -1
  34. package/src/buyer-store.ts +140 -17
  35. package/src/cli.ts +251 -33
  36. package/src/daemon.ts +308 -53
  37. package/src/doctor-clawtip-wallet.ts +70 -0
  38. package/src/doctor-diagnostics.ts +11 -0
  39. package/src/init-clawtip-activation.ts +487 -0
  40. package/src/init-payment-options.ts +140 -22
  41. package/src/terminal-image.ts +187 -0
  42. package/tests/e2e.test.ts +79 -5
  43. package/tests/tokenbuddy.test.ts +745 -19
@@ -1,6 +1,19 @@
1
1
  import { TokenbuddyDaemon } from "../src/daemon.js";
2
- import { BuyerStore, resolveBuyerStorePath } from "../src/buyer-store.js";
3
- import { buildCli } from "../src/cli.js";
2
+ import { BuyerStore, resolveBuyerStorePath, type PaymentConfig } from "../src/buyer-store.js";
3
+ import {
4
+ buildCli,
5
+ fetchClawtipBootstrap,
6
+ normalizeClawtipBootstrapResourceUrl,
7
+ } from "../src/cli.js";
8
+ import {
9
+ checkOpenClawRuntime,
10
+ parseClawtipCliOutput,
11
+ readClawtipPayCredential,
12
+ resolveClawtipQrMediaPath,
13
+ startClawtipWalletBootstrap,
14
+ waitForClawtipActivationConfirmation,
15
+ writeClawtipOrderFile,
16
+ } from "../src/init-clawtip-activation.js";
4
17
  import {
5
18
  applyProviderInstall,
6
19
  detectProviders,
@@ -10,14 +23,21 @@ import {
10
23
  import { detectTerminals } from "../src/terminal-detect.js";
11
24
  import {
12
25
  buildInitSuccessMessage,
13
- buildInitTerminalOptions,
26
+ buildInitTerminalSelectionState,
14
27
  detectExistingClawtipBinding,
28
+ inspectClawtipWalletReadiness,
29
+ inspectOpenClawWalletConfig,
15
30
  INIT_COMING_SOON_PAYMENT_OPTIONS,
16
31
  INIT_PAYMENT_OPTIONS,
17
32
  noteInitComingSoonPayments,
18
33
  OTHER_TERMINAL_OPTION,
19
34
  validateInitTerminalSelection,
20
35
  } from "../src/init-payment-options.js";
36
+ import { printDoctorClawtipWallet } from "../src/doctor-clawtip-wallet.js";
37
+ import {
38
+ detectTerminalImageDisplay,
39
+ displayTerminalImage,
40
+ } from "../src/terminal-image.js";
21
41
  import * as path from "path";
22
42
  import * as fs from "fs";
23
43
  import http from "http";
@@ -26,6 +46,8 @@ import zlib from "zlib";
26
46
 
27
47
  const TEMP_BUYER_DB = path.resolve(__dirname, "../../data-test/buyer-cache-test.db");
28
48
  const TEMP_STORE_ROOT = path.resolve(__dirname, "../../data-test/buyer-store-test");
49
+ const INSPECTION_STORE_ROOT = path.resolve(__dirname, "../../data-test/json-inspection-store");
50
+ const INSPECTION_HOME = path.resolve(__dirname, "../../data-test/json-inspection-home");
29
51
  const PACKAGE_JSON = path.resolve(__dirname, "../package.json");
30
52
 
31
53
  function rmSqliteFiles(dbPath: string): void {
@@ -119,15 +141,27 @@ describe("BuyerStore safe SQLite persistence", () => {
119
141
  expect(store.getToken("seller-a")).toBeUndefined();
120
142
 
121
143
  store.saveToken("seller-a", "raw-token-secret", "model:gpt-4", 500000, "2030-01-01T00:00:00.000Z");
122
- expect(store.getToken("seller-a")).toEqual({
144
+ expect(store.getToken("seller-a")).toMatchObject({
123
145
  token: "raw-token-secret",
124
- balanceMicros: 500000
146
+ balanceMicros: 500000,
147
+ reservedMicros: 0,
148
+ spentMicros: 0,
149
+ balanceSource: "purchase_complete"
125
150
  });
126
151
 
127
- store.deductBalance("seller-a", 120000);
128
- expect(store.getToken("seller-a")).toEqual({
152
+ store.reconcileTokenBalance({
153
+ sellerKey: "seller-a",
154
+ balanceMicros: 499890,
155
+ reservedMicros: 0,
156
+ spentMicros: 110,
157
+ balanceSource: "seller_settlement_summary"
158
+ });
159
+ expect(store.getToken("seller-a")).toMatchObject({
129
160
  token: "raw-token-secret",
130
- balanceMicros: 380000
161
+ balanceMicros: 499890,
162
+ reservedMicros: 0,
163
+ spentMicros: 110,
164
+ balanceSource: "seller_settlement_summary"
131
165
  });
132
166
  });
133
167
 
@@ -471,6 +505,55 @@ describe("TokenBuddy init payment options", () => {
471
505
  }));
472
506
  });
473
507
 
508
+ test("requires the OpenClaw wallet config before reusing ClawTip metadata", () => {
509
+ const payment: PaymentConfig = {
510
+ method: "clawtip",
511
+ enabled: true,
512
+ isDefault: true,
513
+ updatedAt: "2026-05-30T00:00:00.000Z",
514
+ config: {
515
+ orderNo: "order_123",
516
+ resourceUrl: "https://example.test/pay"
517
+ }
518
+ };
519
+
520
+ const missingWallet = inspectClawtipWalletReadiness(payment, {
521
+ expectedPath: "/tmp/home/.openclaw/configs/config.json",
522
+ configsDirExists: true,
523
+ exists: false,
524
+ alternatePaths: ["/tmp/home/.openclaw/configs/config.json.bak"]
525
+ });
526
+ expect(missingWallet.status).toBe("metadata_missing_wallet");
527
+ expect(missingWallet.savedBinding?.orderNo).toBe("order_123");
528
+ expect(missingWallet.reusableBinding).toBeUndefined();
529
+
530
+ const readyWallet = inspectClawtipWalletReadiness(payment, {
531
+ expectedPath: "/tmp/home/.openclaw/configs/config.json",
532
+ configsDirExists: true,
533
+ exists: true,
534
+ alternatePaths: []
535
+ });
536
+ expect(readyWallet.status).toBe("ready");
537
+ expect(readyWallet.reusableBinding?.orderNo).toBe("order_123");
538
+ });
539
+
540
+ test("detects renamed OpenClaw wallet config files as nearby files", () => {
541
+ const home = path.join(TEMP_STORE_ROOT, "openclaw-home");
542
+ const configsDir = path.join(home, ".openclaw", "configs");
543
+ rmDir(home);
544
+ fs.mkdirSync(configsDir, { recursive: true });
545
+ fs.writeFileSync(path.join(configsDir, "config.json.bak"), "{}", "utf8");
546
+
547
+ const wallet = inspectOpenClawWalletConfig(home);
548
+
549
+ expect(wallet.exists).toBe(false);
550
+ expect(wallet.configsDirExists).toBe(true);
551
+ expect(wallet.expectedPath).toBe(path.join(configsDir, "config.json"));
552
+ expect(wallet.alternatePaths).toEqual([
553
+ path.join(configsDir, "config.json.bak")
554
+ ]);
555
+ });
556
+
474
557
  test("builds a clear init success message with summary lines", () => {
475
558
  const message = buildInitSuccessMessage([
476
559
  "2 programming terminals configured for TokenBuddy.",
@@ -483,8 +566,8 @@ describe("TokenBuddy init payment options", () => {
483
566
  expect(message).toContain("Run `tb doctor` to audit status anytime.");
484
567
  });
485
568
 
486
- test("builds terminal options with configured entries marked as already installed", () => {
487
- const options = buildInitTerminalOptions([
569
+ test("builds terminal selection with configured entries separated from selectable options", () => {
570
+ const selection = buildInitTerminalSelectionState([
488
571
  {
489
572
  id: "codex",
490
573
  name: "Codex CLI",
@@ -505,11 +588,13 @@ describe("TokenBuddy init payment options", () => {
505
588
  }
506
589
  ]);
507
590
 
508
- expect(options).toEqual([
591
+ expect(selection.installed).toEqual([
509
592
  expect.objectContaining({
510
- value: "codex:installed",
593
+ value: "codex",
511
594
  label: "Codex CLI(已安装)"
512
- }),
595
+ })
596
+ ]);
597
+ expect(selection.options).toEqual([
513
598
  expect.objectContaining({
514
599
  value: "hermes",
515
600
  label: "Hermes Terminal"
@@ -521,14 +606,467 @@ describe("TokenBuddy init payment options", () => {
521
606
  ]);
522
607
  });
523
608
 
524
- test("requires at least one actionable terminal choice", () => {
609
+ test("requires at least one terminal choice", () => {
525
610
  expect(validateInitTerminalSelection([])).toBe("Select at least one terminal or choose Other.");
526
- expect(validateInitTerminalSelection(["codex:installed"])).toBe(
527
- "Installed terminals are already configured. Select another terminal or choose Other."
528
- );
529
611
  expect(validateInitTerminalSelection(["other"])).toBeUndefined();
530
612
  expect(validateInitTerminalSelection(["hermes"])).toBeUndefined();
531
613
  });
614
+
615
+ test("waits for ClawTip scan confirmation until the wallet config appears", async () => {
616
+ let attempts = 0;
617
+ const inspectWalletConfig = jest.fn(() => {
618
+ attempts += 1;
619
+ return {
620
+ expectedPath: "/tmp/home/.openclaw/configs/config.json",
621
+ configsDirExists: true,
622
+ exists: attempts >= 3,
623
+ alternatePaths: []
624
+ };
625
+ });
626
+ const sleep = jest.fn(async () => undefined);
627
+
628
+ await expect(waitForClawtipActivationConfirmation({
629
+ inspectWalletConfig,
630
+ pollIntervalMs: 10,
631
+ sleep,
632
+ })).resolves.toBe(true);
633
+
634
+ expect(inspectWalletConfig).toHaveBeenCalledTimes(3);
635
+ expect(sleep).toHaveBeenCalledTimes(2);
636
+ });
637
+
638
+ test("cancels ClawTip scan confirmation on Ctrl+C", async () => {
639
+ const cancelled = jest.fn();
640
+ await expect(
641
+ waitForClawtipActivationConfirmation({
642
+ inspectWalletConfig: () => ({
643
+ expectedPath: "/tmp/home/.openclaw/configs/config.json",
644
+ configsDirExists: true,
645
+ exists: false,
646
+ alternatePaths: []
647
+ }),
648
+ isCancelled: () => true,
649
+ cancel: cancelled,
650
+ pollIntervalMs: 10,
651
+ sleep: async () => undefined,
652
+ })
653
+ ).resolves.toBe(false);
654
+
655
+ expect(cancelled).toHaveBeenCalledWith("ClawTip activation cancelled.");
656
+ });
657
+
658
+ test("parses ClawTip auth URL and clawtipId from pay CLI output", () => {
659
+ const parsed = parseClawtipCliOutput(
660
+ "请扫码 https://clawtip.jd.com/qrcode?foo=1&clawtipId=device-789 完成授权"
661
+ );
662
+
663
+ expect(parsed.requiresWalletAuth).toBe(true);
664
+ expect(parsed.authUrl).toBe("https://clawtip.jd.com/qrcode?foo=1&clawtipId=device-789");
665
+ expect(parsed.clawtipId).toBe("device-789");
666
+ });
667
+
668
+ test("ignores bootstrap env endpoint noise when parsing the real ClawTip auth URL", () => {
669
+ const parsed = parseClawtipCliOutput([
670
+ "process.env.CLAWTIP_PAY https://ms.jr.jd.com/gw2/generic/hyqy/h5/m/clawtipPay",
671
+ "请扫码 https://idt.jd.com/unifiedAuthM/plat/safeMonitor/?authFlowUid=abc 完成授权",
672
+ ].join("\n"));
673
+
674
+ expect(parsed.authUrl).toBe(
675
+ "https://idt.jd.com/unifiedAuthM/plat/safeMonitor/?authFlowUid=abc"
676
+ );
677
+ });
678
+
679
+ test("parses the official ClawTip wallet QR image path", () => {
680
+ const parsed = parseClawtipCliOutput([
681
+ "从 .env.prod 加载环境变量",
682
+ "/Users/test/.openclaw/workspace/clawtip/qrcode/clawtip-index-code.png",
683
+ ].join("\n"));
684
+
685
+ expect(parsed.mediaPath).toBe("/Users/test/.openclaw/workspace/clawtip/qrcode/clawtip-index-code.png");
686
+ expect(parsed.requiresWalletAuth).toBe(true);
687
+ });
688
+
689
+ test("surfaces ClawTip upstream payment errors directly", async () => {
690
+ const parsed = parseClawtipCliOutput("ClawTip 返回错误:商家信息有误");
691
+
692
+ expect(parsed.failureMessage).toContain("商家信息有误");
693
+ await expect(startClawtipWalletBootstrap({
694
+ orderNo: "order_error",
695
+ amountFen: 1,
696
+ payTo: "pay-to-test",
697
+ encryptedData: "ciphertext",
698
+ indicator: "indicator_error",
699
+ slug: "tb-wallet-bootstrap",
700
+ skillId: "si-tb-wallet-bootstrap",
701
+ description: "TokenBuddy ClawTip wallet activation",
702
+ resourceUrl: "https://tb-wallet-bootstrap.fly.dev"
703
+ }, {
704
+ home: path.join(TEMP_STORE_ROOT, "clawtip-error-home"),
705
+ runClawtipCommand: async () => "ClawTip 返回错误:商家信息有误",
706
+ })).rejects.toThrow("ClawTip pay failed: ClawTip 返回错误:商家信息有误");
707
+ });
708
+
709
+ test("uses only ClawTip CLI media paths for QR resolution", () => {
710
+ expect(resolveClawtipQrMediaPath({
711
+ authUrl: "https://clawtip.jd.com/qrcode?clawtipId=device-789",
712
+ clawtipId: "device-789",
713
+ mediaPath: "/tmp/clawtip/qrcode-1.png",
714
+ requiresWalletAuth: true,
715
+ walletReady: false,
716
+ }, "/tmp/orders/order-1.json")).toBe("/tmp/clawtip/qrcode-1.png");
717
+ });
718
+
719
+ test("fails ClawTip QR resolution when pay CLI emits no QR source", () => {
720
+ expect(() => resolveClawtipQrMediaPath({
721
+ authUrl: undefined,
722
+ clawtipId: undefined,
723
+ mediaPath: undefined,
724
+ requiresWalletAuth: false,
725
+ walletReady: false,
726
+ }, "/tmp/orders/order-1.json")).toThrow("ClawTip pay did not return a QR media file.");
727
+ });
728
+
729
+ test("checks OpenClaw before ClawTip wallet bootstrap", async () => {
730
+ const calls: string[][] = [];
731
+ const openClawVersion = await checkOpenClawRuntime({
732
+ runOpenClawCommand: async (args) => {
733
+ calls.push(["openclaw", ...args]);
734
+ return "OpenClaw 2026.5.18";
735
+ },
736
+ });
737
+
738
+ expect(openClawVersion).toBe("OpenClaw 2026.5.18");
739
+ expect(calls).toEqual([
740
+ ["openclaw", "--version"],
741
+ ]);
742
+ });
743
+
744
+ test("normalizes the bootstrap resource URL away from the public registry endpoint", () => {
745
+ expect(normalizeClawtipBootstrapResourceUrl(
746
+ "https://tb-wallet-bootstrap.fly.dev",
747
+ "https://tb-wallet-bootstrap.fly.dev/registry/sellers"
748
+ )).toBe("https://tb-wallet-bootstrap.fly.dev");
749
+
750
+ expect(normalizeClawtipBootstrapResourceUrl(
751
+ "https://tb-wallet-bootstrap.fly.dev/base",
752
+ "https://tb-wallet-bootstrap.fly.dev/registry/sellers"
753
+ )).toBe("https://tb-wallet-bootstrap.fly.dev/base");
754
+
755
+ expect(normalizeClawtipBootstrapResourceUrl(
756
+ "https://tb-wallet-bootstrap.fly.dev",
757
+ "https://example.test/pay"
758
+ )).toBe("https://example.test/pay");
759
+ });
760
+
761
+ test("rejects a bootstrap response that still uses the placeholder ClawTip payTo", async () => {
762
+ const originalFetch = global.fetch;
763
+ global.fetch = jest.fn(async () => new Response(JSON.stringify({
764
+ activationFeeFen: 1,
765
+ payment: {
766
+ orderNo: "order_placeholder",
767
+ indicator: "indicator_placeholder",
768
+ payTo: "bootstrap-pay-to",
769
+ resourceUrl: "https://tb-wallet-bootstrap.fly.dev/registry/sellers",
770
+ }
771
+ }), {
772
+ status: 200,
773
+ headers: { "Content-Type": "application/json" }
774
+ })) as typeof fetch;
775
+
776
+ try {
777
+ await expect(fetchClawtipBootstrap("https://tb-wallet-bootstrap.fly.dev")).rejects.toThrow(
778
+ "ClawTip bootstrap service is misconfigured: payTo is still the placeholder"
779
+ );
780
+ } finally {
781
+ global.fetch = originalFetch;
782
+ }
783
+ });
784
+
785
+ test("writes the Rust-compatible ClawTip order file shape", () => {
786
+ const home = path.join(TEMP_STORE_ROOT, "clawtip-order-home");
787
+ rmDir(home);
788
+
789
+ const orderFile = writeClawtipOrderFile({
790
+ orderNo: "order_123",
791
+ amountFen: 1,
792
+ payTo: "pay-to-test",
793
+ encryptedData: "ciphertext",
794
+ indicator: "indicator_123",
795
+ slug: "tb-wallet-bootstrap",
796
+ skillId: "si-tb-wallet-bootstrap",
797
+ description: "TokenBuddy ClawTip wallet activation",
798
+ resourceUrl: "https://tb-wallet-bootstrap.fly.dev"
799
+ }, home);
800
+
801
+ expect(orderFile).toBe(path.join(
802
+ home,
803
+ ".openclaw",
804
+ "skills",
805
+ "orders",
806
+ "indicator_123",
807
+ "order_123.json"
808
+ ));
809
+
810
+ const saved = JSON.parse(fs.readFileSync(orderFile, "utf8"));
811
+ expect(saved).toEqual(expect.objectContaining({
812
+ "skill-id": "si-tb-wallet-bootstrap",
813
+ order_no: "order_123",
814
+ amount: 1,
815
+ encrypted_data: "ciphertext",
816
+ pay_to: "pay-to-test",
817
+ slug: "tb-wallet-bootstrap",
818
+ resource_url: "https://tb-wallet-bootstrap.fly.dev"
819
+ }));
820
+ });
821
+
822
+ test("starts ClawTip payment activation and reads payCredential from the order file", async () => {
823
+ const home = path.join(TEMP_STORE_ROOT, "clawtip-bootstrap-home");
824
+ rmDir(home);
825
+
826
+ const activation = await startClawtipWalletBootstrap({
827
+ orderNo: "order_456",
828
+ amountFen: 1,
829
+ payTo: "pay-to-test",
830
+ encryptedData: "ciphertext",
831
+ indicator: "indicator_456",
832
+ slug: "tb-wallet-bootstrap",
833
+ skillId: "si-tb-wallet-bootstrap",
834
+ description: "TokenBuddy ClawTip wallet activation",
835
+ resourceUrl: "https://tb-wallet-bootstrap.fly.dev"
836
+ }, {
837
+ home,
838
+ runClawtipCommand: async () => {
839
+ const orderFile = path.join(
840
+ home,
841
+ ".openclaw",
842
+ "skills",
843
+ "orders",
844
+ "indicator_456",
845
+ "order_456.json"
846
+ );
847
+ const order = JSON.parse(fs.readFileSync(orderFile, "utf8"));
848
+ order.payCredential = "credential_456";
849
+ fs.writeFileSync(orderFile, JSON.stringify(order, null, 2), "utf8");
850
+ return "已获取到支付凭证";
851
+ }
852
+ });
853
+
854
+ expect(activation.orderFile).toBe(path.join(
855
+ home,
856
+ ".openclaw",
857
+ "skills",
858
+ "orders",
859
+ "indicator_456",
860
+ "order_456.json"
861
+ ));
862
+ expect(activation.payCredential).toBe("credential_456");
863
+ expect(readClawtipPayCredential(activation.orderFile)).toBe("credential_456");
864
+ });
865
+
866
+ test("recovers the latest generated ClawTip QR media path when pay output omits MEDIA", async () => {
867
+ const home = path.join(TEMP_STORE_ROOT, "clawtip-bootstrap-qr-home");
868
+ rmDir(home);
869
+
870
+ const activation = await startClawtipWalletBootstrap({
871
+ orderNo: "order_789",
872
+ amountFen: 1,
873
+ payTo: "pay-to-test",
874
+ encryptedData: "ciphertext",
875
+ indicator: "indicator_789",
876
+ slug: "tb-wallet-bootstrap",
877
+ skillId: "si-tb-wallet-bootstrap",
878
+ description: "TokenBuddy ClawTip wallet activation",
879
+ resourceUrl: "https://tb-wallet-bootstrap.fly.dev"
880
+ }, {
881
+ home,
882
+ runClawtipCommand: async () => {
883
+ const qrDir = path.join(home, ".openclaw", "workspace", "clawtip", "qrcode");
884
+ fs.mkdirSync(qrDir, { recursive: true });
885
+ const qrPath = path.join(qrDir, "qrcode-generated.png");
886
+ fs.writeFileSync(qrPath, "png", "utf8");
887
+ return [
888
+ "process.env.CLAWTIP_PAY https://ms.jr.jd.com/gw2/generic/hyqy/h5/m/clawtipPay",
889
+ "请扫码 https://idt.jd.com/unifiedAuthM/plat/safeMonitor/?authFlowUid=abc 完成授权",
890
+ ].join("\n");
891
+ }
892
+ });
893
+
894
+ expect(activation.parsedOutput.authUrl).toBe(
895
+ "https://idt.jd.com/unifiedAuthM/plat/safeMonitor/?authFlowUid=abc"
896
+ );
897
+ expect(activation.parsedOutput.mediaPath).toBe(
898
+ path.join(home, ".openclaw", "workspace", "clawtip", "qrcode", "qrcode-generated.png")
899
+ );
900
+ });
901
+
902
+ test("recovers ClawTip QR media even when pay output only writes payCredential", async () => {
903
+ const home = path.join(TEMP_STORE_ROOT, "clawtip-bootstrap-credential-and-qr-home");
904
+ rmDir(home);
905
+
906
+ const activation = await startClawtipWalletBootstrap({
907
+ orderNo: "order_credential_qr",
908
+ amountFen: 1,
909
+ payTo: "pay-to-test",
910
+ encryptedData: "ciphertext",
911
+ indicator: "indicator_credential_qr",
912
+ slug: "tb-wallet-bootstrap",
913
+ skillId: "si-tb-wallet-bootstrap",
914
+ description: "TokenBuddy ClawTip wallet activation",
915
+ resourceUrl: "https://tb-wallet-bootstrap.fly.dev"
916
+ }, {
917
+ home,
918
+ runClawtipCommand: async () => {
919
+ const orderFile = path.join(
920
+ home,
921
+ ".openclaw",
922
+ "skills",
923
+ "orders",
924
+ "indicator_credential_qr",
925
+ "order_credential_qr.json"
926
+ );
927
+ const order = JSON.parse(fs.readFileSync(orderFile, "utf8"));
928
+ order.payCredential = "credential_without_wallet_config";
929
+ fs.writeFileSync(orderFile, JSON.stringify(order, null, 2), "utf8");
930
+
931
+ const qrDir = path.join(home, ".openclaw", "workspace", "clawtip", "qrcode");
932
+ fs.mkdirSync(qrDir, { recursive: true });
933
+ const qrPath = path.join(qrDir, "qrcode-credential.png");
934
+ fs.writeFileSync(qrPath, "png", "utf8");
935
+ return "已获取到支付凭证";
936
+ }
937
+ });
938
+
939
+ expect(activation.payCredential).toBe("credential_without_wallet_config");
940
+ expect(activation.parsedOutput.requiresWalletAuth).toBe(true);
941
+ expect(activation.parsedOutput.mediaPath).toBe(
942
+ path.join(home, ".openclaw", "workspace", "clawtip", "qrcode", "qrcode-credential.png")
943
+ );
944
+ });
945
+
946
+ test("doctor prompts tb init when no ClawTip wallet is available", () => {
947
+ const lines: string[] = [];
948
+
949
+ printDoctorClawtipWallet({
950
+ status: "missing",
951
+ ready: false,
952
+ paymentMetadataPresent: false,
953
+ walletConfigPresent: false,
954
+ configsDirExists: false,
955
+ expectedPath: "/tmp/home/.openclaw/configs/config.json",
956
+ alternatePaths: [],
957
+ message: "ClawTip payment metadata and local OpenClaw wallet config are not configured.",
958
+ }, (line) => {
959
+ lines.push(line);
960
+ });
961
+
962
+ expect(lines.join("\n")).toContain(
963
+ "Action: Run `tb init` and choose ClawTip to bind a wallet before using ClawTip-backed purchases."
964
+ );
965
+ });
966
+ });
967
+
968
+ describe("TokenBuddy terminal image display", () => {
969
+ const imagePath = "/Users/test/.openclaw/workspace/clawtip/qrcode/clawtip-index-code.png";
970
+
971
+ test("detects inline image capable terminals only when stdout is a TTY", () => {
972
+ expect(detectTerminalImageDisplay({
973
+ env: { TERM_PROGRAM: "iTerm.app" },
974
+ stdoutIsTTY: true,
975
+ })).toBe("iterm");
976
+ expect(detectTerminalImageDisplay({
977
+ env: { WEZTERM_EXECUTABLE: "/Applications/WezTerm.app/wezterm" },
978
+ stdoutIsTTY: true,
979
+ })).toBe("iterm");
980
+ expect(detectTerminalImageDisplay({
981
+ env: { KITTY_WINDOW_ID: "1" },
982
+ stdoutIsTTY: true,
983
+ })).toBe("kitty");
984
+ expect(detectTerminalImageDisplay({
985
+ env: { TERM_PROGRAM: "iTerm.app" },
986
+ stdoutIsTTY: false,
987
+ })).toBeUndefined();
988
+ });
989
+
990
+ test("renders the ClawTip QR image inline for iTerm2-compatible terminals", async () => {
991
+ const writes: string[] = [];
992
+ const runCommand = jest.fn(async () => undefined);
993
+
994
+ const result = await displayTerminalImage(imagePath, {
995
+ env: { TERM_PROGRAM: "iTerm.app" },
996
+ stdoutIsTTY: true,
997
+ fileExists: () => true,
998
+ readFile: () => Buffer.from("png-bytes"),
999
+ write: (chunk) => {
1000
+ writes.push(chunk);
1001
+ },
1002
+ runCommand,
1003
+ });
1004
+
1005
+ expect(result).toEqual(expect.objectContaining({
1006
+ method: "inline-iterm",
1007
+ displayed: true,
1008
+ }));
1009
+ expect(writes.join("")).toContain("\u001B]1337;File=");
1010
+ expect(writes.join("")).toContain("inline=1");
1011
+ expect(runCommand).not.toHaveBeenCalled();
1012
+ });
1013
+
1014
+ test("renders the ClawTip QR image inline for Kitty terminals", async () => {
1015
+ const writes: string[] = [];
1016
+
1017
+ const result = await displayTerminalImage(imagePath, {
1018
+ env: { TERM: "xterm-kitty" },
1019
+ stdoutIsTTY: true,
1020
+ fileExists: () => true,
1021
+ write: (chunk) => {
1022
+ writes.push(chunk);
1023
+ },
1024
+ });
1025
+
1026
+ expect(result).toEqual(expect.objectContaining({
1027
+ method: "inline-kitty",
1028
+ displayed: true,
1029
+ }));
1030
+ expect(writes.join("")).toContain("\u001B_Ga=T,t=f,f=100,c=60;");
1031
+ });
1032
+
1033
+ test("opens the ClawTip QR image with the system viewer when inline images are unsupported", async () => {
1034
+ const runCommand = jest.fn(async () => undefined);
1035
+
1036
+ const result = await displayTerminalImage(imagePath, {
1037
+ env: { TERM_PROGRAM: "Apple_Terminal" },
1038
+ platform: "darwin",
1039
+ stdoutIsTTY: true,
1040
+ fileExists: () => true,
1041
+ runCommand,
1042
+ });
1043
+
1044
+ expect(result).toEqual(expect.objectContaining({
1045
+ method: "system-open",
1046
+ displayed: true,
1047
+ fallbackCommand: `open ${imagePath}`,
1048
+ }));
1049
+ expect(runCommand).toHaveBeenCalledWith("open", [imagePath]);
1050
+ });
1051
+
1052
+ test("falls back to a manual open command when the system viewer fails", async () => {
1053
+ const result = await displayTerminalImage(imagePath, {
1054
+ env: {},
1055
+ platform: "darwin",
1056
+ stdoutIsTTY: true,
1057
+ fileExists: () => true,
1058
+ runCommand: async () => {
1059
+ throw new Error("no gui session");
1060
+ },
1061
+ });
1062
+
1063
+ expect(result).toEqual(expect.objectContaining({
1064
+ method: "manual",
1065
+ displayed: false,
1066
+ fallbackCommand: `open ${imagePath}`,
1067
+ }));
1068
+ expect(result.message).toContain("no gui session");
1069
+ });
532
1070
  });
533
1071
 
534
1072
  describe("TokenBuddy JSON inspection commands", () => {
@@ -538,10 +1076,31 @@ describe("TokenBuddy JSON inspection commands", () => {
538
1076
  let proxyPort: number;
539
1077
  let previousControlPort: string | undefined;
540
1078
  let previousProxyPort: string | undefined;
1079
+ let previousBuyerStoreRoot: string | undefined;
1080
+ let previousHome: string | undefined;
541
1081
 
542
1082
  beforeEach((done) => {
543
1083
  previousControlPort = process.env.TB_PROXYD_CONTROL_PORT;
544
1084
  previousProxyPort = process.env.TB_PROXYD_PROXY_PORT;
1085
+ previousBuyerStoreRoot = process.env.TOKENBUDDY_BUYER_STORE;
1086
+ previousHome = process.env.HOME;
1087
+ rmDir(INSPECTION_STORE_ROOT);
1088
+ rmDir(INSPECTION_HOME);
1089
+ process.env.TOKENBUDDY_BUYER_STORE = INSPECTION_STORE_ROOT;
1090
+ process.env.HOME = INSPECTION_HOME;
1091
+ fs.mkdirSync(path.join(INSPECTION_HOME, ".openclaw", "configs"), { recursive: true });
1092
+ fs.writeFileSync(path.join(INSPECTION_HOME, ".openclaw", "configs", "config.json.bak"), "{}", "utf8");
1093
+ const store = new BuyerStore();
1094
+ store.savePayment({
1095
+ method: "clawtip",
1096
+ enabled: true,
1097
+ isDefault: true,
1098
+ config: {
1099
+ orderNo: "order_json",
1100
+ resourceUrl: "https://example.test/pay"
1101
+ }
1102
+ });
1103
+ store.close();
545
1104
  proxyServer = http.createServer((req, res) => {
546
1105
  res.setHeader("Content-Type", "application/json");
547
1106
  if (req.url === "/v1/models") {
@@ -650,8 +1209,22 @@ describe("TokenBuddy JSON inspection commands", () => {
650
1209
  } else {
651
1210
  process.env.TB_PROXYD_PROXY_PORT = previousProxyPort;
652
1211
  }
1212
+ if (previousBuyerStoreRoot === undefined) {
1213
+ delete process.env.TOKENBUDDY_BUYER_STORE;
1214
+ } else {
1215
+ process.env.TOKENBUDDY_BUYER_STORE = previousBuyerStoreRoot;
1216
+ }
1217
+ if (previousHome === undefined) {
1218
+ delete process.env.HOME;
1219
+ } else {
1220
+ process.env.HOME = previousHome;
1221
+ }
653
1222
  jest.restoreAllMocks();
654
- controlServer.close(() => proxyServer.close(done));
1223
+ controlServer.close(() => proxyServer.close(() => {
1224
+ rmDir(INSPECTION_STORE_ROOT);
1225
+ rmDir(INSPECTION_HOME);
1226
+ done();
1227
+ }));
655
1228
  });
656
1229
 
657
1230
  test("doctor --json reports daemon and provider state", async () => {
@@ -694,6 +1267,16 @@ describe("TokenBuddy JSON inspection commands", () => {
694
1267
  count: 1,
695
1268
  registryUrl: "https://example.test/registry/sellers",
696
1269
  });
1270
+ expect(parsed.clawtipWallet).toMatchObject({
1271
+ status: "metadata_missing_wallet",
1272
+ ready: false,
1273
+ paymentMetadataPresent: true,
1274
+ walletConfigPresent: false,
1275
+ expectedPath: path.join(INSPECTION_HOME, ".openclaw", "configs", "config.json"),
1276
+ alternatePaths: [
1277
+ path.join(INSPECTION_HOME, ".openclaw", "configs", "config.json.bak")
1278
+ ]
1279
+ });
697
1280
  expect(parsed.providers).toEqual(expect.arrayContaining([
698
1281
  expect.objectContaining({ id: "codex" }),
699
1282
  expect.objectContaining({ id: "claude-code" })
@@ -709,6 +1292,10 @@ describe("TokenBuddy JSON inspection commands", () => {
709
1292
  await buildCli().parseAsync(["node", "tb", "doctor"]);
710
1293
 
711
1294
  const joined = output.join("\n");
1295
+ expect(joined).toContain("--- ClawTip Wallet ---");
1296
+ expect(joined).toContain("❌ ClawTip Wallet [metadata_missing_wallet]");
1297
+ expect(joined).toContain("Payment metadata: present");
1298
+ expect(joined).toContain("Wallet config: missing");
712
1299
  expect(joined).toContain("Checking local control plane and proxy endpoints...");
713
1300
  expect(joined).toContain("Refreshing seller registry...");
714
1301
  expect(joined).toContain("Model catalog hidden in `tb doctor`. Run `tb models` for the current model summary.");
@@ -1003,9 +1590,11 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
1003
1590
  let mockSellerServer: http.Server;
1004
1591
  let sellerReqCount = 0;
1005
1592
  let completeReqCount = 0;
1593
+ let balanceReqCount = 0;
1006
1594
  let mockSellerPort: number;
1007
1595
  let daemonControlPort: number;
1008
1596
  let daemonProxyPort: number;
1597
+ const insufficientFundsAttempts = new Map<string, number>();
1009
1598
  const sellerRequests: Array<{
1010
1599
  url?: string;
1011
1600
  authorization?: string;
@@ -1024,6 +1613,25 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
1024
1613
  });
1025
1614
  });
1026
1615
 
1616
+ function setSettlementHeader(res: http.ServerResponse, requestId: string, settledMicros: number, remainingCreditMicros: number): void {
1617
+ res.setHeader("X-TokenBuddy-Settlement", JSON.stringify({
1618
+ requestId,
1619
+ request_id: requestId,
1620
+ settledMicros,
1621
+ settled_micros: settledMicros,
1622
+ settledUsdMicros: settledMicros,
1623
+ settled_usd_micros: settledMicros,
1624
+ remainingCreditMicros,
1625
+ remaining_credit_micros: remainingCreditMicros,
1626
+ reservedBalanceMicros: 0,
1627
+ reserved_balance_micros: 0,
1628
+ spentMicros: settledMicros,
1629
+ spent_micros: settledMicros,
1630
+ priceVersion: "openrouter_usd.v1",
1631
+ price_version: "openrouter_usd.v1"
1632
+ }));
1633
+ }
1634
+
1027
1635
  beforeAll((done) => {
1028
1636
  mockSellerServer = http.createServer(async (req, res) => {
1029
1637
  res.setHeader("Content-Type", "application/json");
@@ -1107,6 +1715,22 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
1107
1715
  return;
1108
1716
  }
1109
1717
 
1718
+ if (req.url === "/v1/balance") {
1719
+ balanceReqCount++;
1720
+ sellerRequests.push({
1721
+ url: req.url,
1722
+ authorization: req.headers.authorization
1723
+ });
1724
+ res.end(JSON.stringify({
1725
+ tokenId: "cred_mock",
1726
+ creditMicros: 1000,
1727
+ reservedMicros: 0,
1728
+ spentMicros: 1999000,
1729
+ currency: "Micros"
1730
+ }));
1731
+ return;
1732
+ }
1733
+
1110
1734
  if (req.url === "/v1/chat/completions") {
1111
1735
  const body = await readJsonBody(req);
1112
1736
  sellerRequests.push({
@@ -1115,12 +1739,37 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
1115
1739
  idempotencyKey: req.headers["idempotency-key"] as string | undefined,
1116
1740
  body
1117
1741
  });
1742
+ if (body.requestId === "chat_req_402_retry") {
1743
+ const attempts = insufficientFundsAttempts.get(body.requestId) || 0;
1744
+ insufficientFundsAttempts.set(body.requestId, attempts + 1);
1745
+ if (attempts === 0) {
1746
+ res.statusCode = 402;
1747
+ res.end(JSON.stringify({
1748
+ error: {
1749
+ code: "insufficient_funds",
1750
+ message: "Insufficient funds"
1751
+ }
1752
+ }));
1753
+ return;
1754
+ }
1755
+ }
1118
1756
  if (body.stream) {
1119
1757
  res.writeHead(200, { "Content-Type": "text/event-stream" });
1120
1758
  res.write("data: {\"id\":\"chatcmpl-stream\",\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n");
1759
+ res.write("event: tokenbuddy.settlement\n");
1760
+ res.write(`data: ${JSON.stringify({
1761
+ requestId: body.requestId || "stream_req_mock",
1762
+ settledMicros: 110,
1763
+ settledUsdMicros: 110,
1764
+ remainingCreditMicros: 1999890,
1765
+ reservedBalanceMicros: 0,
1766
+ spentMicros: 110,
1767
+ priceVersion: "openrouter_usd.v1"
1768
+ })}\n\n`);
1121
1769
  res.end("data: [DONE]\n\n");
1122
1770
  return;
1123
1771
  }
1772
+ setSettlementHeader(res, body.requestId || "chat_req_mock", 110, 1999890);
1124
1773
  res.end(JSON.stringify({
1125
1774
  id: "chatcmpl-mock",
1126
1775
  usage: { prompt_tokens: 10, completion_tokens: 10 }
@@ -1159,6 +1808,7 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
1159
1808
  res.end(compressed);
1160
1809
  return;
1161
1810
  }
1811
+ setSettlementHeader(res, body.requestId || "responses_req_mock", 64, 1999936);
1162
1812
  res.end(JSON.stringify({
1163
1813
  id: "resp-mock",
1164
1814
  usage: { input_tokens: 7, output_tokens: 9 }
@@ -1174,6 +1824,7 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
1174
1824
  idempotencyKey: req.headers["idempotency-key"] as string | undefined,
1175
1825
  body
1176
1826
  });
1827
+ setSettlementHeader(res, body.requestId || "messages_req_mock", 44, 1999956);
1177
1828
  res.end(JSON.stringify({
1178
1829
  id: "msg-mock",
1179
1830
  usage: { input_tokens: 5, output_tokens: 6 }
@@ -1198,6 +1849,8 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
1198
1849
  rmSqliteFiles(TEMP_BUYER_DB);
1199
1850
  sellerReqCount = 0;
1200
1851
  completeReqCount = 0;
1852
+ balanceReqCount = 0;
1853
+ insufficientFundsAttempts.clear();
1201
1854
  sellerRequests.length = 0;
1202
1855
  const seedStore = new BuyerStore({ dbPath: TEMP_BUYER_DB });
1203
1856
  seedStore.savePayment({
@@ -1385,11 +2038,75 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
1385
2038
  const inferences = await (await fetch(`${controlUrl}/ledger/inferences`)).json() as any;
1386
2039
  expect(purchases.purchases.some((entry: any) => entry.purchaseId === "pur_mock_123" && entry.paymentMethod === "mock")).toBe(true);
1387
2040
  expect(inferences.inferences.filter((entry: any) => entry.endpoint === "/v1/chat/completions")).toHaveLength(11);
2041
+ const chatLedgers = inferences.inferences.filter((entry: any) => entry.requestId === "chat_req_parallel");
2042
+ expect(chatLedgers).toHaveLength(10);
2043
+ expect(chatLedgers).toEqual(expect.arrayContaining([
2044
+ expect.objectContaining({
2045
+ billedMicros: 110,
2046
+ estimatedMicros: 80,
2047
+ settledMicros: 110,
2048
+ settledUsdMicros: 110,
2049
+ priceVersion: "openrouter_usd.v1",
2050
+ balanceSnapshotMicros: 1999890,
2051
+ balanceSource: "seller_authoritative"
2052
+ })
2053
+ ]));
2054
+ expect(balanceReqCount).toBe(0);
2055
+ const store = new BuyerStore({ dbPath: TEMP_BUYER_DB });
2056
+ try {
2057
+ expect(store.getToken("mock-seller")).toMatchObject({
2058
+ balanceMicros: 1999890,
2059
+ reservedMicros: 0,
2060
+ spentMicros: 110,
2061
+ balanceSource: "seller_settlement_summary"
2062
+ });
2063
+ } finally {
2064
+ store.close();
2065
+ }
1388
2066
  const publicOutput = JSON.stringify({ purchases, inferences });
1389
2067
  expect(publicOutput).not.toContain("raw concurrent prompt secret");
1390
2068
  expect(publicOutput).not.toContain("tok_mock_token_abc");
1391
2069
  });
1392
2070
 
2071
+ test("refreshes balance, auto-purchases, and retries once after seller 402 insufficient funds", async () => {
2072
+ const store = new BuyerStore({ dbPath: TEMP_BUYER_DB });
2073
+ store.saveToken("mock-seller", "tok_existing_high_cache", "model:gpt-4", 900000, "2030-01-01T00:00:00.000Z");
2074
+ store.close();
2075
+
2076
+ const response = await fetch(`http://127.0.0.1:${daemonProxyPort}/v1/chat/completions`, {
2077
+ method: "POST",
2078
+ headers: {
2079
+ "Content-Type": "application/json",
2080
+ "Idempotency-Key": "idem-402-retry"
2081
+ },
2082
+ body: JSON.stringify({
2083
+ model: "gpt-4",
2084
+ messages: [{ role: "user", content: "trigger 402" }],
2085
+ requestId: "chat_req_402_retry"
2086
+ })
2087
+ });
2088
+
2089
+ expect(response.ok).toBe(true);
2090
+ expect((await response.json() as any).id).toBe("chatcmpl-mock");
2091
+ expect(balanceReqCount).toBe(1);
2092
+ expect(sellerRequests.filter((request) => request.url === "/v1/chat/completions" && request.body?.requestId === "chat_req_402_retry")).toHaveLength(2);
2093
+ expect(sellerRequests.filter((request) => request.url === "/purchase/create")).toHaveLength(1);
2094
+ expect(sellerRequests.filter((request) => request.url === "/purchase/complete")).toHaveLength(1);
2095
+ expect(sellerRequests.filter((request) => request.url === "/v1/chat/completions" && request.body?.requestId === "chat_req_402_retry")
2096
+ .every((request) => request.idempotencyKey === "idem-402-retry")).toBe(true);
2097
+
2098
+ const inferences = await (await fetch(`http://127.0.0.1:${daemonControlPort}/ledger/inferences`)).json() as any;
2099
+ expect(inferences.inferences).toEqual(expect.arrayContaining([
2100
+ expect.objectContaining({
2101
+ requestId: "chat_req_402_retry",
2102
+ billedMicros: 110,
2103
+ estimatedMicros: 80,
2104
+ settledMicros: 110,
2105
+ balanceSource: "seller_authoritative"
2106
+ })
2107
+ ]));
2108
+ });
2109
+
1393
2110
  test("proxies models, responses, and anthropic message endpoints through compatible seller manifests", async () => {
1394
2111
  const proxyUrl = `http://127.0.0.1:${daemonProxyPort}`;
1395
2112
 
@@ -1520,11 +2237,20 @@ describe("TokenBuddy CLI and Daemon Integration Tests", () => {
1520
2237
  const body = await response.text();
1521
2238
  expect(body).toContain("chatcmpl-stream");
1522
2239
  expect(body).toContain("[DONE]");
2240
+ expect(body).not.toContain("tokenbuddy.settlement");
1523
2241
  expect(sellerRequests.find((request) => request.url === "/v1/chat/completions" && request.body?.stream)?.idempotencyKey).toBe("idem-stream-preserved");
1524
2242
 
1525
2243
  const inferences = await (await fetch(`http://127.0.0.1:${daemonControlPort}/ledger/inferences`)).json() as any;
1526
2244
  expect(inferences.inferences).toEqual(expect.arrayContaining([
1527
- expect.objectContaining({ endpoint: "/v1/chat/completions", requestId: "stream_req_1" })
2245
+ expect.objectContaining({
2246
+ endpoint: "/v1/chat/completions",
2247
+ requestId: "stream_req_1",
2248
+ status: "settled",
2249
+ billedMicros: 110,
2250
+ settledMicros: 110,
2251
+ settledUsdMicros: 110,
2252
+ balanceSource: "seller_authoritative"
2253
+ })
1528
2254
  ]));
1529
2255
  const publicOutput = JSON.stringify(inferences);
1530
2256
  expect(publicOutput).not.toContain("raw stream prompt secret");