Replace fabricated identity, trace, and forum output with real data

fa6c121d89f9 · AtlantisPleb · · parent c8d2f76040b1

Replace fabricated identity, trace, and forum output with real data

Three commands in the Rust CLI printed invented values as though a real
system had produced them. A stub that prints nothing is honest; these
were not, and `trace redact` was the worst of them, because anyone who
ran it before sharing a trace had been told the trace was safe.

`oa identity` derived every key from `Sha256` of the string literal
"openagents-entropy-seed-phrase", so two empty HOMEs on two machines
minted the same npub, and that npub was `format!`-prefixed hex — 37
characters where NIP-19 is 63, with no bech32 checksum. `create` never
persisted, so `show` reported nothing a moment later, and import,
backup, and forget were `println!`s over untouched files. It now derives
BIP-39 -> NIP-06 `m/44'/1237'/0'/0/0` -> secp256k1 -> bech32 under the
frozen `openagents.legacy_unified_nostr_spark.v1` profile, seeded from
OS entropy and stored 0600 inside a 0700 directory.

`seed-identity.ts` is the contract, and an npub that differs between the
two CLIs is a different account, so `identity_test.rs` pins the
published BIP-39 test phrase to the same frozen vector its TypeScript
counterpart asserts: npub, x-only key, wallet key, fingerprint, and
P2PKH address, field for field. A change to the derivation now fails the
build rather than silently reissuing every identity.

`oa trace list` returned two hardcoded sessions and their step counts.
It now walks the three real stores breadth-first within depth, entry,
and listing budgets, never following a symlink and counting every one it
skips. `trace show` accepted any id at all and printed one line back; it
now resolves by path, refuses an argument that resolves to nothing, and
summarizes an ATIF document from its own contents while reporting a
foreign log as a foreign log rather than giving it invented steps.

`redact_trace` replaced the *prefix* of a secret and dropped the result:
`sk-liveSECRET` became `[REDACTED_KEY]liveSECRET`, the file grew by
exactly the length of the two markers, and nothing was written. It now
applies the ten ordered rules from `trace-command.ts` — seed phrase
resolved against the BIP-39 wordlist so prose survives, private keys,
bearer tokens, API keys, JWTs, secret-named fields, environment values,
and home paths — and writes the redacted sibling. Rust's `regex` has
neither backreferences nor lookaheads, so the paired-quote and
already-redacted guards are hand-rolled where the JavaScript leaned on
them.

Redaction also gained OpenAgents' own token family, which no TypeScript
rule covers: `oa_pat_REALTOKEN456` survived the ported rules intact.
The patterns come from `packages/atif/src/redaction.ts`. Redacting more
than the contract is safe; redacting less is the failure being closed.

`oa forum boards` answered any non-2xx with two hardcoded boards,
inventing a `dev` board the server has never served — and the route it
called, `/forum/boards`, does not exist. It now calls `/api/v1/forum`
and `/api/v1/forum/topics?forum=<slug>`, renders what the server sends,
and surfaces a refusal as a typed error. `topics` returned nothing for a
board holding 107 of them; it now lists them.

Every one of these paths exits non-zero when it cannot reach its data.
No fallback invents a value the server did not send.

Three tests asserted the fabrication and had to be fixed rather than
preserved. `test_forum_client_issue_80` asserted only that boards were
non-empty, which passed *because of* the fallback; it now asserts the
board's own id, title, and topic count. `test_trace_store_and_redaction`
asserted `sessions.len() == 2` against the two literals and that the
output *contained* a marker — which a prefix swap satisfies while
leaving the token — so it now asserts the secret body is absent.
`test_identity_generation` checked only that the strings began `npub1`
and `nsec1`, which a formatted digest satisfies.

Verified by running the binary: two empty HOMEs now mint different
npubs; a probe file carrying an API key, a JWT, a bearer token, a PAT, an
nsec, an env value, and a home path redacts to a written file that greps
clean for every one, shrinking 374 bytes to 250 rather than growing; and
`trace list` reports 66/1285/2533 files against a filesystem holding
exactly 66/1285/2533.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 182 · 2026-08-26T06:24:05.098991Z

Changed files

  • modified Cargo.lock
  • modified crates/openagents-cli/Cargo.toml
  • modified crates/openagents-cli/src/cli.rs
  • modified crates/openagents-cli/src/forum.rs
  • modified crates/openagents-cli/src/identity.rs
  • modified crates/openagents-cli/src/trace.rs
  • modified crates/openagents-cli/tests/cli_test.rs
  • added crates/openagents-cli/tests/identity_test.rs
  • added crates/openagents-cli/tests/trace_test.rs

Diff

9 files changed, +2649 -196

Cargo.lock modified +343 -7

@@ -89,6 +89,12 @@ dependencies = [

89 89
 "windows-sys 0.61.2",
90 90
]
91 91
92
[[package]]
93
name = "arrayvec"
94
version = "0.7.8"
95
source = "registry+https://github.com/rust-lang/crates.io-index"
96
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
97
92 98
[[package]]
93 99
name = "async-trait"
94 100
version = "0.1.92"

@@ -112,12 +118,24 @@ version = "1.5.1"

112 118
source = "registry+https://github.com/rust-lang/crates.io-index"
113 119
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
114 120
121
[[package]]
122
name = "base16ct"
123
version = "0.2.0"
124
source = "registry+https://github.com/rust-lang/crates.io-index"
125
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
126
115 127
[[package]]
116 128
name = "base64"
117 129
version = "0.22.1"
118 130
source = "registry+https://github.com/rust-lang/crates.io-index"
119 131
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
120 132
133
[[package]]
134
name = "bech32"
135
version = "0.11.1"
136
source = "registry+https://github.com/rust-lang/crates.io-index"
137
checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f"
138
121 139
[[package]]
122 140
name = "bindgen"
123 141
version = "0.72.1"

@@ -136,6 +154,47 @@ dependencies = [

136 154
 "syn 2.0.117",
137 155
]
138 156
157
[[package]]
158
name = "bip32"
159
version = "0.5.3"
160
source = "registry+https://github.com/rust-lang/crates.io-index"
161
checksum = "db40d3dfbeab4e031d78c844642fa0caa0b0db11ce1607ac9d2986dff1405c69"
162
dependencies = [
163
 "bs58",
164
 "hmac",
165
 "k256",
166
 "once_cell",
167
 "pbkdf2",
168
 "rand_core 0.6.4",
169
 "ripemd",
170
 "secp256k1",
171
 "sha2",
172
 "subtle",
173
 "zeroize",
174
]
175
176
[[package]]
177
name = "bip39"
178
version = "2.2.2"
179
source = "registry+https://github.com/rust-lang/crates.io-index"
180
checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc"
181
dependencies = [
182
 "bitcoin_hashes",
183
 "rand 0.8.8",
184
 "rand_core 0.6.4",
185
 "serde",
186
 "unicode-normalization",
187
]
188
189
[[package]]
190
name = "bitcoin_hashes"
191
version = "0.14.101"
192
source = "registry+https://github.com/rust-lang/crates.io-index"
193
checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2"
194
dependencies = [
195
 "hex-conservative",
196
]
197
139 198
[[package]]
140 199
name = "bitflags"
141 200
version = "1.3.2"

@@ -157,6 +216,16 @@ dependencies = [

157 216
 "generic-array",
158 217
]
159 218
219
[[package]]
220
name = "bs58"
221
version = "0.5.1"
222
source = "registry+https://github.com/rust-lang/crates.io-index"
223
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
224
dependencies = [
225
 "sha2",
226
 "tinyvec",
227
]
228
160 229
[[package]]
161 230
name = "bumpalo"
162 231
version = "3.20.3"

@@ -304,6 +373,12 @@ dependencies = [

304 373
 "static_assertions",
305 374
]
306 375
376
[[package]]
377
name = "const-oid"
378
version = "0.9.6"
379
source = "registry+https://github.com/rust-lang/crates.io-index"
380
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
381
307 382
[[package]]
308 383
name = "core-foundation"
309 384
version = "0.10.1"

@@ -398,7 +473,7 @@ dependencies = [

398 473
 "futures-core",
399 474
 "mio",
400 475
 "parking_lot",
401
 "rustix",
476
 "rustix 0.38.44",
402 477
 "signal-hook",
403 478
 "signal-hook-mio",
404 479
 "winapi",

@@ -413,6 +488,18 @@ dependencies = [

413 488
 "winapi",
414 489
]
415 490
491
[[package]]
492
name = "crypto-bigint"
493
version = "0.5.5"
494
source = "registry+https://github.com/rust-lang/crates.io-index"
495
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
496
dependencies = [
497
 "generic-array",
498
 "rand_core 0.6.4",
499
 "subtle",
500
 "zeroize",
501
]
502
416 503
[[package]]
417 504
name = "crypto-common"
418 505
version = "0.1.7"

@@ -469,6 +556,16 @@ version = "2.11.0"

469 556
source = "registry+https://github.com/rust-lang/crates.io-index"
470 557
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
471 558
559
[[package]]
560
name = "der"
561
version = "0.7.10"
562
source = "registry+https://github.com/rust-lang/crates.io-index"
563
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
564
dependencies = [
565
 "const-oid",
566
 "zeroize",
567
]
568
472 569
[[package]]
473 570
name = "digest"
474 571
version = "0.10.7"

@@ -476,7 +573,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"

476 573
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
477 574
dependencies = [
478 575
 "block-buffer",
576
 "const-oid",
479 577
 "crypto-common",
578
 "subtle",
480 579
]
481 580
482 581
[[package]]

@@ -490,12 +589,43 @@ dependencies = [

490 589
 "syn 2.0.117",
491 590
]
492 591
592
[[package]]
593
name = "ecdsa"
594
version = "0.16.9"
595
source = "registry+https://github.com/rust-lang/crates.io-index"
596
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
597
dependencies = [
598
 "der",
599
 "digest",
600
 "elliptic-curve",
601
 "rfc6979",
602
 "signature",
603
]
604
493 605
[[package]]
494 606
name = "either"
495 607
version = "1.16.0"
496 608
source = "registry+https://github.com/rust-lang/crates.io-index"
497 609
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
498 610
611
[[package]]
612
name = "elliptic-curve"
613
version = "0.13.8"
614
source = "registry+https://github.com/rust-lang/crates.io-index"
615
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
616
dependencies = [
617
 "base16ct",
618
 "crypto-bigint",
619
 "digest",
620
 "ff",
621
 "generic-array",
622
 "group",
623
 "rand_core 0.6.4",
624
 "sec1",
625
 "subtle",
626
 "zeroize",
627
]
628
499 629
[[package]]
500 630
name = "equivalent"
501 631
version = "1.0.2"

@@ -523,6 +653,22 @@ dependencies = [

523 653
 "pin-project-lite",
524 654
]
525 655
656
[[package]]
657
name = "fastrand"
658
version = "2.5.0"
659
source = "registry+https://github.com/rust-lang/crates.io-index"
660
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
661
662
[[package]]
663
name = "ff"
664
version = "0.13.1"
665
source = "registry+https://github.com/rust-lang/crates.io-index"
666
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
667
dependencies = [
668
 "rand_core 0.6.4",
669
 "subtle",
670
]
671
526 672
[[package]]
527 673
name = "find-msvc-tools"
528 674
version = "0.1.9"

@@ -640,6 +786,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"

640 786
dependencies = [
641 787
 "typenum",
642 788
 "version_check",
789
 "zeroize",
643 790
]
644 791
645 792
[[package]]

@@ -686,6 +833,17 @@ version = "0.3.3"

686 833
source = "registry+https://github.com/rust-lang/crates.io-index"
687 834
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
688 835
836
[[package]]
837
name = "group"
838
version = "0.13.0"
839
source = "registry+https://github.com/rust-lang/crates.io-index"
840
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
841
dependencies = [
842
 "ff",
843
 "rand_core 0.6.4",
844
 "subtle",
845
]
846
689 847
[[package]]
690 848
name = "hashbrown"
691 849
version = "0.15.5"

@@ -709,6 +867,24 @@ version = "0.5.0"

709 867
source = "registry+https://github.com/rust-lang/crates.io-index"
710 868
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
711 869
870
[[package]]
871
name = "hex-conservative"
872
version = "0.2.2"
873
source = "registry+https://github.com/rust-lang/crates.io-index"
874
checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f"
875
dependencies = [
876
 "arrayvec",
877
]
878
879
[[package]]
880
name = "hmac"
881
version = "0.12.1"
882
source = "registry+https://github.com/rust-lang/crates.io-index"
883
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
884
dependencies = [
885
 "digest",
886
]
887
712 888
[[package]]
713 889
name = "http"
714 890
version = "1.4.0"

@@ -1041,6 +1217,18 @@ dependencies = [

1041 1217
 "wasm-bindgen",
1042 1218
]
1043 1219
1220
[[package]]
1221
name = "k256"
1222
version = "0.13.4"
1223
source = "registry+https://github.com/rust-lang/crates.io-index"
1224
checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
1225
dependencies = [
1226
 "cfg-if",
1227
 "ecdsa",
1228
 "elliptic-curve",
1229
 "sha2",
1230
]
1231
1044 1232
[[package]]
1045 1233
name = "lazy_static"
1046 1234
version = "1.5.0"

@@ -1069,6 +1257,12 @@ version = "0.4.15"

1069 1257
source = "registry+https://github.com/rust-lang/crates.io-index"
1070 1258
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
1071 1259
1260
[[package]]
1261
name = "linux-raw-sys"
1262
version = "0.12.1"
1263
source = "registry+https://github.com/rust-lang/crates.io-index"
1264
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
1265
1072 1266
[[package]]
1073 1267
name = "litemap"
1074 1268
version = "0.8.2"

@@ -1371,6 +1565,10 @@ name = "openagents-cli"

1371 1565
version = "0.1.0"
1372 1566
dependencies = [
1373 1567
 "async-trait",
1568
 "bech32",
1569
 "bip32",
1570
 "bip39",
1571
 "bs58",
1374 1572
 "clap",
1375 1573
 "crossterm",
1376 1574
 "eventsource-stream",

@@ -1378,11 +1576,14 @@ dependencies = [

1378 1576
 "openagents-all-work-contract",
1379 1577
 "openagents-cloud-contract",
1380 1578
 "ratatui",
1579
 "regex",
1381 1580
 "reqwest",
1581
 "ripemd",
1382 1582
 "serde",
1383 1583
 "serde_json",
1384 1584
 "sha2",
1385 1585
 "sysinfo",
1586
 "tempfile",
1386 1587
 "tokio",
1387 1588
 "tracing",
1388 1589
 "tracing-subscriber",

@@ -1433,6 +1634,16 @@ version = "1.0.15"

1433 1634
source = "registry+https://github.com/rust-lang/crates.io-index"
1434 1635
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
1435 1636
1637
[[package]]
1638
name = "pbkdf2"
1639
version = "0.12.2"
1640
source = "registry+https://github.com/rust-lang/crates.io-index"
1641
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
1642
dependencies = [
1643
 "digest",
1644
 "hmac",
1645
]
1646
1436 1647
[[package]]
1437 1648
name = "percent-encoding"
1438 1649
version = "2.3.2"

@@ -1516,7 +1727,7 @@ dependencies = [

1516 1727
 "bytes",
1517 1728
 "getrandom 0.3.4",
1518 1729
 "lru-slab",
1519
 "rand",
1730
 "rand 0.9.4",
1520 1731
 "ring",
1521 1732
 "rustc-hash",
1522 1733
 "rustls",

@@ -1563,14 +1774,35 @@ version = "6.0.0"

1563 1774
source = "registry+https://github.com/rust-lang/crates.io-index"
1564 1775
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
1565 1776
1777
[[package]]
1778
name = "rand"
1779
version = "0.8.8"
1780
source = "registry+https://github.com/rust-lang/crates.io-index"
1781
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
1782
dependencies = [
1783
 "libc",
1784
 "rand_chacha 0.3.1",
1785
 "rand_core 0.6.4",
1786
]
1787
1566 1788
[[package]]
1567 1789
name = "rand"
1568 1790
version = "0.9.4"
1569 1791
source = "registry+https://github.com/rust-lang/crates.io-index"
1570 1792
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
1571 1793
dependencies = [
1572
 "rand_chacha",
1573
 "rand_core",
1794
 "rand_chacha 0.9.0",
1795
 "rand_core 0.9.5",
1796
]
1797
1798
[[package]]
1799
name = "rand_chacha"
1800
version = "0.3.1"
1801
source = "registry+https://github.com/rust-lang/crates.io-index"
1802
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
1803
dependencies = [
1804
 "ppv-lite86",
1805
 "rand_core 0.6.4",
1574 1806
]
1575 1807
1576 1808
[[package]]

@@ -1580,7 +1812,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"

1580 1812
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
1581 1813
dependencies = [
1582 1814
 "ppv-lite86",
1583
 "rand_core",
1815
 "rand_core 0.9.5",
1816
]
1817
1818
[[package]]
1819
name = "rand_core"
1820
version = "0.6.4"
1821
source = "registry+https://github.com/rust-lang/crates.io-index"
1822
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
1823
dependencies = [
1824
 "getrandom 0.2.17",
1584 1825
]
1585 1826
1586 1827
[[package]]

@@ -1693,6 +1934,16 @@ dependencies = [

1693 1934
 "webpki-roots",
1694 1935
]
1695 1936
1937
[[package]]
1938
name = "rfc6979"
1939
version = "0.4.0"
1940
source = "registry+https://github.com/rust-lang/crates.io-index"
1941
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
1942
dependencies = [
1943
 "hmac",
1944
 "subtle",
1945
]
1946
1696 1947
[[package]]
1697 1948
name = "ring"
1698 1949
version = "0.17.14"

@@ -1707,6 +1958,15 @@ dependencies = [

1707 1958
 "windows-sys 0.52.0",
1708 1959
]
1709 1960
1961
[[package]]
1962
name = "ripemd"
1963
version = "0.1.3"
1964
source = "registry+https://github.com/rust-lang/crates.io-index"
1965
checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f"
1966
dependencies = [
1967
 "digest",
1968
]
1969
1710 1970
[[package]]
1711 1971
name = "rustc-hash"
1712 1972
version = "2.1.2"

@@ -1722,10 +1982,23 @@ dependencies = [

1722 1982
 "bitflags 2.11.1",
1723 1983
 "errno",
1724 1984
 "libc",
1725
 "linux-raw-sys",
1985
 "linux-raw-sys 0.4.15",
1726 1986
 "windows-sys 0.52.0",
1727 1987
]
1728 1988
1989
[[package]]
1990
name = "rustix"
1991
version = "1.1.4"
1992
source = "registry+https://github.com/rust-lang/crates.io-index"
1993
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
1994
dependencies = [
1995
 "bitflags 2.11.1",
1996
 "errno",
1997
 "libc",
1998
 "linux-raw-sys 0.12.1",
1999
 "windows-sys 0.61.2",
2000
]
2001
1729 2002
[[package]]
1730 2003
name = "rustls"
1731 2004
version = "0.23.40"

@@ -1809,6 +2082,37 @@ version = "1.2.0"

1809 2082
source = "registry+https://github.com/rust-lang/crates.io-index"
1810 2083
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
1811 2084
2085
[[package]]
2086
name = "sec1"
2087
version = "0.7.3"
2088
source = "registry+https://github.com/rust-lang/crates.io-index"
2089
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
2090
dependencies = [
2091
 "base16ct",
2092
 "der",
2093
 "generic-array",
2094
 "subtle",
2095
 "zeroize",
2096
]
2097
2098
[[package]]
2099
name = "secp256k1"
2100
version = "0.27.0"
2101
source = "registry+https://github.com/rust-lang/crates.io-index"
2102
checksum = "25996b82292a7a57ed3508f052cfff8640d38d32018784acd714758b43da9c8f"
2103
dependencies = [
2104
 "secp256k1-sys",
2105
]
2106
2107
[[package]]
2108
name = "secp256k1-sys"
2109
version = "0.8.2"
2110
source = "registry+https://github.com/rust-lang/crates.io-index"
2111
checksum = "4473013577ec77b4ee3668179ef1186df3146e2cf2d927bd200974c6fe60fd99"
2112
dependencies = [
2113
 "cc",
2114
]
2115
1812 2116
[[package]]
1813 2117
name = "security-framework"
1814 2118
version = "3.7.0"

@@ -1955,6 +2259,16 @@ dependencies = [

1955 2259
 "libc",
1956 2260
]
1957 2261
2262
[[package]]
2263
name = "signature"
2264
version = "2.2.0"
2265
source = "registry+https://github.com/rust-lang/crates.io-index"
2266
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
2267
dependencies = [
2268
 "digest",
2269
 "rand_core 0.6.4",
2270
]
2271
1958 2272
[[package]]
1959 2273
name = "slab"
1960 2274
version = "0.4.12"

@@ -2079,6 +2393,19 @@ dependencies = [

2079 2393
 "windows 0.61.3",
2080 2394
]
2081 2395
2396
[[package]]
2397
name = "tempfile"
2398
version = "3.27.0"
2399
source = "registry+https://github.com/rust-lang/crates.io-index"
2400
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
2401
dependencies = [
2402
 "fastrand",
2403
 "getrandom 0.4.3",
2404
 "once_cell",
2405
 "rustix 1.1.4",
2406
 "windows-sys 0.61.2",
2407
]
2408
2082 2409
[[package]]
2083 2410
name = "thiserror"
2084 2411
version = "1.0.69"

@@ -2357,7 +2684,7 @@ dependencies = [

2357 2684
 "http",
2358 2685
 "httparse",
2359 2686
 "log",
2360
 "rand",
2687
 "rand 0.9.4",
2361 2688
 "rustls",
2362 2689
 "rustls-native-certs",
2363 2690
 "rustls-pki-types",

@@ -2378,6 +2705,15 @@ version = "1.0.24"

2378 2705
source = "registry+https://github.com/rust-lang/crates.io-index"
2379 2706
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
2380 2707
2708
[[package]]
2709
name = "unicode-normalization"
2710
version = "0.1.25"
2711
source = "registry+https://github.com/rust-lang/crates.io-index"
2712
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
2713
dependencies = [
2714
 "tinyvec",
2715
]
2716
2381 2717
[[package]]
2382 2718
name = "unicode-segmentation"
2383 2719
version = "1.13.3"
crates/openagents-cli/Cargo.toml modified +10

@@ -28,3 +28,13 @@ async-trait = "0.1"

28 28
eventsource-stream = "0.2.3"
29 29
unicode-width = "0.2"
30 30
unicode-segmentation = "1"
31
bip39 = { version = "2", features = ["rand"] }
32
bip32 = "0.5"
33
bech32 = "0.11"
34
ripemd = "0.1"
35
bs58 = { version = "0.5", features = ["check"] }
36
regex = "1"
37
38
[dev-dependencies]
39
tempfile = "3"
40
bech32 = "0.11"
crates/openagents-cli/src/cli.rs modified +456 -51

@@ -86,17 +86,27 @@ pub struct IdentityArgs {

86 86
87 87
#[derive(Subcommand, Debug)]
88 88
pub enum IdentityAction {
89
    /// Show the public identity derived from the stored seed
89 90
    Show,
91
    /// Generate a new seed phrase and store it 0600
90 92
    Create {
91
        #[arg(long)]
92
        name: Option<String>,
93
        #[arg(long, default_value_t = 12, help = "Words in the new seed phrase: 12 for 128 bits, 24 for 256")]
94
        words: usize,
95
        #[arg(long, help = "Replace the stored seed. The identity and wallet it derives are lost")]
96
        force: bool,
93 97
    },
98
    /// Restore an existing seed phrase, read from standard input
94 99
    Import {
95
        #[arg(long)]
96
        seed: Option<String>,
100
        #[arg(long, help = "Replace the stored seed. The identity and wallet it derives are lost")]
101
        force: bool,
97 102
    },
103
    /// Print the stored seed phrase
98 104
    Backup,
99
    Forget,
105
    /// Delete the stored seed
106
    Forget {
107
        #[arg(long, help = "Confirm that the identity and wallet are to be destroyed")]
108
        force: bool,
109
    },
100 110
}
101 111
102 112
#[derive(Args, Debug)]

@@ -255,10 +265,17 @@ pub struct ForumArgs {

255 265
256 266
#[derive(Subcommand, Debug)]
257 267
pub enum ForumAction {
268
    /// List the forum's boards
258 269
    Boards,
270
    /// List a board's topics
259 271
    Topics {
260
        #[arg(long)]
261
        board: Option<String>,
272
        #[arg(long, help = "Board slug, as `oa forum boards` reports it")]
273
        board: String,
274
    },
275
    /// Search topics across boards
276
    Search {
277
        #[arg(help = "Search query")]
278
        query: String,
262 279
    },
263 280
}
264 281

@@ -298,14 +315,24 @@ pub struct TraceArgs {

298 315
299 316
#[derive(Subcommand, Debug)]
300 317
pub enum TraceAction {
301
    List,
318
    /// Discover trace files in the local agent stores
319
    List {
320
        #[arg(long, help = "Scan this directory instead of the default stores. Repeatable")]
321
        path: Vec<String>,
322
        #[arg(long, default_value_t = 20, help = "Most files to list per store")]
323
        limit: usize,
324
    },
325
    /// Summarize one trace file
302 326
    Show {
303
        #[arg(help = "Trace UUID or session ID")]
304
        id: String,
327
        #[arg(help = "A trace file path, or a file name inside ~/.openagents/exports")]
328
        trace: String,
305 329
    },
330
    /// Write a redacted copy of a trace file beside the original
306 331
    Redact {
307
        #[arg(long)]
308
        file: String,
332
        #[arg(help = "A trace file path, or a file name inside ~/.openagents/exports")]
333
        trace: Option<String>,
334
        #[arg(long, help = "Deprecated alias for the positional trace argument")]
335
        file: Option<String>,
309 336
    },
310 337
}
311 338

@@ -343,27 +370,7 @@ pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {

343 370
                print!("{}", output);
344 371
            }
345 372
        },
346
        Commands::Identity(identity) => match identity.action {
347
            IdentityAction::Show => {
348
                let ident_store = crate::identity::IdentityStore::new(None);
349
                let idents = ident_store.load()?;
350
                println!("Active identities: {} registered", idents.len());
351
            }
352
            IdentityAction::Create { name } => {
353
                let ident_name = name.unwrap_or_else(|| "default".to_string());
354
                let record = crate::identity::IdentityStore::generate_identity(&ident_name, None);
355
                println!("Created identity: {} (npub: {})", record.name, record.npub);
356
            }
357
            IdentityAction::Import { seed: _ } => {
358
                println!("Imported cryptographic identity.");
359
            }
360
            IdentityAction::Backup => {
361
                println!("Identity backup exported.");
362
            }
363
            IdentityAction::Forget => {
364
                println!("Identity removed.");
365
            }
366
        },
373
        Commands::Identity(identity) => run_identity(identity.action, cli.json),
367 374
        Commands::Issue(issue) => {
368 375
            let tracker = crate::tracker::TrackerClient::new("https://openagents.com/api/v1", token);
369 376
            match issue.action {

@@ -489,16 +496,44 @@ pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {

489 496
            let client = crate::forum::ForumClient::new("https://openagents.com/api/v1", token);
490 497
            match forum.action {
491 498
                ForumAction::Boards => {
492
                    let boards = client.list_boards().await.map_err(|e| e.to_string())?;
499
                    // A refusal ends the command. The version this replaces answered
500
                    // a non-2xx with two hardcoded boards, one of which the server
501
                    // has never served.
502
                    let boards = client.list_boards().await.unwrap_or_else(|e| fail(&e.to_string()));
503
                    if boards.is_empty() {
504
                        println!("No boards found.");
505
                    }
493 506
                    for b in boards {
494
                        println!("{}\t{}\t- {}", b.id, b.name, b.description);
507
                        println!("{} — {} ({} topics)", b.slug, b.title, b.topic_count);
495 508
                    }
496 509
                }
497 510
                ForumAction::Topics { board } => {
498
                    let b = board.unwrap_or_else(|| "general".to_string());
499
                    let topics = client.list_topics(&b).await.map_err(|e| e.to_string())?;
511
                    let topics = client
512
                        .list_topics(&board)
513
                        .await
514
                        .unwrap_or_else(|e| fail(&e.to_string()));
515
                    if topics.is_empty() {
516
                        println!("No topics found.");
517
                    }
500 518
                    for t in topics {
501
                        println!("{}\t{}", t.id, t.title);
519
                        println!("{} — {} ({} posts)", short_id(&t.id), t.title, t.posts_count);
520
                    }
521
                }
522
                ForumAction::Search { query } => {
523
                    let topics = client
524
                        .search_topics(&query)
525
                        .await
526
                        .unwrap_or_else(|e| fail(&e.to_string()));
527
                    if topics.is_empty() {
528
                        println!("No topics match.");
529
                    }
530
                    for t in topics {
531
                        println!(
532
                            "{} — {} — {}",
533
                            short_id(&t.id),
534
                            t.title,
535
                            t.author.as_deref().unwrap_or("?")
536
                        );
502 537
                    }
503 538
                }
504 539
            }

@@ -524,22 +559,392 @@ pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {

524 559
            let res = client.execute_request(&api.method, &api.path, None).await.map_err(|e| e.to_string())?;
525 560
            println!("{}", serde_json::to_string_pretty(&res)?);
526 561
        }
527
        Commands::Trace(trace) => match trace.action {
528
            TraceAction::List => {
529
                for s in crate::trace::TraceStore::scan_foreign_sessions() {
530
                    println!("{}\t{}\t({} steps)", s.session_id, s.agent_name, s.step_count);
531
                }
532
            }
533
            TraceAction::Show { id } => println!("Viewing trace session {}", id),
534
            TraceAction::Redact { file } => {
535
                let content = std::fs::read_to_string(&file).unwrap_or_default();
536
                let sanitized = crate::trace::TraceStore::redact_trace(&content);
537
                println!("Redacted size: {} bytes", sanitized.len());
538
            }
539
        },
562
        Commands::Trace(trace) => run_trace(trace.action),
540 563
        Commands::Update(update) => {
541 564
            crate::update::run(update.channel, update.version, update.check, update.force).await?;
542 565
        }
543 566
    }
544 567
    Ok(())
545 568
}
569
570
/// Print a refusal on stderr and exit non-zero.
571
///
572
/// Exit code 2 is what the TypeScript CLI returns for an input or configuration
573
/// error, and the point of this whole path: a command that cannot reach its data
574
/// says so and exits non-zero rather than returning something plausible.
575
fn fail(message: &str) -> ! {
576
    eprintln!("oa: {}", message);
577
    std::process::exit(2)
578
}
579
580
/// The first eight characters of a UUID, which is how the TypeScript CLI renders
581
/// topic ids in a listing.
582
fn short_id(id: &str) -> &str {
583
    &id[..id.len().min(8)]
584
}
585
586
fn home_directory() -> std::path::PathBuf {
587
    std::path::PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".to_string()))
588
}
589
590
// ---------------------------------------------------------------------------
591
// identity
592
// ---------------------------------------------------------------------------
593
594
/// The public identity block. Public identifiers only: no seed phrase, no `nsec`,
595
/// and no private key reaches this function.
596
fn print_identity(identity: &crate::identity::SeedIdentity, json: bool) {
597
    if json {
598
        let value = serde_json::json!({
599
            "schema": "openagents.cli_identity.v1",
600
            "profile": identity.profile,
601
            "npub": identity.npub,
602
            "nostr_public_key": identity.nostr_public_key_hex,
603
            "nostr_derivation_path": identity.nostr_derivation_path,
604
            "wallet_address": identity.wallet_address,
605
            "wallet_public_key": identity.wallet_public_key_hex,
606
            "wallet_fingerprint": identity.wallet_fingerprint_hex,
607
            "wallet_derivation_path": identity.wallet_derivation_path,
608
            "spending_rail": serde_json::Value::Null,
609
        });
610
        println!("{}", value);
611
        return;
612
    }
613
    println!("Identity: {}", identity.npub);
614
    println!("  public key   {}", identity.nostr_public_key_hex);
615
    println!("  path         {}", identity.nostr_derivation_path);
616
    println!("Wallet:   {}", identity.wallet_address);
617
    println!("  public key   {}", identity.wallet_public_key_hex);
618
    println!("  fingerprint  {}", identity.wallet_fingerprint_hex);
619
    println!("  path         {}", identity.wallet_derivation_path);
620
    println!("Profile:  {}", identity.profile);
621
}
622
623
fn run_identity(action: IdentityAction, json: bool) {
624
    use crate::identity::{
625
        derive_seed_identity, generate_seed_phrase, is_valid_seed_phrase, SeedStore,
626
    };
627
    let store = SeedStore::new(None);
628
    let seed_path = store.path();
629
630
    let refuse_if_seed_exists = |force: bool| {
631
        if store.present() && !force {
632
            fail(&format!(
633
                "A seed is already stored at {}. Back it up with `oa identity backup` first, \
634
                 then pass --force to replace it.",
635
                seed_path.display()
636
            ));
637
        }
638
    };
639
640
    match action {
641
        IdentityAction::Show => {
642
            let identity = store.identity().unwrap_or_else(|e| fail(&e.to_string()));
643
            print_identity(&identity, json);
644
        }
645
        IdentityAction::Create { words, force } => {
646
            if words != 12 && words != 24 {
647
                fail("--words must be 12 or 24.");
648
            }
649
            refuse_if_seed_exists(force);
650
651
            let phrase = generate_seed_phrase(words).unwrap_or_else(|e| fail(&e.to_string()));
652
            // Derive before storing: a phrase that cannot be derived from must not
653
            // become the identity on this machine.
654
            let identity = derive_seed_identity(&phrase).unwrap_or_else(|e| fail(&e.to_string()));
655
            store
656
                .write_phrase(&phrase)
657
                .unwrap_or_else(|e| fail(&format!(
658
                    "The new seed could not be stored at {}: {}",
659
                    seed_path.display(),
660
                    e
661
                )));
662
663
            if !json {
664
                println!(
665
                    "Wrote a new {}-word seed to {} (mode 0600).",
666
                    words,
667
                    seed_path.display()
668
                );
669
                println!(
670
                    "Back it up now with `oa identity backup`. Nothing else on this machine \
671
                     can recover it."
672
                );
673
            }
674
            print_identity(&identity, json);
675
        }
676
        IdentityAction::Import { force } => {
677
            refuse_if_seed_exists(force);
678
679
            let mut phrase = String::new();
680
            if std::io::Read::read_to_string(&mut std::io::stdin(), &mut phrase).is_err() {
681
                fail("No seed phrase was provided on standard input.");
682
            }
683
            let phrase = phrase.trim().to_string();
684
            if phrase.is_empty() {
685
                fail("No seed phrase was provided on standard input.");
686
            }
687
            // The phrase is never echoed back, not even the part that parsed.
688
            if !is_valid_seed_phrase(&phrase) {
689
                fail(
690
                    "That is not a valid English BIP-39 seed phrase. Check the word count \
691
                     (12, 15, 18, 21, or 24) and the spelling of each word.",
692
                );
693
            }
694
            let identity = derive_seed_identity(&phrase).unwrap_or_else(|e| fail(&e.to_string()));
695
            store.write_phrase(&phrase).unwrap_or_else(|e| {
696
                fail(&format!(
697
                    "The seed could not be stored at {}: {}",
698
                    seed_path.display(),
699
                    e
700
                ))
701
            });
702
703
            if !json {
704
                println!("Stored the seed at {} (mode 0600).", seed_path.display());
705
            }
706
            print_identity(&identity, json);
707
        }
708
        IdentityAction::Backup => {
709
            // The one command that prints the secret, and the one that refuses
710
            // --json: the phrase must not land in machine-collected output.
711
            if json {
712
                fail(
713
                    "`oa identity backup` does not support --json. The seed phrase must not \
714
                     land in machine-collected output; run it without --json and copy the \
715
                     phrase yourself.",
716
                );
717
            }
718
            let phrase = match store.read_phrase() {
719
                Ok(Some(phrase)) => phrase,
720
                Ok(None) => fail(&crate::identity::IdentityError::NoSeed.to_string()),
721
                Err(e) => fail(&e.to_string()),
722
            };
723
            println!(
724
                "This is the only secret on this machine. Anyone holding it holds the \
725
                 identity and the wallet."
726
            );
727
            println!("{}", phrase);
728
        }
729
        IdentityAction::Forget { force } => {
730
            if !force {
731
                fail(&format!(
732
                    "Deleting {} destroys the identity and the wallet it derives. Back the \
733
                     phrase up with `oa identity backup`, then pass --force.",
734
                    seed_path.display()
735
                ));
736
            }
737
            let removed = store.forget().unwrap_or_else(|e| {
738
                fail(&format!(
739
                    "The seed at {} could not be removed: {}",
740
                    seed_path.display(),
741
                    e
742
                ))
743
            });
744
            if json {
745
                println!(
746
                    "{}",
747
                    serde_json::json!({
748
                        "schema": "openagents.cli_identity_forget.v1",
749
                        "removed": removed,
750
                        "seed_path": seed_path,
751
                    })
752
                );
753
            } else if removed {
754
                println!("Removed {}.", seed_path.display());
755
            } else {
756
                println!("No seed was stored at {}.", seed_path.display());
757
            }
758
        }
759
    }
760
}
761
762
// ---------------------------------------------------------------------------
763
// trace
764
// ---------------------------------------------------------------------------
765
766
fn run_trace(action: TraceAction) {
767
    use crate::trace;
768
    let home = home_directory();
769
770
    match action {
771
        TraceAction::List { path, limit } => {
772
            if limit == 0 {
773
                fail("--limit must be greater than zero.");
774
            }
775
            let specs: Vec<trace::TraceStoreSpec> = if path.is_empty() {
776
                let mut specs = trace::default_trace_stores(&home);
777
                specs.extend(trace::extra_path_stores());
778
                specs
779
            } else {
780
                path.iter()
781
                    .map(|entry| trace::path_trace_store(std::path::PathBuf::from(entry)))
782
                    .collect()
783
            };
784
            let bounds = trace::DiscoveryBounds {
785
                max_files_per_store: limit,
786
                ..Default::default()
787
            };
788
            let (scans, candidates) = trace::discover(&specs, bounds);
789
790
            for scan in &scans {
791
                if !scan.present {
792
                    println!("{}: {} (not present)", scan.kind.as_str(), scan.root.display());
793
                    continue;
794
                }
795
                let mut line = format!(
796
                    "{}: {} ({} matched, {} listed",
797
                    scan.kind.as_str(),
798
                    scan.root.display(),
799
                    scan.matched,
800
                    scan.listed
801
                );
802
                if scan.skipped_symlinks > 0 {
803
                    line.push_str(&format!(", {} symlinks skipped", scan.skipped_symlinks));
804
                }
805
                if scan.truncated {
806
                    line.push_str(", scan truncated at its entry budget");
807
                }
808
                line.push(')');
809
                println!("{}", line);
810
            }
811
812
            if candidates.is_empty() {
813
                println!("No trace files found.");
814
            }
815
            for candidate in candidates {
816
                println!(
817
                    "{}  {}  {}B  {}",
818
                    candidate.kind.as_str(),
819
                    candidate.modified_at,
820
                    candidate.bytes,
821
                    candidate.path.display()
822
                );
823
            }
824
        }
825
        TraceAction::Show { trace: argument } => {
826
            // An argument that resolves to nothing is refused. The version this
827
            // replaces printed "Viewing trace session <id>" for any id at all.
828
            let path = trace::resolve_trace_argument(&argument, &home)
829
                .unwrap_or_else(|message| fail(&message));
830
            let summary = trace::summarize_trace_file(&path).unwrap_or_else(|e| {
831
                fail(&format!(
832
                    "The trace file at {} could not be read: {}",
833
                    path.display(),
834
                    e
835
                ))
836
            });
837
838
            println!("File: {}", summary.path.display());
839
            if summary.format != "atif" {
840
                let described = if summary.format == "jsonl" {
841
                    "line-delimited session log (not ATIF)"
842
                } else {
843
                    "unknown"
844
                };
845
                println!("Format: {}", described);
846
                println!("Size: {} bytes", summary.bytes);
847
                if let Some(lines) = summary.lines {
848
                    println!("Lines: {}", lines);
849
                }
850
                println!("This slice summarizes ATIF documents only; foreign logs get metadata.");
851
                return;
852
            }
853
854
            println!(
855
                "Schema: {}",
856
                summary.schema_version.as_deref().unwrap_or("(missing schema_version)")
857
            );
858
            if let Some(session) = &summary.session_id {
859
                println!("Session: {}", session);
860
            }
861
            if summary.agent_name.is_some() || summary.agent_model.is_some() {
862
                println!(
863
                    "Agent: {} ({})",
864
                    summary.agent_name.as_deref().unwrap_or("unknown"),
865
                    summary.agent_model.as_deref().unwrap_or("unknown model")
866
                );
867
            }
868
            let sources = summary
869
                .steps_by_source
870
                .as_ref()
871
                .map(|by_source| {
872
                    by_source
873
                        .iter()
874
                        .map(|(source, count)| format!("{} {}", source, count))
875
                        .collect::<Vec<_>>()
876
                        .join(", ")
877
                })
878
                .unwrap_or_default();
879
            println!("Steps: {} ({})", summary.steps.unwrap_or(0), sources);
880
            let models = summary.models.unwrap_or_default();
881
            println!(
882
                "Models: {}",
883
                if models.is_empty() {
884
                    "(none recorded)".to_string()
885
                } else {
886
                    models.join(", ")
887
                }
888
            );
889
            println!("Tool calls: {}", summary.tool_calls.unwrap_or(0));
890
            match (summary.total_prompt_tokens, summary.total_completion_tokens) {
891
                (None, None) => println!("Tokens: not recorded"),
892
                (prompt, completion) => println!(
893
                    "Tokens: {} prompt, {} completion",
894
                    prompt.unwrap_or(0),
895
                    completion.unwrap_or(0)
896
                ),
897
            }
898
            if let (Some(first), Some(last)) = (&summary.first_timestamp, &summary.last_timestamp) {
899
                println!("Span: {} to {}", first, last);
900
            }
901
        }
902
        TraceAction::Redact { trace: argument, file } => {
903
            let argument = match argument.or(file) {
904
                Some(argument) => argument,
905
                None => fail("Name the trace file to redact."),
906
            };
907
            let path = trace::resolve_trace_argument(&argument, &home)
908
                .unwrap_or_else(|message| fail(&message));
909
            if trace::is_redacted_copy(&path) {
910
                fail(&format!(
911
                    "{} is already a redacted copy; redact the original instead.",
912
                    path.display()
913
                ));
914
            }
915
916
            let home_text = home.to_string_lossy().into_owned();
917
            let result = trace::redact_trace_file(&path, &home_text).unwrap_or_else(|e| {
918
                fail(&format!(
919
                    "The trace file at {} could not be redacted: {}",
920
                    path.display(),
921
                    e
922
                ))
923
            });
924
925
            println!("Wrote {}", result.output.display());
926
            if result.total == 0 {
927
                println!("Nothing matched the redaction rules.");
928
            } else {
929
                // Counts per category, never the matched text.
930
                let detail = result
931
                    .counts
932
                    .iter()
933
                    .map(|(category, count)| format!("{} {}", category, count))
934
                    .collect::<Vec<_>>()
935
                    .join(", ");
936
                println!(
937
                    "Redacted {} match{}: {}",
938
                    result.total,
939
                    if result.total == 1 { "" } else { "es" },
940
                    detail
941
                );
942
            }
943
            if result.valid_json == Some(false) {
944
                println!(
945
                    "Warning: the redacted copy no longer parses as JSON; review it before sharing."
946
                );
947
            }
948
        }
949
    }
950
}
crates/openagents-cli/src/forum.rs modified +164 -58

@@ -1,25 +1,73 @@

1
//! Forum board browsing, topics, claims and NIP-29 chat integration
2
//! Real client communicating with `/api/v1/forum` routes
1
//! Forum board browsing and topic listing.
2
//!
3
//! The routes are the ones `packages/openagents-cli/src/forum-client.ts` calls:
4
//! `GET /api/v1/forum` for boards and `GET /api/v1/forum/topics?forum=<slug>` for a
5
//! board's topics. An earlier version of this module called `/api/v1/forum/boards`,
6
//! which does not exist, and answered the resulting non-2xx with a hardcoded pair of
7
//! boards — inventing a `dev` board the server has never served. Nothing here
8
//! substitutes a value the server did not send: a refusal is returned as
9
//! [`ForumError`] and the command exits non-zero.
3 10
4 11
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
5 12
use serde::{Deserialize, Serialize};
13
use std::fmt;
6 14
7
#[derive(Debug, Clone, Serialize, Deserialize)]
15
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
8 16
pub struct ForumBoard {
17
    /// The board's stable UUID.
9 18
    pub id: String,
10
    pub name: String,
19
    /// The URL-safe short name, and what `oa forum topics --board` takes.
20
    pub slug: String,
21
    /// The board's display title.
22
    pub title: String,
11 23
    pub description: String,
24
    pub topic_count: u64,
25
    pub post_count: u64,
12 26
}
13 27
14
#[derive(Debug, Clone, Serialize, Deserialize)]
28
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15 29
pub struct ForumTopic {
16 30
    pub id: String,
17
    pub board_id: String,
31
    pub slug: String,
18 32
    pub title: String,
19
    pub author_npub: Option<String>,
33
    pub state: String,
34
    /// The author's display name as the server rendered it, when it sent one.
35
    pub author: Option<String>,
20 36
    pub created_at: Option<String>,
37
    pub updated_at: Option<String>,
38
    pub posts_count: u64,
39
}
40
41
/// Why a forum read did not produce data. Never a substitute for data.
42
#[derive(Debug)]
43
pub enum ForumError {
44
    /// The request never completed.
45
    Transport(String),
46
    /// The server answered, and refused. Carries the status and its message.
47
    Refused { status: u16, body: String },
48
    /// The server answered 2xx with a body this client cannot read.
49
    Malformed(String),
50
}
51
52
impl fmt::Display for ForumError {
53
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54
        match self {
55
            Self::Transport(why) => write!(f, "Could not reach the forum API: {}", why),
56
            Self::Refused { status, body } => {
57
                write!(f, "The forum API refused the request (HTTP {})", status)?;
58
                let trimmed = body.trim();
59
                if !trimmed.is_empty() {
60
                    write!(f, ": {}", &trimmed[..trimmed.len().min(400)])?;
61
                }
62
                Ok(())
63
            }
64
            Self::Malformed(why) => write!(f, "The forum API returned an unreadable body: {}", why),
65
        }
66
    }
21 67
}
22 68
69
impl std::error::Error for ForumError {}
70
23 71
pub struct ForumClient {
24 72
    pub api_base: String,
25 73
    pub token: Option<String>,

@@ -46,61 +94,119 @@ impl ForumClient {

46 94
        map
47 95
    }
48 96
49
    pub async fn list_boards(&self) -> Result<Vec<ForumBoard>, Box<dyn std::error::Error + Send + Sync>> {
50
        let url = format!("{}/forum/boards", self.api_base);
51
        let resp = self.http.get(&url).headers(self.headers()).send().await?;
52
53
        if resp.status().is_success() {
54
            let body: serde_json::Value = resp.json().await?;
55
            let items = body.get("boards").and_then(|v| v.as_array()).cloned().unwrap_or_default();
56
            let mut boards = Vec::new();
57
            for item in items {
58
                let id = item.get("id").or_else(|| item.get("slug")).and_then(|v| v.as_str()).unwrap_or("").to_string();
59
                let name = item.get("name").or_else(|| item.get("title")).and_then(|v| v.as_str()).unwrap_or("").to_string();
60
                let description = item.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string();
61
                boards.push(ForumBoard { id, name, description });
62
            }
63
            Ok(boards)
64
        } else {
65
            Ok(vec![
66
                ForumBoard {
67
                    id: "general".to_string(),
68
                    name: "General".to_string(),
69
                    description: "OpenAgents community discussions".to_string(),
70
                },
71
                ForumBoard {
72
                    id: "dev".to_string(),
73
                    name: "Development".to_string(),
74
                    description: "Technical discussions and forge updates".to_string(),
75
                },
76
            ])
97
    /// `GET` a forum route and return its parsed body, or the server's refusal.
98
    async fn get_json(&self, path: &str) -> Result<serde_json::Value, ForumError> {
99
        let url = format!("{}/{}", self.api_base, path);
100
        let resp = self
101
            .http
102
            .get(&url)
103
            .headers(self.headers())
104
            .send()
105
            .await
106
            .map_err(|e| ForumError::Transport(e.to_string()))?;
107
108
        let status = resp.status();
109
        let body = resp
110
            .text()
111
            .await
112
            .map_err(|e| ForumError::Transport(e.to_string()))?;
113
114
        if !status.is_success() {
115
            return Err(ForumError::Refused {
116
                status: status.as_u16(),
117
                body,
118
            });
77 119
        }
120
        serde_json::from_str(&body).map_err(|e| ForumError::Malformed(e.to_string()))
121
    }
122
123
    pub async fn list_boards(&self) -> Result<Vec<ForumBoard>, ForumError> {
124
        let body = self.get_json("forum").await?;
125
        let items = body
126
            .get("boards")
127
            .and_then(|v| v.as_array())
128
            .ok_or_else(|| ForumError::Malformed("no `boards` array in the response".into()))?;
129
130
        Ok(items
131
            .iter()
132
            .map(|item| ForumBoard {
133
                id: string_field(item, "id"),
134
                slug: string_field(item, "slug"),
135
                title: string_field(item, "title"),
136
                description: string_field(item, "description"),
137
                topic_count: number_field(item, "topic_count"),
138
                post_count: number_field(item, "post_count"),
139
            })
140
            .collect())
78 141
    }
79 142
80
    pub async fn list_topics(&self, board_id: &str) -> Result<Vec<ForumTopic>, Box<dyn std::error::Error + Send + Sync>> {
81
        let url = format!("{}/forum/boards/{}/topics", self.api_base, board_id);
82
        let resp = self.http.get(&url).headers(self.headers()).send().await?;
83
84
        if resp.status().is_success() {
85
            let body: serde_json::Value = resp.json().await?;
86
            let items = body.get("topics").and_then(|v| v.as_array()).cloned().unwrap_or_default();
87
            let mut topics = Vec::new();
88
            for item in items {
89
                let id = item.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
90
                let title = item.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string();
91
                let author_npub = item.get("author_npub").and_then(|v| v.as_str()).map(String::from);
92
                let created_at = item.get("created_at").and_then(|v| v.as_str()).map(String::from);
93
                topics.push(ForumTopic {
94
                    id,
95
                    board_id: board_id.to_string(),
96
                    title,
97
                    author_npub,
98
                    created_at,
99
                });
143
    /// List a board's topics. `board` is a slug, as `list_boards` reports it.
144
    pub async fn list_topics(&self, board: &str) -> Result<Vec<ForumTopic>, ForumError> {
145
        let body = self
146
            .get_json(&format!("forum/topics?forum={}", urlencode(board)))
147
            .await?;
148
        let items = body
149
            .get("topics")
150
            .and_then(|v| v.as_array())
151
            .ok_or_else(|| ForumError::Malformed("no `topics` array in the response".into()))?;
152
153
        Ok(items.iter().map(parse_topic).collect())
154
    }
155
156
    /// Search topics across boards.
157
    pub async fn search_topics(&self, query: &str) -> Result<Vec<ForumTopic>, ForumError> {
158
        let body = self
159
            .get_json(&format!("forum/topics?q={}", urlencode(query)))
160
            .await?;
161
        let items = body
162
            .get("topics")
163
            .and_then(|v| v.as_array())
164
            .ok_or_else(|| ForumError::Malformed("no `topics` array in the response".into()))?;
165
166
        Ok(items.iter().map(parse_topic).collect())
167
    }
168
}
169
170
fn parse_topic(item: &serde_json::Value) -> ForumTopic {
171
    ForumTopic {
172
        id: string_field(item, "id"),
173
        slug: string_field(item, "slug"),
174
        title: string_field(item, "title"),
175
        state: string_field(item, "state"),
176
        author: item
177
            .get("author")
178
            .and_then(|a| a.get("display_name"))
179
            .and_then(|v| v.as_str())
180
            .map(String::from),
181
        created_at: item.get("created_at").and_then(|v| v.as_str()).map(String::from),
182
        updated_at: item.get("updated_at").and_then(|v| v.as_str()).map(String::from),
183
        posts_count: number_field(item, "posts_count"),
184
    }
185
}
186
187
/// Read a string field, or the empty string when the server omitted it. The empty
188
/// string is what the server sent — it is not a stand-in for a value it withheld.
189
fn string_field(item: &serde_json::Value, key: &str) -> String {
190
    item.get(key)
191
        .and_then(|v| v.as_str())
192
        .unwrap_or_default()
193
        .to_string()
194
}
195
196
fn number_field(item: &serde_json::Value, key: &str) -> u64 {
197
    item.get(key).and_then(|v| v.as_u64()).unwrap_or(0)
198
}
199
200
/// Percent-encode a query-string value. Only unreserved characters pass through.
201
fn urlencode(value: &str) -> String {
202
    let mut out = String::with_capacity(value.len());
203
    for byte in value.as_bytes() {
204
        match byte {
205
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
206
                out.push(*byte as char)
100 207
            }
101
            Ok(topics)
102
        } else {
103
            Ok(Vec::new())
208
            _ => out.push_str(&format!("%{:02X}", byte)),
104 209
        }
105 210
    }
211
    out
106 212
}
crates/openagents-cli/src/identity.rs modified +257 -42

@@ -1,68 +1,283 @@

1
//! Cryptographic identity generation, seed derivation, and identity management
1
//! Cryptographic identity: one BIP-39 seed, one Nostr identity, one wallet branch.
2
//!
3
//! This is the Rust port of `packages/openagents-cli/src/seed-identity.ts`, and the
4
//! TypeScript module is the contract. The derivation profile
5
//! (`openagents.legacy_unified_nostr_spark.v1`), the NIP-06 path, the wallet path,
6
//! the English word list, and the empty BIP-39 passphrase all come from there. An
7
//! identity that differs between the two CLIs is a different account, so
8
//! `tests/identity_test.rs` pins the derivation against shared vectors: change the
9
//! derivation and the build fails rather than silently reissuing every `npub`.
10
//!
11
//! SECRETS. The mnemonic is returned from exactly one function,
12
//! [`SeedStore::read_phrase`], and derived from in memory. [`SeedIdentity`] carries
13
//! public identifiers only and is safe to print. No `nsec` and no private key is
14
//! ever written to disk or returned by `show`; the seed file is `0600` inside a
15
//! `0700` directory.
2 16
17
use bech32::{Bech32, Hrp};
18
use bip32::{DerivationPath, XPrv};
19
use bip39::{Language, Mnemonic};
20
use ripemd::Ripemd160;
3 21
use serde::{Deserialize, Serialize};
4 22
use sha2::{Digest, Sha256};
5
use std::collections::HashMap;
23
use std::fmt;
6 24
use std::fs;
7 25
use std::path::PathBuf;
26
use std::str::FromStr;
8 27
9
#[derive(Debug, Clone, Serialize, Deserialize)]
10
pub struct IdentityRecord {
11
    pub name: String,
28
/// The frozen shared-root profile both the CLI and Pylon derive under.
29
pub const DERIVATION_PROFILE_ID: &str = "openagents.legacy_unified_nostr_spark.v1";
30
31
/// Nostr identity path: NIP-06 account zero.
32
pub const NOSTR_DERIVATION_PATH: &str = "m/44'/1237'/0'/0/0";
33
34
/// Wallet path: BIP-44 Bitcoin account zero, first external key.
35
pub const WALLET_DERIVATION_PATH: &str = "m/44'/0'/0'/0/0";
36
37
/// The frozen BIP-39 passphrase. It is empty, and a non-empty one produces a
38
/// different identity, so it is a constant here rather than an option.
39
const BIP39_PASSPHRASE: &str = "";
40
41
/// Mainnet pay-to-public-key-hash version byte, the standard BIP-44 pairing.
42
const P2PKH_VERSION: u8 = 0x00;
43
44
/// Every way identity work fails. No variant carries secret material.
45
#[derive(Debug)]
46
pub enum IdentityError {
47
    /// The phrase is not a valid English BIP-39 mnemonic. Never quotes the phrase.
48
    InvalidPhrase,
49
    /// No seed is stored, and the command needs one.
50
    NoSeed,
51
    /// A seed is already stored and the command would have overwritten it.
52
    SeedExists(PathBuf),
53
    /// Key derivation failed underneath us.
54
    Derivation(String),
55
    Io(std::io::Error),
56
}
57
58
impl fmt::Display for IdentityError {
59
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60
        match self {
61
            Self::InvalidPhrase => {
62
                write!(f, "The seed phrase is not a valid English BIP-39 mnemonic.")
63
            }
64
            Self::NoSeed => write!(
65
                f,
66
                "No seed is stored. Run `oa identity create` to make one, or \
67
                 `oa identity import` to restore an existing seed phrase."
68
            ),
69
            Self::SeedExists(path) => write!(
70
                f,
71
                "A seed is already stored at {}. Run `oa identity forget --force` first \
72
                 if you mean to replace it.",
73
                path.display()
74
            ),
75
            Self::Derivation(why) => write!(f, "Key derivation failed: {}", why),
76
            Self::Io(err) => write!(f, "{}", err),
77
        }
78
    }
79
}
80
81
impl std::error::Error for IdentityError {}
82
83
impl From<std::io::Error> for IdentityError {
84
    fn from(err: std::io::Error) -> Self {
85
        Self::Io(err)
86
    }
87
}
88
89
/// The public half of one seed. Nothing here can spend, sign, or reconstruct the
90
/// seed, so every field is safe to print, store, and export.
91
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92
pub struct SeedIdentity {
93
    /// The frozen derivation profile these identifiers were produced under.
94
    pub profile: String,
95
    /// The NIP-19 `npub`, and the one cross-surface name for this identity.
12 96
    pub npub: String,
13
    pub nsec: String,
14
    pub created_at: u64,
97
    /// The x-only 32-byte Nostr public key as hex.
98
    pub nostr_public_key_hex: String,
99
    pub nostr_derivation_path: String,
100
    /// The compressed 33-byte wallet public key as hex.
101
    pub wallet_public_key_hex: String,
102
    /// The BIP-32 key fingerprint, `HASH160(pubkey)[0..4]`, as hex.
103
    pub wallet_fingerprint_hex: String,
104
    /// The mainnet P2PKH receive address for the wallet path.
105
    pub wallet_address: String,
106
    pub wallet_derivation_path: String,
107
}
108
109
fn to_hex(bytes: &[u8]) -> String {
110
    bytes.iter().map(|b| format!("{:02x}", b)).collect()
111
}
112
113
/// Trim and collapse whitespace without changing the words themselves.
114
pub fn normalize_phrase(phrase: &str) -> String {
115
    phrase.split_whitespace().collect::<Vec<_>>().join(" ")
15 116
}
16 117
17
pub struct IdentityStore {
18
    store_path: PathBuf,
118
/// True when the phrase is a valid English BIP-39 mnemonic with a good checksum.
119
pub fn is_valid_seed_phrase(phrase: &str) -> bool {
120
    Mnemonic::parse_in_normalized(Language::English, &normalize_phrase(phrase)).is_ok()
19 121
}
20 122
21
impl IdentityStore {
22
    pub fn default_path() -> PathBuf {
23
        let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
24
        PathBuf::from(home).join(".openagents").join("identities.json")
123
/// Generate a fresh mnemonic from OS entropy. 12 words is 128 bits, 24 words is 256.
124
///
125
/// The entropy comes from the operating system through `bip39`'s `rand` feature, so
126
/// two runs — on one machine or two — produce different phrases. There is no seed
127
/// constant in this file, and a test asserts successive calls differ.
128
pub fn generate_seed_phrase(words: usize) -> Result<String, IdentityError> {
129
    let count = if words == 24 { 24 } else { 12 };
130
    Mnemonic::generate_in(Language::English, count)
131
        .map(|m| m.to_string())
132
        .map_err(|e| IdentityError::Derivation(e.to_string()))
133
}
134
135
/// Derive the public identity and wallet from one mnemonic.
136
///
137
/// Deterministic and side-effect free: the same phrase always yields the same `npub`
138
/// and the same wallet address, on every machine and every version. Refuses a phrase
139
/// that is not valid BIP-39 English, because deriving from a mistyped phrase would
140
/// hand back a plausible identity nobody can recover.
141
pub fn derive_seed_identity(phrase: &str) -> Result<SeedIdentity, IdentityError> {
142
    let normalized = normalize_phrase(phrase);
143
    let mnemonic = Mnemonic::parse_in_normalized(Language::English, &normalized)
144
        .map_err(|_| IdentityError::InvalidPhrase)?;
145
    let seed = mnemonic.to_seed_normalized(BIP39_PASSPHRASE);
146
147
    let nostr_public_key = derive_public_key(&seed, NOSTR_DERIVATION_PATH)?;
148
    // NIP-06 keys are x-only: drop the compressed-form parity byte.
149
    let nostr_x_only = &nostr_public_key[1..];
150
151
    let wallet_public_key = derive_public_key(&seed, WALLET_DERIVATION_PATH)?;
152
    let wallet_hash160 = hash160(&wallet_public_key);
153
154
    let mut address_payload = [0u8; 21];
155
    address_payload[0] = P2PKH_VERSION;
156
    address_payload[1..].copy_from_slice(&wallet_hash160);
157
158
    let hrp = Hrp::parse("npub").map_err(|e| IdentityError::Derivation(e.to_string()))?;
159
    let npub = bech32::encode::<Bech32>(hrp, nostr_x_only)
160
        .map_err(|e| IdentityError::Derivation(e.to_string()))?;
161
162
    Ok(SeedIdentity {
163
        profile: DERIVATION_PROFILE_ID.to_string(),
164
        npub,
165
        nostr_public_key_hex: to_hex(nostr_x_only),
166
        nostr_derivation_path: NOSTR_DERIVATION_PATH.to_string(),
167
        wallet_public_key_hex: to_hex(&wallet_public_key),
168
        wallet_fingerprint_hex: to_hex(&wallet_hash160[..4]),
169
        wallet_address: bs58::encode(address_payload).with_check().into_string(),
170
        wallet_derivation_path: WALLET_DERIVATION_PATH.to_string(),
171
    })
172
}
173
174
/// Derive one compressed 33-byte secp256k1 public key at `path` from a BIP-32 seed.
175
fn derive_public_key(seed: &[u8; 64], path: &str) -> Result<[u8; 33], IdentityError> {
176
    let parsed =
177
        DerivationPath::from_str(path).map_err(|e| IdentityError::Derivation(e.to_string()))?;
178
    let xprv = XPrv::derive_from_path(seed, &parsed)
179
        .map_err(|e| IdentityError::Derivation(e.to_string()))?;
180
    Ok(xprv.public_key().to_bytes())
181
}
182
183
/// `RIPEMD160(SHA256(bytes))`, the standard Bitcoin HASH160.
184
fn hash160(bytes: &[u8]) -> [u8; 20] {
185
    let sha = Sha256::digest(bytes);
186
    let mut out = [0u8; 20];
187
    out.copy_from_slice(&Ripemd160::digest(sha));
188
    out
189
}
190
191
/// Where the seed lives on disk, and the only thing that touches it.
192
pub struct SeedStore {
193
    directory: PathBuf,
194
}
195
196
impl SeedStore {
197
    /// `OPENAGENTS_IDENTITY_DIR` moves the store, which is how tests get an isolated
198
    /// identity without touching the developer's own. Matches the TypeScript CLI so
199
    /// both read the same seed.
200
    pub fn default_directory() -> PathBuf {
201
        match std::env::var("OPENAGENTS_IDENTITY_DIR") {
202
            Ok(dir) if !dir.trim().is_empty() => PathBuf::from(dir),
203
            _ => {
204
                let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
205
                PathBuf::from(home).join(".openagents").join("identity")
206
            }
207
        }
25 208
    }
26 209
27
    pub fn new(path: Option<PathBuf>) -> Self {
210
    pub fn new(directory: Option<PathBuf>) -> Self {
28 211
        Self {
29
            store_path: path.unwrap_or_else(Self::default_path),
212
            directory: directory.unwrap_or_else(Self::default_directory),
213
        }
214
    }
215
216
    /// The seed file itself: one line, the mnemonic, mode `0600`.
217
    pub fn path(&self) -> PathBuf {
218
        self.directory.join("seed")
219
    }
220
221
    /// True when a seed is already stored. Presence only; the bytes stay on disk.
222
    pub fn present(&self) -> bool {
223
        self.path().is_file()
224
    }
225
226
    /// Read the stored mnemonic. This is the only function that returns secret
227
    /// material, and every caller either derives from it or hands it to the reader
228
    /// who asked for a backup.
229
    pub fn read_phrase(&self) -> Result<Option<String>, IdentityError> {
230
        let path = self.path();
231
        if !path.is_file() {
232
            return Ok(None);
30 233
        }
234
        let phrase = normalize_phrase(&fs::read_to_string(&path)?);
235
        Ok(if phrase.is_empty() { None } else { Some(phrase) })
31 236
    }
32 237
33
    pub fn load(&self) -> Result<HashMap<String, IdentityRecord>, Box<dyn std::error::Error>> {
34
        if !self.store_path.exists() {
35
            return Ok(HashMap::new());
238
    /// Write the mnemonic, `0600` inside a `0700` directory, after validating it.
239
    /// The validation is not politeness: a phrase stored here that does not validate
240
    /// would be an identity nobody can recover from its own backup.
241
    pub fn write_phrase(&self, phrase: &str) -> Result<PathBuf, IdentityError> {
242
        let normalized = normalize_phrase(phrase);
243
        if !is_valid_seed_phrase(&normalized) {
244
            return Err(IdentityError::InvalidPhrase);
36 245
        }
37
        let data = fs::read_to_string(&self.store_path)?;
38
        let map: HashMap<String, IdentityRecord> = serde_json::from_str(&data)?;
39
        Ok(map)
246
        fs::create_dir_all(&self.directory)?;
247
        Self::set_mode(&self.directory, 0o700)?;
248
        let path = self.path();
249
        fs::write(&path, format!("{}\n", normalized))?;
250
        Self::set_mode(&path, 0o600)?;
251
        Ok(path)
40 252
    }
41 253
42
    pub fn save(&self, records: &HashMap<String, IdentityRecord>) -> Result<(), Box<dyn std::error::Error>> {
43
        if let Some(parent) = self.store_path.parent() {
44
            fs::create_dir_all(parent)?;
254
    /// Remove the stored seed. Idempotent, and it deletes nothing else.
255
    pub fn forget(&self) -> Result<bool, IdentityError> {
256
        let path = self.path();
257
        if !path.exists() {
258
            return Ok(false);
45 259
        }
46
        let data = serde_json::to_string_pretty(records)?;
47
        fs::write(&self.store_path, data)?;
48
        Ok(())
260
        fs::remove_file(&path)?;
261
        Ok(true)
49 262
    }
50 263
51
    pub fn generate_identity(name: &str, seed_phrase: Option<&str>) -> IdentityRecord {
52
        let seed = seed_phrase.unwrap_or("openagents-entropy-seed-phrase");
53
        let mut hasher = Sha256::new();
54
        hasher.update(seed.as_bytes());
55
        hasher.update(name.as_bytes());
56
        let digest = format!("{:x}", hasher.finalize());
57
58
        let npub = format!("npub1{}", &digest[..32]);
59
        let nsec = format!("nsec1{}", &digest[32..]);
60
61
        IdentityRecord {
62
            name: name.to_string(),
63
            npub,
64
            nsec,
65
            created_at: 1724600000,
264
    /// Derive the public identity from the stored seed, or say there is none.
265
    pub fn identity(&self) -> Result<SeedIdentity, IdentityError> {
266
        match self.read_phrase()? {
267
            Some(phrase) => derive_seed_identity(&phrase),
268
            None => Err(IdentityError::NoSeed),
66 269
        }
67 270
    }
271
272
    #[cfg(unix)]
273
    fn set_mode(path: &std::path::Path, mode: u32) -> Result<(), IdentityError> {
274
        use std::os::unix::fs::PermissionsExt;
275
        fs::set_permissions(path, fs::Permissions::from_mode(mode))?;
276
        Ok(())
277
    }
278
279
    #[cfg(not(unix))]
280
    fn set_mode(_path: &std::path::Path, _mode: u32) -> Result<(), IdentityError> {
281
        Ok(())
282
    }
68 283
}
crates/openagents-cli/src/trace.rs modified +777 -29

@@ -1,43 +1,791 @@

1
//! Trace ingestion, redaction, and recording commands
1
//! Local agent-trace discovery, summary, and redaction.
2
//!
3
//! This is the Rust port of `packages/openagents-cli/src/trace-store.ts` and
4
//! `trace-command.ts`. It replaces a module that returned two hardcoded sessions
5
//! from `scan_foreign_sessions` and a `redact_trace` that swapped the *prefix* of a
6
//! secret — turning `sk-liveSECRET` into `[REDACTED_KEY]liveSECRET` — while writing
7
//! no file and reporting success. A redaction command that leaves the key in place
8
//! is worse than no command, because whoever ran it has been told the trace is safe.
9
//!
10
//! Nothing here invents a value. Discovery reports only files it actually stat'ed,
11
//! `summarize` reports only fields the document carries, and redaction reports
12
//! counts per category without ever echoing the matched text.
2 13
14
use regex::{Captures, Regex};
3 15
use serde::{Deserialize, Serialize};
16
use std::collections::{BTreeMap, BTreeSet, VecDeque};
17
use std::fs;
18
use std::path::{Path, PathBuf};
4 19
5
#[derive(Debug, Clone, Serialize, Deserialize)]
6
pub struct SessionTrace {
7
    pub session_id: String,
8
    pub agent_name: String,
9
    pub step_count: usize,
10
    pub created_at: u64,
20
/// Which store a file was found in. It is a property of the directory the file was
21
/// found under, never inferred from the file's name or contents.
22
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23
#[serde(rename_all = "snake_case")]
24
pub enum TraceSourceKind {
25
    OpenagentsExport,
26
    ClaudeSession,
27
    CodexSession,
28
    TracePath,
29
}
30
31
impl TraceSourceKind {
32
    pub fn as_str(self) -> &'static str {
33
        match self {
34
            Self::OpenagentsExport => "openagents_export",
35
            Self::ClaudeSession => "claude_session",
36
            Self::CodexSession => "codex_session",
37
            Self::TracePath => "trace_path",
38
        }
39
    }
11 40
}
12 41
13
pub struct TraceStore {
14
    pub traces: Vec<SessionTrace>,
42
#[derive(Debug, Clone)]
43
pub struct TraceStoreSpec {
44
    pub root: PathBuf,
45
    pub kind: TraceSourceKind,
46
    pub extensions: Vec<&'static str>,
15 47
}
16 48
17
impl TraceStore {
18
    pub fn new() -> Self {
19
        Self { traces: Vec::new() }
49
/// One discovered file. Its only identifier is its absolute path: discovery derives
50
/// no session id, and `trace show` resolves by path, so inventing an id here would
51
/// add a lookup that has nothing behind it.
52
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53
pub struct TraceCandidate {
54
    pub path: PathBuf,
55
    pub kind: TraceSourceKind,
56
    pub bytes: u64,
57
    pub modified_at: String,
58
}
59
60
/// What one store yielded, including what was refused and why.
61
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62
pub struct TraceStoreScan {
63
    pub root: PathBuf,
64
    pub kind: TraceSourceKind,
65
    pub present: bool,
66
    pub matched: usize,
67
    pub listed: usize,
68
    pub skipped_symlinks: usize,
69
    pub truncated: bool,
70
}
71
72
#[derive(Debug, Clone, Copy)]
73
pub struct DiscoveryBounds {
74
    pub max_depth: usize,
75
    pub max_files_per_store: usize,
76
    pub max_scan_entries: usize,
77
}
78
79
impl Default for DiscoveryBounds {
80
    fn default() -> Self {
81
        Self {
82
            max_depth: 4,
83
            max_files_per_store: 20,
84
            max_scan_entries: 5000,
85
        }
20 86
    }
87
}
21 88
22
    pub fn scan_foreign_sessions() -> Vec<SessionTrace> {
23
        vec![
24
            SessionTrace {
25
                session_id: "claude_sess_01".to_string(),
26
                agent_name: "claude-code".to_string(),
27
                step_count: 42,
28
                created_at: 1724600000,
29
            },
30
            SessionTrace {
31
                session_id: "codex_sess_01".to_string(),
32
                agent_name: "codex-cli".to_string(),
33
                step_count: 18,
34
                created_at: 1724600100,
89
/// The three stores a machine carries by default, in listing order.
90
pub fn default_trace_stores(home: &Path) -> Vec<TraceStoreSpec> {
91
    vec![
92
        TraceStoreSpec {
93
            root: home.join(".openagents").join("exports"),
94
            kind: TraceSourceKind::OpenagentsExport,
95
            extensions: vec![".json"],
96
        },
97
        TraceStoreSpec {
98
            root: home.join(".claude").join("projects"),
99
            kind: TraceSourceKind::ClaudeSession,
100
            extensions: vec![".jsonl"],
101
        },
102
        TraceStoreSpec {
103
            root: home.join(".codex").join("sessions"),
104
            kind: TraceSourceKind::CodexSession,
105
            extensions: vec![".jsonl"],
106
        },
107
    ]
108
}
109
110
/// A store the caller named, through `--path` or `OPENAGENTS_TRACE_PATHS`.
111
pub fn path_trace_store(root: PathBuf) -> TraceStoreSpec {
112
    TraceStoreSpec {
113
        root,
114
        kind: TraceSourceKind::TracePath,
115
        extensions: vec![".json", ".jsonl"],
116
    }
117
}
118
119
/// Extra stores from `OPENAGENTS_TRACE_PATHS`, a colon-separated list.
120
pub fn extra_path_stores() -> Vec<TraceStoreSpec> {
121
    std::env::var("OPENAGENTS_TRACE_PATHS")
122
        .unwrap_or_default()
123
        .split(':')
124
        .map(str::trim)
125
        .filter(|entry| !entry.is_empty())
126
        .map(|entry| path_trace_store(PathBuf::from(entry)))
127
        .collect()
128
}
129
130
fn matches_extension(name: &str, extensions: &[&str]) -> bool {
131
    extensions.iter().any(|ext| name.ends_with(ext))
132
}
133
134
/// Walk one store breadth-first within `bounds`, newest file first.
135
///
136
/// Symlinks are never followed and always counted: a symlinked root is refused
137
/// outright, and a symlinked entry is skipped, so a planted link can neither escape
138
/// the store nor spin the walk in a loop.
139
pub fn scan_store(spec: &TraceStoreSpec, bounds: DiscoveryBounds) -> (TraceStoreScan, Vec<TraceCandidate>) {
140
    let root_meta = fs::symlink_metadata(&spec.root).ok();
141
    let root_is_symlink = root_meta.as_ref().is_some_and(|m| m.file_type().is_symlink());
142
    let usable = root_meta.as_ref().is_some_and(|m| m.is_dir()) && !root_is_symlink;
143
144
    if !usable {
145
        return (
146
            TraceStoreScan {
147
                root: spec.root.clone(),
148
                kind: spec.kind,
149
                present: false,
150
                matched: 0,
151
                listed: 0,
152
                skipped_symlinks: usize::from(root_is_symlink),
153
                truncated: false,
35 154
            },
36
        ]
155
            Vec::new(),
156
        );
157
    }
158
159
    let mut found: Vec<(PathBuf, u64, std::time::SystemTime)> = Vec::new();
160
    let mut skipped_symlinks = 0usize;
161
    let mut visited = 0usize;
162
    let mut truncated = false;
163
    let mut queue: VecDeque<(PathBuf, usize)> = VecDeque::from([(spec.root.clone(), 0usize)]);
164
165
    'walk: while let Some((directory, depth)) = queue.pop_front() {
166
        let entries = match fs::read_dir(&directory) {
167
            Ok(entries) => entries,
168
            // An unreadable directory is skipped, not reported as a file.
169
            Err(_) => continue,
170
        };
171
        for entry in entries.flatten() {
172
            visited += 1;
173
            if visited >= bounds.max_scan_entries {
174
                truncated = true;
175
                break 'walk;
176
            }
177
            let path = entry.path();
178
            let Ok(meta) = fs::symlink_metadata(&path) else {
179
                continue;
180
            };
181
            if meta.file_type().is_symlink() {
182
                skipped_symlinks += 1;
183
                continue;
184
            }
185
            if meta.is_dir() {
186
                if depth < bounds.max_depth {
187
                    queue.push_back((path, depth + 1));
188
                }
189
                continue;
190
            }
191
            let name = entry.file_name().to_string_lossy().into_owned();
192
            if meta.is_file() && matches_extension(&name, &spec.extensions) {
193
                let mtime = meta.modified().unwrap_or(std::time::UNIX_EPOCH);
194
                found.push((path, meta.len(), mtime));
195
            }
196
        }
197
    }
198
199
    let matched = found.len();
200
    found.sort_by(|a, b| b.2.cmp(&a.2));
201
    let candidates: Vec<TraceCandidate> = found
202
        .into_iter()
203
        .take(bounds.max_files_per_store)
204
        .map(|(path, bytes, mtime)| TraceCandidate {
205
            path,
206
            kind: spec.kind,
207
            bytes,
208
            modified_at: iso8601_utc(mtime),
209
        })
210
        .collect();
211
212
    (
213
        TraceStoreScan {
214
            root: spec.root.clone(),
215
            kind: spec.kind,
216
            present: true,
217
            matched,
218
            listed: candidates.len(),
219
            skipped_symlinks,
220
            truncated,
221
        },
222
        candidates,
223
    )
224
}
225
226
/// Scan every store and merge the candidates, newest first across all of them.
227
pub fn discover(specs: &[TraceStoreSpec], bounds: DiscoveryBounds) -> (Vec<TraceStoreScan>, Vec<TraceCandidate>) {
228
    let mut scans = Vec::with_capacity(specs.len());
229
    let mut candidates = Vec::new();
230
    for spec in specs {
231
        let (scan, mut found) = scan_store(spec, bounds);
232
        scans.push(scan);
233
        candidates.append(&mut found);
234
    }
235
    candidates.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
236
    (scans, candidates)
237
}
238
239
/// Format a `SystemTime` as `2026-08-26T05:44:44.859Z`, matching the ISO strings the
240
/// TypeScript CLI reports so the two listings can be compared line for line.
241
fn iso8601_utc(time: std::time::SystemTime) -> String {
242
    let duration = time
243
        .duration_since(std::time::UNIX_EPOCH)
244
        .unwrap_or_default();
245
    let total_secs = duration.as_secs() as i64;
246
    let millis = duration.subsec_millis();
247
248
    let days = total_secs.div_euclid(86_400);
249
    let secs_of_day = total_secs.rem_euclid(86_400);
250
    let (year, month, day) = civil_from_days(days);
251
    format!(
252
        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
253
        year,
254
        month,
255
        day,
256
        secs_of_day / 3600,
257
        (secs_of_day % 3600) / 60,
258
        secs_of_day % 60,
259
        millis
260
    )
261
}
262
263
/// Howard Hinnant's `civil_from_days`: days since the Unix epoch to a civil date.
264
fn civil_from_days(days: i64) -> (i64, u32, u32) {
265
    let z = days + 719_468;
266
    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
267
    let doe = (z - era * 146_097) as u64;
268
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
269
    let y = yoe as i64 + era * 400;
270
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
271
    let mp = (5 * doy + 2) / 153;
272
    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
273
    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
274
    (if m <= 2 { y + 1 } else { y }, m, d)
275
}
276
277
// ---------------------------------------------------------------------------
278
// Summary
279
// ---------------------------------------------------------------------------
280
281
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
282
pub struct TraceSummary {
283
    pub path: PathBuf,
284
    /// `atif`, `jsonl`, or `unknown`. Detected from the content, then the extension.
285
    pub format: String,
286
    pub bytes: u64,
287
    #[serde(skip_serializing_if = "Option::is_none")]
288
    pub lines: Option<usize>,
289
    #[serde(skip_serializing_if = "Option::is_none")]
290
    pub schema_version: Option<String>,
291
    #[serde(skip_serializing_if = "Option::is_none")]
292
    pub session_id: Option<String>,
293
    #[serde(skip_serializing_if = "Option::is_none")]
294
    pub agent_name: Option<String>,
295
    #[serde(skip_serializing_if = "Option::is_none")]
296
    pub agent_model: Option<String>,
297
    #[serde(skip_serializing_if = "Option::is_none")]
298
    pub steps: Option<usize>,
299
    #[serde(skip_serializing_if = "Option::is_none")]
300
    pub steps_by_source: Option<BTreeMap<String, usize>>,
301
    #[serde(skip_serializing_if = "Option::is_none")]
302
    pub models: Option<Vec<String>>,
303
    #[serde(skip_serializing_if = "Option::is_none")]
304
    pub tool_calls: Option<usize>,
305
    #[serde(skip_serializing_if = "Option::is_none")]
306
    pub total_prompt_tokens: Option<u64>,
307
    #[serde(skip_serializing_if = "Option::is_none")]
308
    pub total_completion_tokens: Option<u64>,
309
    #[serde(skip_serializing_if = "Option::is_none")]
310
    pub first_timestamp: Option<String>,
311
    #[serde(skip_serializing_if = "Option::is_none")]
312
    pub last_timestamp: Option<String>,
313
}
314
315
fn str_field(value: &serde_json::Value, key: &str) -> Option<String> {
316
    value.get(key).and_then(|v| v.as_str()).map(String::from)
317
}
318
319
/// Read a trace file and report only what it actually carries.
320
///
321
/// A file that is not an ATIF document is reported as what it is — a line-delimited
322
/// log, or an unknown format — rather than being given invented step counts.
323
pub fn summarize_trace_file(path: &Path) -> std::io::Result<TraceSummary> {
324
    let text = fs::read_to_string(path)?;
325
    let bytes = text.len() as u64;
326
327
    let document = serde_json::from_str::<serde_json::Value>(&text)
328
        .ok()
329
        .filter(|v| v.is_object());
330
    let steps = document
331
        .as_ref()
332
        .and_then(|d| d.get("steps"))
333
        .and_then(|v| v.as_array());
334
335
    let base = TraceSummary {
336
        path: path.to_path_buf(),
337
        format: "unknown".to_string(),
338
        bytes,
339
        lines: None,
340
        schema_version: None,
341
        session_id: None,
342
        agent_name: None,
343
        agent_model: None,
344
        steps: None,
345
        steps_by_source: None,
346
        models: None,
347
        tool_calls: None,
348
        total_prompt_tokens: None,
349
        total_completion_tokens: None,
350
        first_timestamp: None,
351
        last_timestamp: None,
352
    };
353
354
    let (Some(document), Some(steps)) = (document.as_ref(), steps) else {
355
        if path.to_string_lossy().ends_with(".jsonl") {
356
            return Ok(TraceSummary {
357
                format: "jsonl".to_string(),
358
                lines: Some(text.lines().filter(|l| !l.trim().is_empty()).count()),
359
                ..base
360
            });
361
        }
362
        return Ok(base);
363
    };
364
365
    let mut steps_by_source: BTreeMap<String, usize> = BTreeMap::new();
366
    let mut models: BTreeSet<String> = BTreeSet::new();
367
    let mut tool_calls = 0usize;
368
    let mut prompt_tokens = 0u64;
369
    let mut completion_tokens = 0u64;
370
    let mut saw_tokens = false;
371
372
    for step in steps.iter().filter(|s| s.is_object()) {
373
        let source = str_field(step, "source").unwrap_or_else(|| "unknown".to_string());
374
        *steps_by_source.entry(source).or_insert(0) += 1;
375
        if let Some(model) = str_field(step, "model_name") {
376
            models.insert(model);
377
        }
378
        if let Some(calls) = step.get("tool_calls").and_then(|v| v.as_array()) {
379
            tool_calls += calls.len();
380
        }
381
        if let Some(metrics) = step.get("metrics") {
382
            if let Some(n) = metrics.get("prompt_tokens").and_then(|v| v.as_u64()) {
383
                prompt_tokens += n;
384
                saw_tokens = true;
385
            }
386
            if let Some(n) = metrics.get("completion_tokens").and_then(|v| v.as_u64()) {
387
                completion_tokens += n;
388
                saw_tokens = true;
389
            }
390
        }
391
    }
392
393
    let final_metrics = document.get("final_metrics");
394
    let final_prompt = final_metrics
395
        .and_then(|m| m.get("total_prompt_tokens"))
396
        .and_then(|v| v.as_u64());
397
    let final_completion = final_metrics
398
        .and_then(|m| m.get("total_completion_tokens"))
399
        .and_then(|v| v.as_u64());
400
401
    // The document's own totals win; the per-step sums are a fallback, and when
402
    // neither exists the fields are omitted rather than reported as zero.
403
    let (total_prompt_tokens, total_completion_tokens) =
404
        if final_prompt.is_some() || final_completion.is_some() {
405
            (final_prompt, final_completion)
406
        } else if saw_tokens {
407
            (Some(prompt_tokens), Some(completion_tokens))
408
        } else {
409
            (None, None)
410
        };
411
412
    let agent = document.get("agent");
413
    Ok(TraceSummary {
414
        format: "atif".to_string(),
415
        schema_version: str_field(document, "schema_version"),
416
        session_id: str_field(document, "session_id"),
417
        agent_name: agent.and_then(|a| str_field(a, "name")),
418
        agent_model: agent.and_then(|a| str_field(a, "model_name")),
419
        steps: Some(steps.len()),
420
        steps_by_source: Some(steps_by_source),
421
        models: Some(models.into_iter().collect()),
422
        tool_calls: Some(tool_calls),
423
        total_prompt_tokens,
424
        total_completion_tokens,
425
        first_timestamp: steps.first().and_then(|s| str_field(s, "timestamp")),
426
        last_timestamp: steps.last().and_then(|s| str_field(s, "timestamp")),
427
        ..base
428
    })
429
}
430
431
// ---------------------------------------------------------------------------
432
// Redaction
433
// ---------------------------------------------------------------------------
434
435
/// Where a redacted copy is written. `.jsonl` is checked first.
436
pub fn redacted_path_for(path: &Path) -> PathBuf {
437
    let text = path.to_string_lossy();
438
    if let Some(stem) = text.strip_suffix(".jsonl") {
439
        PathBuf::from(format!("{}.redacted.jsonl", stem))
440
    } else if let Some(stem) = text.strip_suffix(".json") {
441
        PathBuf::from(format!("{}.redacted.json", stem))
442
    } else {
443
        PathBuf::from(format!("{}.redacted.json", text))
444
    }
445
}
446
447
/// True when the path is itself a redacted copy.
448
pub fn is_redacted_copy(path: &Path) -> bool {
449
    let text = path.to_string_lossy();
450
    text.ends_with(".redacted.json") || text.ends_with(".redacted.jsonl")
451
}
452
453
/// What redaction removed, counted by category. Never the matched text.
454
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
455
pub struct Redaction {
456
    pub text: String,
457
    pub counts: BTreeMap<String, usize>,
458
    pub total: usize,
459
}
460
461
#[derive(Debug, Clone, Serialize, Deserialize)]
462
pub struct RedactionResult {
463
    pub input: PathBuf,
464
    pub output: PathBuf,
465
    pub counts: BTreeMap<String, usize>,
466
    pub total: usize,
467
    /// `None` when the input was not JSON to begin with, so re-parsing proves nothing.
468
    pub valid_json: Option<bool>,
469
}
470
471
/// The number of consecutive BIP-39 words a run must reach before it is treated as a
472
/// seed phrase. Below this, the rule declines and the prose is left alone.
473
const MIN_SEED_WORDS: usize = 12;
474
475
struct RedactionRule {
476
    category: &'static str,
477
    pattern: Regex,
478
    replacement: &'static str,
479
    /// Rules whose decision needs more than the pattern. Returning the match
480
    /// unchanged means the rule declined, and a decline is not counted.
481
    resolve: Option<fn(&str) -> String>,
482
    /// The bare `NAME=value` rule, which must not re-match the marker the quoted
483
    /// rules just wrote. Stands in for the TypeScript `(?!\[REDACTED)` lookahead.
484
    declines_redacted_capture: bool,
485
}
486
487
/// Every rule, in the order they run.
488
///
489
/// Order is load-bearing: the specific shapes run before the broad ones so a bearer
490
/// token is counted as `bearer_token` rather than swallowed by `env_value`, and each
491
/// rule sees the previous rule's substitutions.
492
fn redaction_rules(home: &str) -> Vec<RedactionRule> {
493
    let mut rules = vec![
494
        RedactionRule {
495
            category: "seed_phrase",
496
            pattern: Regex::new(r"\b(?:[a-z]{3,8} ){11}[a-z]{3,8}(?:(?: [a-z]{3,8}){3})*\b").unwrap(),
497
            replacement: "[REDACTED:seed_phrase]",
498
            resolve: Some(resolve_seed_phrase),
499
            declines_redacted_capture: false,
500
        },
501
        RedactionRule {
502
            category: "private_key",
503
            pattern: Regex::new(
504
                r"\b(?:nsec1[02-9ac-hj-np-z]{50,}|(?:xprv|yprv|zprv|tprv|uprv|vprv)[1-9A-HJ-NP-Za-km-z]{50,})\b",
505
            )
506
            .unwrap(),
507
            replacement: "[REDACTED:private_key]",
508
            resolve: None,
509
            declines_redacted_capture: false,
510
        },
511
        RedactionRule {
512
            category: "bearer_token",
513
            pattern: Regex::new(r"\b[Bb]earer\s+[A-Za-z0-9._~+/=-]{8,}").unwrap(),
514
            replacement: "Bearer [REDACTED:bearer_token]",
515
            resolve: None,
516
            declines_redacted_capture: false,
517
        },
518
        RedactionRule {
519
            category: "api_key",
520
            pattern: Regex::new(
521
                r"\b(?:sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|gho_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[A-Z0-9]{16}|AIza[A-Za-z0-9_-]{30,})\b",
522
            )
523
            .unwrap(),
524
            replacement: "[REDACTED:api_key]",
525
            resolve: None,
526
            declines_redacted_capture: false,
527
        },
528
        // OpenAgents' own token family. `trace-command.ts` does not carry these —
529
        // its `api_key` rule stops at the third-party prefixes — so `oa_pat_…`
530
        // survived a redaction that claimed to have run. The patterns are the
531
        // authoritative ones from `packages/atif/src/redaction.ts`. Redacting more
532
        // than the TypeScript rules is always safe here; redacting less is the
533
        // failure mode this whole module exists to close.
534
        RedactionRule {
535
            category: "oa_agent_token",
536
            pattern: Regex::new(r"\boa_agent_[A-Za-z0-9_-]{6,}\b").unwrap(),
537
            replacement: "[REDACTED:oa_agent_token]",
538
            resolve: None,
539
            declines_redacted_capture: false,
540
        },
541
        RedactionRule {
542
            category: "oa_token",
543
            pattern: Regex::new(
544
                r"\b(?:oa_(?:live|test|sk|key|secret|tok|token|pat)?_?[A-Za-z0-9]{12,}|oa-x-[A-Za-z0-9_-]{4,}|smct_[A-Za-z0-9_-]{8,})\b",
545
            )
546
            .unwrap(),
547
            replacement: "[REDACTED:oa_token]",
548
            resolve: None,
549
            declines_redacted_capture: false,
550
        },
551
        RedactionRule {
552
            category: "jwt",
553
            pattern: Regex::new(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b").unwrap(),
554
            replacement: "[REDACTED:jwt]",
555
            resolve: None,
556
            declines_redacted_capture: false,
557
        },
558
        RedactionRule {
559
            category: "secret_field",
560
            pattern: Regex::new(
561
                r#"(?i)("[\w.-]*(?:token|secret|password|passwd|api[_-]?key|credential|private[_-]?key)[\w.-]*"\s*:\s*)"(?:[^"\\]|\\.)*""#,
562
            )
563
            .unwrap(),
564
            replacement: "$1\"[REDACTED:secret_field]\"",
565
            resolve: None,
566
            declines_redacted_capture: false,
567
        },
568
        // The quoted form. The TypeScript rule uses a backreference to pair the
569
        // quote character; `regex` has none, so the two quote styles are two rules
570
        // that cannot cross-match — which is exactly what the backreference enforced.
571
        RedactionRule {
572
            category: "env_value",
573
            pattern: Regex::new("\\b([A-Z][A-Z0-9_]{2,})=(\"[^\"\n]{4,}?\")").unwrap(),
574
            replacement: "$1=[REDACTED:env_value]",
575
            resolve: None,
576
            declines_redacted_capture: false,
577
        },
578
        RedactionRule {
579
            category: "env_value",
580
            pattern: Regex::new(r"\b([A-Z][A-Z0-9_]{2,})=('[^'\n]{4,}?')").unwrap(),
581
            replacement: "$1=[REDACTED:env_value]",
582
            resolve: None,
583
            declines_redacted_capture: false,
584
        },
585
        // The bare form. `already_redacted` below stands in for the TypeScript
586
        // `(?!\[REDACTED)` lookahead, so this rule cannot re-match the marker the
587
        // two rules above just wrote.
588
        RedactionRule {
589
            category: "env_value",
590
            pattern: Regex::new("\\b([A-Z][A-Z0-9_]{2,})=([^\\s\"'`\\\\,}]{4,})").unwrap(),
591
            replacement: "$1=[REDACTED:env_value]",
592
            resolve: None,
593
            declines_redacted_capture: true,
594
        },
595
    ];
596
597
    if !home.is_empty() {
598
        rules.push(RedactionRule {
599
            category: "home_path",
600
            pattern: Regex::new(&regex::escape(home)).unwrap(),
601
            replacement: "~",
602
            resolve: None,
603
            declines_redacted_capture: false,
604
        });
605
    }
606
    rules.push(RedactionRule {
607
        category: "home_path",
608
        pattern: Regex::new(r"(?:/Users|/home)/[A-Za-z0-9._-]+").unwrap(),
609
        replacement: "~",
610
        resolve: None,
611
        declines_redacted_capture: false,
612
    });
613
    rules
614
}
615
616
/// Decide whether a run of lowercase words is really a seed phrase.
617
///
618
/// A 12-word run of English prose matches the shape, so the shape alone is not
619
/// enough. The rule finds the longest run of consecutive words that are all in the
620
/// BIP-39 English list and declines unless that run reaches [`MIN_SEED_WORDS`],
621
/// which keeps surrounding prose intact.
622
fn resolve_seed_phrase(matched: &str) -> String {
623
    let words: Vec<&str> = matched.split(' ').collect();
624
    let wordlist = bip39::Language::English.word_list();
625
626
    let mut best_start = 0usize;
627
    let mut best_length = 0usize;
628
    let mut run_start = 0usize;
629
    let mut run_length = 0usize;
630
    for (index, word) in words.iter().enumerate() {
631
        if wordlist.contains(word) {
632
            if run_length == 0 {
633
                run_start = index;
634
            }
635
            run_length += 1;
636
            if run_length > best_length {
637
                best_length = run_length;
638
                best_start = run_start;
639
            }
640
        } else {
641
            run_length = 0;
642
        }
643
    }
644
645
    if best_length < MIN_SEED_WORDS {
646
        return matched.to_string();
37 647
    }
38 648
39
    pub fn redact_trace(input: &str) -> String {
40
        input.replace("sk-", "[REDACTED_KEY]")
41
            .replace("oa_pat_", "[REDACTED_PAT]")
649
    [
650
        words[..best_start].join(" "),
651
        "[REDACTED:seed_phrase]".to_string(),
652
        words[best_start + best_length..].join(" "),
653
    ]
654
    .into_iter()
655
    .filter(|part| !part.is_empty())
656
    .collect::<Vec<_>>()
657
    .join(" ")
658
}
659
660
/// True when a bare env value is already a redaction marker.
661
fn already_redacted(value: &str) -> bool {
662
    value.starts_with("[REDACTED")
663
}
664
665
/// Apply every rule in order and report what each removed.
666
///
667
/// The returned text is what gets written; the counts are what gets printed. The
668
/// matched text appears in neither.
669
pub fn redact_text(input: &str, home: &str) -> Redaction {
670
    let mut output = input.to_string();
671
    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
672
    let mut total = 0usize;
673
674
    for rule in redaction_rules(home) {
675
        let mut matched = 0usize;
676
        let replaced = rule.pattern.replace_all(&output, |caps: &Captures| {
677
            let whole = caps.get(0).map(|m| m.as_str()).unwrap_or_default();
678
679
            if let Some(resolve) = rule.resolve {
680
                let resolved = resolve(whole);
681
                if resolved == whole {
682
                    return whole.to_string();
683
                }
684
                matched += 1;
685
                return resolved;
686
            }
687
688
            // Stand-in for the `(?!\[REDACTED)` lookahead.
689
            if rule.declines_redacted_capture
690
                && caps.get(2).is_some_and(|m| already_redacted(m.as_str()))
691
            {
692
                return whole.to_string();
693
            }
694
695
            matched += 1;
696
            expand_single_digit(rule.replacement, caps)
697
        });
698
        output = replaced.into_owned();
699
700
        if matched > 0 {
701
            *counts.entry(rule.category.to_string()).or_insert(0) += matched;
702
            total += matched;
703
        }
704
    }
705
706
    Redaction {
707
        text: output,
708
        counts,
709
        total,
42 710
    }
43 711
}
712
713
/// Expand `$1`..`$9` in a replacement, exactly as the TypeScript does: a single
714
/// digit, and a missing capture becomes the empty string.
715
fn expand_single_digit(replacement: &str, caps: &Captures) -> String {
716
    let mut out = String::with_capacity(replacement.len());
717
    let mut chars = replacement.chars().peekable();
718
    while let Some(ch) = chars.next() {
719
        if ch == '$' {
720
            if let Some(digit) = chars.peek().and_then(|c| c.to_digit(10)) {
721
                chars.next();
722
                if let Some(capture) = caps.get(digit as usize) {
723
                    out.push_str(capture.as_str());
724
                }
725
                continue;
726
            }
727
        }
728
        out.push(ch);
729
    }
730
    out
731
}
732
733
/// Redact a trace file and write the redacted copy beside it.
734
///
735
/// The write is the point: the command this replaces printed a size and dropped the
736
/// result, so a caller who ran it before sharing a trace had been told the trace was
737
/// safe while the original still held the key.
738
pub fn redact_trace_file(path: &Path, home: &str) -> std::io::Result<RedactionResult> {
739
    let text = fs::read_to_string(path)?;
740
    let parsed_before = serde_json::from_str::<serde_json::Value>(&text).is_ok();
741
742
    let redaction = redact_text(&text, home);
743
    let output = redacted_path_for(path);
744
    fs::write(&output, &redaction.text)?;
745
746
    let valid_json = if parsed_before {
747
        Some(serde_json::from_str::<serde_json::Value>(&redaction.text).is_ok())
748
    } else {
749
        None
750
    };
751
752
    Ok(RedactionResult {
753
        input: path.to_path_buf(),
754
        output,
755
        counts: redaction.counts,
756
        total: redaction.total,
757
        valid_json,
758
    })
759
}
760
761
/// Resolve a `trace show` / `trace redact` argument to a real file.
762
///
763
/// A path, or a bare file name inside `~/.openagents/exports`. An argument that
764
/// resolves to nothing is refused: there is no session-id lookup behind this, and
765
/// pretending otherwise is how the old `trace show` accepted any id at all.
766
pub fn resolve_trace_argument(value: &str, home: &Path) -> Result<PathBuf, String> {
767
    let candidate = PathBuf::from(value);
768
    let direct = if candidate.is_absolute() {
769
        candidate
770
    } else {
771
        std::env::current_dir()
772
            .unwrap_or_else(|_| PathBuf::from("."))
773
            .join(candidate)
774
    };
775
    if direct.exists() {
776
        return Ok(direct);
777
    }
778
779
    if !value.contains('/') {
780
        let in_exports = home.join(".openagents").join("exports").join(value);
781
        if in_exports.exists() {
782
            return Ok(in_exports);
783
        }
784
    }
785
786
    Err(format!(
787
        "No trace file exists at {}, and ~/.openagents/exports has no file by that name. \
788
         Run `oa trace list` to see what is discoverable.",
789
        value
790
    ))
791
}
crates/openagents-cli/tests/cli_test.rs modified +65 -9

@@ -6,7 +6,7 @@ mod tests {

6 6
    use openagents_cli::delegate::DelegationSupervisor;
7 7
    use openagents_cli::tools::{HarnessToolRegistry, ToolCall};
8 8
    use openagents_cli::auth::CredentialStore;
9
    use openagents_cli::identity::IdentityStore;
9
    use openagents_cli::identity::{derive_seed_identity, SeedStore};
10 10
    use openagents_cli::tracker::TrackerClient;
11 11
    use openagents_cli::repo::handle_git_credential;
12 12
    use openagents_cli::box_client::BoxClient;

@@ -14,7 +14,7 @@ mod tests {

14 14
    use openagents_cli::forum::ForumClient;
15 15
    use openagents_cli::memory_client::MemoryClient;
16 16
    use openagents_cli::api_passthrough::ApiPassthroughClient;
17
    use openagents_cli::trace::TraceStore;
17
    use openagents_cli::trace::{default_trace_stores, redact_text};
18 18
19 19
    #[test]
20 20
    fn test_auth_and_credential_store_issue_74() {

@@ -23,11 +23,26 @@ mod tests {

23 23
        assert!(config.default_profile.is_some());
24 24
    }
25 25
26
    /// The old assertion checked only that the strings began `npub1`/`nsec1`, which
27
    /// a `format!` over a SHA-256 digest satisfied. These assert the derivation.
28
    /// The full contract, including parity with the TypeScript CLI, is in
29
    /// `tests/identity_test.rs`.
26 30
    #[test]
27 31
    fn test_identity_generation_issue_75() {
28
        let ident = IdentityStore::generate_identity("test-agent", None);
29
        assert!(ident.npub.starts_with("npub1"));
30
        assert!(ident.nsec.starts_with("nsec1"));
32
        let phrase = "abandon abandon abandon abandon abandon abandon \
33
                      abandon abandon abandon abandon abandon about";
34
        let identity = derive_seed_identity(phrase).unwrap();
35
        assert_eq!(
36
            identity.npub,
37
            "npub1az708q3kd9zy6z6f44zav5ygvdwelkzspf6mtusttx47lft2z38sghk0w7"
38
        );
39
        assert!(derive_seed_identity("not a mnemonic").is_err());
40
41
        // Nothing is persisted until something asks for it to be.
42
        let directory = tempfile::tempdir().unwrap();
43
        let store = SeedStore::new(Some(directory.path().join("identity")));
44
        assert!(!store.present());
45
        assert!(store.identity().is_err());
31 46
    }
32 47
33 48
    #[tokio::test]

@@ -56,11 +71,38 @@ mod tests {

56 71
        assert!(probe.num_cpus > 0);
57 72
    }
58 73
74
    /// The old assertion was `!boards.is_empty()`, and it passed *because of* the
75
    /// fabrication: a non-2xx returned two hardcoded boards. Removing the fallback
76
    /// is what makes this test meaningful, so it now asserts the boards carry the
77
    /// server's own fields.
59 78
    #[tokio::test]
60 79
    async fn test_forum_client_issue_80() {
61 80
        let client = ForumClient::new("https://openagents.com/api/v1", None);
62 81
        let boards = client.list_boards().await.unwrap();
63 82
        assert!(!boards.is_empty());
83
        let promises = boards
84
            .iter()
85
            .find(|b| b.slug == "product-promises")
86
            .expect("the live forum serves a `product-promises` board");
87
        assert!(!promises.id.is_empty(), "a real board carries a UUID");
88
        assert!(!promises.title.is_empty());
89
        assert!(
90
            promises.topic_count > 0,
91
            "the board has topics; a fabricated board had no counts at all"
92
        );
93
    }
94
95
    /// The fabrication in one assertion: a route that refuses must produce an
96
    /// error, never a board list. This needs no live data to be meaningful.
97
    #[tokio::test]
98
    async fn test_forum_refuses_rather_than_inventing_boards() {
99
        let client = ForumClient::new("https://openagents.com/api/v1/no-such-surface", None);
100
        let result = client.list_boards().await;
101
        assert!(
102
            result.is_err(),
103
            "a refused request must not yield boards, got {:?}",
104
            result.ok()
105
        );
64 106
    }
65 107
66 108
    #[tokio::test]

@@ -70,12 +112,26 @@ mod tests {

70 112
        assert!(res.is_object());
71 113
    }
72 114
115
    /// The old assertions were `sessions.len() == 2` against two source literals,
116
    /// and that the output *contains* a marker — which the prefix swap satisfied
117
    /// while leaving the token in place. These assert the secret body is gone.
118
    /// The full redaction and discovery contract is in `tests/trace_test.rs`.
73 119
    #[test]
74 120
    fn test_trace_store_and_redaction_issue_82() {
75
        let sessions = TraceStore::scan_foreign_sessions();
76
        assert_eq!(sessions.len(), 2);
77
        let redacted = TraceStore::redact_trace("Bearer oa_pat_998877 secret");
78
        assert!(redacted.contains("[REDACTED_PAT]"));
121
        // Discovery describes real directories, not invented sessions.
122
        let specs = default_trace_stores(std::path::Path::new("/nonexistent-home"));
123
        assert_eq!(specs.len(), 3);
124
        assert!(specs
125
            .iter()
126
            .all(|spec| spec.root.starts_with("/nonexistent-home")));
127
128
        let redacted = redact_text("Bearer oa_pat_998877_TOKENBODY secret", "");
129
        assert!(
130
            !redacted.text.contains("oa_pat_998877_TOKENBODY"),
131
            "the token body survived redaction"
132
        );
133
        assert!(!redacted.text.contains("998877"));
134
        assert!(redacted.total > 0);
79 135
    }
80 136
81 137
    /// A turn streams its reply and returns it.
crates/openagents-cli/tests/identity_test.rs added +147

@@ -0,0 +1,147 @@

1
//! Derivation parity with the TypeScript CLI, and the storage contract.
2
//!
3
//! The vectors below are the ones `packages/openagents-cli/test/seed-identity.test.ts`
4
//! freezes, which in turn are `packages/sovereign-identity/src/contract/vectors.ts`.
5
//! An `npub` that differs between the two CLIs is a different account, so a change
6
//! to the Rust derivation fails here rather than silently reissuing every identity.
7
8
use openagents_cli::identity::{
9
    derive_seed_identity, generate_seed_phrase, is_valid_seed_phrase, SeedStore,
10
    DERIVATION_PROFILE_ID, NOSTR_DERIVATION_PATH, WALLET_DERIVATION_PATH,
11
};
12
13
/// The canonical published BIP-39 test phrase. It is not a secret and never was;
14
/// it exists so a deterministic answer can be committed.
15
const TEST_PHRASE: &str =
16
    "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
17
18
const FROZEN_NPUB: &str = "npub1az708q3kd9zy6z6f44zav5ygvdwelkzspf6mtusttx47lft2z38sghk0w7";
19
const FROZEN_NOSTR_PUBKEY_HEX: &str =
20
    "e8bcf3823669444d0b49ad45d65088635d9fd8500a75b5f20b59abefa56a144f";
21
const FROZEN_WALLET_PUBKEY_HEX: &str =
22
    "03aaeb52dd7494c361049de67cc680e83ebcbbbdbeb13637d92cd845f70308af5e";
23
const FROZEN_WALLET_FINGERPRINT_HEX: &str = "d986ed01";
24
const FROZEN_WALLET_ADDRESS: &str = "1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA";
25
26
#[test]
27
fn derives_the_frozen_identity_from_the_published_test_phrase() {
28
    let identity = derive_seed_identity(TEST_PHRASE).expect("the test phrase is valid BIP-39");
29
30
    assert_eq!(identity.profile, DERIVATION_PROFILE_ID);
31
    assert_eq!(identity.npub, FROZEN_NPUB);
32
    assert_eq!(identity.nostr_public_key_hex, FROZEN_NOSTR_PUBKEY_HEX);
33
    assert_eq!(identity.nostr_derivation_path, NOSTR_DERIVATION_PATH);
34
    assert_eq!(identity.wallet_public_key_hex, FROZEN_WALLET_PUBKEY_HEX);
35
    assert_eq!(identity.wallet_fingerprint_hex, FROZEN_WALLET_FINGERPRINT_HEX);
36
    assert_eq!(identity.wallet_address, FROZEN_WALLET_ADDRESS);
37
    assert_eq!(identity.wallet_derivation_path, WALLET_DERIVATION_PATH);
38
}
39
40
#[test]
41
fn the_npub_is_real_bech32_not_a_prefixed_hex_string() {
42
    let identity = derive_seed_identity(TEST_PHRASE).unwrap();
43
44
    // A NIP-19 npub is 63 characters. The fabricated implementation produced 37.
45
    assert_eq!(identity.npub.len(), 63, "npub: {}", identity.npub);
46
47
    // It decodes, the checksum holds, and the payload is the x-only public key.
48
    let (hrp, payload) = bech32::decode(&identity.npub).expect("npub is valid bech32");
49
    assert_eq!(hrp.as_str(), "npub");
50
    assert_eq!(payload.len(), 32);
51
    assert_eq!(
52
        payload.iter().map(|b| format!("{:02x}", b)).collect::<String>(),
53
        FROZEN_NOSTR_PUBKEY_HEX
54
    );
55
56
    // Bech32 has no uppercase and excludes `1`, `b`, `i`, and `o` from its alphabet.
57
    let data = &identity.npub["npub1".len()..];
58
    assert!(
59
        data.chars().all(|c| "qpzry9x8gf2tvdw0s3jn54khce6mua7l".contains(c)),
60
        "npub payload is outside the bech32 alphabet: {}",
61
        data
62
    );
63
}
64
65
#[test]
66
fn is_insensitive_to_surrounding_whitespace_but_not_to_the_words() {
67
    let spaced = format!("  {}\n", TEST_PHRASE.replace(' ', "  "));
68
    assert_eq!(derive_seed_identity(&spaced).unwrap().npub, FROZEN_NPUB);
69
}
70
71
#[test]
72
fn refuses_a_phrase_whose_checksum_does_not_hold() {
73
    let wrong_checksum = TEST_PHRASE.replace("about", "abandon");
74
    assert!(!is_valid_seed_phrase(&wrong_checksum));
75
    assert!(derive_seed_identity(&wrong_checksum).is_err());
76
}
77
78
#[test]
79
fn generated_phrases_come_from_os_entropy_not_a_constant() {
80
    // The defect this replaces derived every key from a string literal, so two
81
    // empty HOMEs on two machines minted the same npub. Distinct phrases and
82
    // distinct identities are the property that proves the literal is gone.
83
    let first = generate_seed_phrase(12).unwrap();
84
    let second = generate_seed_phrase(12).unwrap();
85
    assert_ne!(first, second);
86
    assert_eq!(first.split_whitespace().count(), 12);
87
    assert_eq!(generate_seed_phrase(24).unwrap().split_whitespace().count(), 24);
88
89
    let first_identity = derive_seed_identity(&first).unwrap();
90
    let second_identity = derive_seed_identity(&second).unwrap();
91
    assert_ne!(first_identity.npub, second_identity.npub);
92
    assert_ne!(first_identity.wallet_address, second_identity.wallet_address);
93
94
    // Every generated phrase must validate, or it could not be written back.
95
    assert!(is_valid_seed_phrase(&first));
96
}
97
98
#[test]
99
fn writes_the_phrase_0600_and_reads_it_back_unchanged() {
100
    let directory = tempfile::tempdir().unwrap();
101
    let store = SeedStore::new(Some(directory.path().join("identity")));
102
103
    assert!(!store.present());
104
    assert!(store.read_phrase().unwrap().is_none());
105
    assert!(store.identity().is_err(), "no seed means no identity");
106
107
    let path = store.write_phrase(TEST_PHRASE).unwrap();
108
    assert!(store.present());
109
    assert_eq!(store.read_phrase().unwrap().as_deref(), Some(TEST_PHRASE));
110
111
    // Create, then show: the identity shown is the one just created.
112
    assert_eq!(store.identity().unwrap().npub, FROZEN_NPUB);
113
114
    #[cfg(unix)]
115
    {
116
        use std::os::unix::fs::PermissionsExt;
117
        let file_mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
118
        assert_eq!(file_mode, 0o600, "seed file must not be readable by anyone else");
119
        let dir_mode = std::fs::metadata(path.parent().unwrap())
120
            .unwrap()
121
            .permissions()
122
            .mode()
123
            & 0o777;
124
        assert_eq!(dir_mode, 0o700);
125
    }
126
}
127
128
#[test]
129
fn refuses_to_store_a_phrase_that_could_not_be_recovered() {
130
    let directory = tempfile::tempdir().unwrap();
131
    let store = SeedStore::new(Some(directory.path().join("identity")));
132
133
    assert!(store.write_phrase("not a real mnemonic at all").is_err());
134
    assert!(!store.present(), "an invalid phrase must leave no file behind");
135
}
136
137
#[test]
138
fn forget_deletes_the_seed_and_is_idempotent() {
139
    let directory = tempfile::tempdir().unwrap();
140
    let store = SeedStore::new(Some(directory.path().join("identity")));
141
142
    store.write_phrase(TEST_PHRASE).unwrap();
143
    assert!(store.forget().unwrap(), "the first forget removes the seed");
144
    assert!(!store.present());
145
    assert!(!store.path().exists());
146
    assert!(!store.forget().unwrap(), "a second forget reports nothing to remove");
147
}
crates/openagents-cli/tests/trace_test.rs added +430

@@ -0,0 +1,430 @@

1
//! Discovery and redaction, asserted on values only correct behaviour produces.
2
//!
3
//! The defect these cover reported "Redacted size: 141 bytes" — exactly the input
4
//! plus the length of the two markers it had prepended — while leaving every secret
5
//! body intact and writing no file. So the assertions here are about *absence*: the
6
//! secret must not appear in the written output. A test that a marker is present
7
//! passes against a prefix swap, and that is what let the defect ship.
8
9
use openagents_cli::trace::{
10
    default_trace_stores, discover, is_redacted_copy, path_trace_store, redact_text,
11
    redact_trace_file, redacted_path_for, resolve_trace_argument, summarize_trace_file,
12
    DiscoveryBounds, TraceSourceKind,
13
};
14
use std::fs;
15
use std::path::{Path, PathBuf};
16
17
/// Planted secrets, one of each shape the rules claim to cover. None is real.
18
const API_KEY: &str = "sk-liveSECRETVALUE123456789abcdef";
19
const JWT: &str = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXk";
20
const BEARER_BODY: &str = "REALTOKEN456abcdefghij";
21
const NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5";
22
const ENV_VALUE: &str = "hunter2SUPERSECRET";
23
/// OpenAgents' own personal access token shape, which no TypeScript rule covers.
24
const OA_PAT: &str = "oa_pat_REALTOKEN456";
25
const SEED_PHRASE: &str =
26
    "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
27
28
fn probe_document(home: &str) -> String {
29
    format!(
30
        r#"{{
31
  "api_key": "{API_KEY}",
32
  "authorization": "Bearer {BEARER_BODY}",
33
  "jwt": "{JWT}",
34
  "nsec": "{NSEC}",
35
  "note": "OPENAI_API_KEY={ENV_VALUE}",
36
  "pat": "{OA_PAT}",
37
  "seed": "{SEED_PHRASE}",
38
  "cwd": "{home}/work/openagents"
39
}}"#
40
    )
41
}
42
43
/// Every planted secret, so a single loop can prove each one is gone.
44
fn planted_secrets() -> Vec<&'static str> {
45
    vec![
46
        API_KEY,
47
        "liveSECRETVALUE123",
48
        JWT,
49
        BEARER_BODY,
50
        "REALTOKEN456",
51
        NSEC,
52
        ENV_VALUE,
53
        SEED_PHRASE,
54
        OA_PAT,
55
        "REALTOKEN456",
56
    ]
57
}
58
59
#[test]
60
fn redaction_removes_every_planted_secret_body() {
61
    let home = "/Users/probe-user";
62
    let document = probe_document(home);
63
    let redacted = redact_text(&document, home);
64
65
    for secret in planted_secrets() {
66
        assert!(
67
            !redacted.text.contains(secret),
68
            "the redacted text still contains a planted secret ({} chars of shape {}...)",
69
            secret.len(),
70
            &secret[..secret.len().min(3)]
71
        );
72
    }
73
74
    // The home path is rewritten, not merely flagged.
75
    assert!(!redacted.text.contains(home));
76
    assert!(redacted.text.contains("~/work/openagents"));
77
78
    // And the categories are all reported.
79
    for category in [
80
        "api_key",
81
        "bearer_token",
82
        "jwt",
83
        "private_key",
84
        "env_value",
85
        "seed_phrase",
86
        "home_path",
87
        "oa_token",
88
    ] {
89
        assert!(
90
            redacted.counts.contains_key(category),
91
            "no `{}` match was counted; counts were {:?}",
92
            category,
93
            redacted.counts.keys().collect::<Vec<_>>()
94
        );
95
    }
96
    assert_eq!(
97
        redacted.total,
98
        redacted.counts.values().sum::<usize>(),
99
        "the total must be the sum of the per-category counts"
100
    );
101
}
102
103
#[test]
104
fn redaction_writes_the_output_file_and_it_holds_no_secret() {
105
    let directory = tempfile::tempdir().unwrap();
106
    let input = directory.path().join("probe.json");
107
    let home = "/Users/probe-user";
108
    fs::write(&input, probe_document(home)).unwrap();
109
110
    let result = redact_trace_file(&input, home).unwrap();
111
112
    // The old command wrote nothing at all while reporting success.
113
    assert_eq!(result.output, directory.path().join("probe.redacted.json"));
114
    assert!(result.output.is_file(), "no redacted copy was written");
115
116
    let written = fs::read_to_string(&result.output).unwrap();
117
    for secret in planted_secrets() {
118
        assert!(
119
            !written.contains(secret),
120
            "the written file still contains a planted secret"
121
        );
122
    }
123
124
    // The original is left alone, and still holds what it held.
125
    assert!(fs::read_to_string(&input).unwrap().contains(API_KEY));
126
127
    // A JSON input must still parse after redaction, or the caller is warned.
128
    assert_eq!(result.valid_json, Some(true));
129
    assert!(result.total >= 7);
130
}
131
132
#[test]
133
fn a_prefix_swap_would_fail_this_test() {
134
    // The exact defect: `input.replace("sk-", "[REDACTED_KEY]")` grows the text by
135
    // the marker length and leaves the body. Redaction must shrink this input,
136
    // because the secret is longer than the marker that replaces it.
137
    let input = format!("token: {}\n", API_KEY);
138
    let redacted = redact_text(&input, "");
139
    assert!(
140
        redacted.text.len() < input.len(),
141
        "the output grew, which is what a prefix swap does: {:?}",
142
        redacted.text
143
    );
144
    assert!(!redacted.text.contains("liveSECRETVALUE123"));
145
    assert_eq!(redacted.text.trim(), "token: [REDACTED:api_key]");
146
}
147
148
#[test]
149
fn a_twelve_word_run_of_ordinary_prose_is_not_treated_as_a_seed() {
150
    // The shape rule matches any run of 12 lowercase words. Only a run that is
151
    // actually BIP-39 is redacted, or every paragraph would be destroyed.
152
    let prose = "the quick brown fox jumped over the lazy dog while nobody else watched";
153
    let redacted = redact_text(prose, "");
154
    assert_eq!(redacted.text, prose);
155
    assert!(!redacted.counts.contains_key("seed_phrase"));
156
}
157
158
#[test]
159
fn a_seed_phrase_is_redacted_without_eating_the_prose_around_it() {
160
    let line = format!("my phrase is {} ok", SEED_PHRASE);
161
    let redacted = redact_text(&line, "");
162
    assert!(!redacted.text.contains("abandon"));
163
    assert!(redacted.text.contains("[REDACTED:seed_phrase]"));
164
    assert_eq!(redacted.counts.get("seed_phrase"), Some(&1));
165
}
166
167
#[test]
168
fn redacting_twice_does_not_re_redact_its_own_markers() {
169
    let input = "OPENAI_API_KEY=hunter2SUPERSECRET";
170
    let once = redact_text(input, "");
171
    assert_eq!(once.text, "OPENAI_API_KEY=[REDACTED:env_value]");
172
173
    let twice = redact_text(&once.text, "");
174
    assert_eq!(twice.text, once.text, "the marker was redacted again");
175
    assert_eq!(twice.total, 0);
176
}
177
178
#[test]
179
fn nothing_matching_is_reported_as_nothing_matching() {
180
    let redacted = redact_text("{\"note\":\"a harmless line\"}", "");
181
    assert_eq!(redacted.total, 0);
182
    assert!(redacted.counts.is_empty());
183
    assert_eq!(redacted.text, "{\"note\":\"a harmless line\"}");
184
}
185
186
#[test]
187
fn redacted_sibling_paths_follow_the_extension() {
188
    assert_eq!(
189
        redacted_path_for(Path::new("/t/a.jsonl")),
190
        PathBuf::from("/t/a.redacted.jsonl")
191
    );
192
    assert_eq!(
193
        redacted_path_for(Path::new("/t/a.json")),
194
        PathBuf::from("/t/a.redacted.json")
195
    );
196
    assert_eq!(
197
        redacted_path_for(Path::new("/t/a.txt")),
198
        PathBuf::from("/t/a.txt.redacted.json")
199
    );
200
    assert!(is_redacted_copy(Path::new("/t/a.redacted.json")));
201
    assert!(is_redacted_copy(Path::new("/t/a.redacted.jsonl")));
202
    assert!(!is_redacted_copy(Path::new("/t/a.json")));
203
}
204
205
// ---------------------------------------------------------------------------
206
// Discovery
207
// ---------------------------------------------------------------------------
208
209
#[test]
210
fn discovery_reports_the_files_that_are_there_and_nothing_else() {
211
    let root = tempfile::tempdir().unwrap();
212
    let store = root.path().join("store");
213
    fs::create_dir_all(store.join("nested")).unwrap();
214
    fs::write(store.join("one.json"), "{}").unwrap();
215
    fs::write(store.join("nested").join("two.jsonl"), "{}\n").unwrap();
216
    // Neither extension matches, so neither is listed.
217
    fs::write(store.join("notes.txt"), "hello").unwrap();
218
219
    let bounds = DiscoveryBounds::default();
220
    let (scans, candidates) = discover(&[path_trace_store(store.clone())], bounds);
221
222
    assert_eq!(scans.len(), 1);
223
    assert!(scans[0].present);
224
    assert_eq!(scans[0].matched, 2);
225
    assert_eq!(scans[0].listed, 2);
226
    assert!(!scans[0].truncated);
227
228
    assert_eq!(candidates.len(), 2);
229
    let mut names: Vec<String> = candidates
230
        .iter()
231
        .map(|c| c.path.file_name().unwrap().to_string_lossy().into_owned())
232
        .collect();
233
    names.sort();
234
    assert_eq!(names, vec!["one.json", "two.jsonl"]);
235
    assert!(candidates.iter().all(|c| c.kind == TraceSourceKind::TracePath));
236
    // The reported size is the file's real size.
237
    let one = candidates.iter().find(|c| c.path.ends_with("one.json")).unwrap();
238
    assert_eq!(one.bytes, 2);
239
    assert!(
240
        one.modified_at.ends_with('Z') && one.modified_at.len() == 24,
241
        "modified_at is not an ISO-8601 instant: {}",
242
        one.modified_at
243
    );
244
}
245
246
#[test]
247
fn a_missing_store_is_reported_as_missing_not_as_empty_success() {
248
    let (scans, candidates) = discover(
249
        &[path_trace_store(PathBuf::from("/nonexistent-store-xyz"))],
250
        DiscoveryBounds::default(),
251
    );
252
    assert!(!scans[0].present);
253
    assert_eq!(scans[0].matched, 0);
254
    assert!(candidates.is_empty());
255
}
256
257
#[test]
258
fn symlinks_are_skipped_and_counted_never_followed() {
259
    let root = tempfile::tempdir().unwrap();
260
    let store = root.path().join("store");
261
    let outside = root.path().join("outside");
262
    fs::create_dir_all(&store).unwrap();
263
    fs::create_dir_all(&outside).unwrap();
264
    fs::write(store.join("real.json"), "{}").unwrap();
265
    fs::write(outside.join("secret.json"), "{}").unwrap();
266
267
    #[cfg(unix)]
268
    {
269
        std::os::unix::fs::symlink(outside.join("secret.json"), store.join("link.json")).unwrap();
270
        std::os::unix::fs::symlink(&outside, store.join("escape")).unwrap();
271
    }
272
273
    let (scans, candidates) = discover(&[path_trace_store(store)], DiscoveryBounds::default());
274
275
    #[cfg(unix)]
276
    {
277
        assert_eq!(scans[0].skipped_symlinks, 2);
278
        assert_eq!(scans[0].matched, 1, "a symlink must not be counted as a file");
279
    }
280
    assert!(candidates.iter().all(|c| c.path.ends_with("real.json")));
281
}
282
283
#[test]
284
fn the_listing_cap_limits_what_is_listed_but_not_what_is_counted() {
285
    let root = tempfile::tempdir().unwrap();
286
    let store = root.path().join("store");
287
    fs::create_dir_all(&store).unwrap();
288
    for index in 0..7 {
289
        fs::write(store.join(format!("t{}.json", index)), "{}").unwrap();
290
    }
291
292
    let bounds = DiscoveryBounds {
293
        max_files_per_store: 3,
294
        ..Default::default()
295
    };
296
    let (scans, candidates) = discover(&[path_trace_store(store)], bounds);
297
    assert_eq!(scans[0].matched, 7, "matched counts every file found");
298
    assert_eq!(scans[0].listed, 3, "listed honours the cap");
299
    assert_eq!(candidates.len(), 3);
300
}
301
302
#[test]
303
fn the_default_stores_are_the_three_real_agent_directories() {
304
    let home = Path::new("/home/probe");
305
    let specs = default_trace_stores(home);
306
    let roots: Vec<PathBuf> = specs.iter().map(|s| s.root.clone()).collect();
307
    assert_eq!(
308
        roots,
309
        vec![
310
            home.join(".openagents").join("exports"),
311
            home.join(".claude").join("projects"),
312
            home.join(".codex").join("sessions"),
313
        ]
314
    );
315
    assert_eq!(specs[0].kind, TraceSourceKind::OpenagentsExport);
316
    assert_eq!(specs[1].kind, TraceSourceKind::ClaudeSession);
317
    assert_eq!(specs[2].kind, TraceSourceKind::CodexSession);
318
}
319
320
// ---------------------------------------------------------------------------
321
// Resolution and summary
322
// ---------------------------------------------------------------------------
323
324
#[test]
325
fn an_id_that_resolves_to_nothing_is_refused() {
326
    let home = tempfile::tempdir().unwrap();
327
    // The fabricated `trace show` accepted any id, including this one.
328
    let result = resolve_trace_argument("claude_sess_01", home.path());
329
    assert!(result.is_err());
330
    assert!(result.unwrap_err().contains("No trace file exists"));
331
}
332
333
#[test]
334
fn a_bare_name_resolves_inside_the_exports_directory() {
335
    let home = tempfile::tempdir().unwrap();
336
    let exports = home.path().join(".openagents").join("exports");
337
    fs::create_dir_all(&exports).unwrap();
338
    fs::write(exports.join("run.json"), "{}").unwrap();
339
340
    assert_eq!(
341
        resolve_trace_argument("run.json", home.path()).unwrap(),
342
        exports.join("run.json")
343
    );
344
    // A name with a separator is not looked up in exports.
345
    assert!(resolve_trace_argument("sub/run.json", home.path()).is_err());
346
}
347
348
#[test]
349
fn an_atif_document_is_summarized_from_its_own_contents() {
350
    let directory = tempfile::tempdir().unwrap();
351
    let path = directory.path().join("run.json");
352
    fs::write(
353
        &path,
354
        r#"{
355
          "schema_version": "1.7",
356
          "session_id": "sess-abc",
357
          "agent": { "name": "coder", "model_name": "opus" },
358
          "steps": [
359
            { "source": "user", "timestamp": "2026-08-01T00:00:00Z" },
360
            { "source": "assistant", "model_name": "opus", "timestamp": "2026-08-01T00:01:00Z",
361
              "tool_calls": [{"name":"shell"},{"name":"read"}],
362
              "metrics": { "prompt_tokens": 100, "completion_tokens": 20 } }
363
          ]
364
        }"#,
365
    )
366
    .unwrap();
367
368
    let summary = summarize_trace_file(&path).unwrap();
369
    assert_eq!(summary.format, "atif");
370
    assert_eq!(summary.schema_version.as_deref(), Some("1.7"));
371
    assert_eq!(summary.session_id.as_deref(), Some("sess-abc"));
372
    assert_eq!(summary.agent_name.as_deref(), Some("coder"));
373
    assert_eq!(summary.steps, Some(2));
374
    assert_eq!(summary.tool_calls, Some(2));
375
    assert_eq!(summary.models.as_deref(), Some(&["opus".to_string()][..]));
376
    assert_eq!(summary.total_prompt_tokens, Some(100));
377
    assert_eq!(summary.total_completion_tokens, Some(20));
378
    assert_eq!(summary.first_timestamp.as_deref(), Some("2026-08-01T00:00:00Z"));
379
    assert_eq!(summary.last_timestamp.as_deref(), Some("2026-08-01T00:01:00Z"));
380
    let by_source = summary.steps_by_source.unwrap();
381
    assert_eq!(by_source.get("user"), Some(&1));
382
    assert_eq!(by_source.get("assistant"), Some(&1));
383
}
384
385
#[test]
386
fn a_foreign_log_is_reported_as_a_foreign_log_not_given_invented_steps() {
387
    let directory = tempfile::tempdir().unwrap();
388
    let path = directory.path().join("session.jsonl");
389
    fs::write(&path, "{\"a\":1}\n\n{\"b\":2}\n").unwrap();
390
391
    let summary = summarize_trace_file(&path).unwrap();
392
    assert_eq!(summary.format, "jsonl");
393
    assert_eq!(summary.lines, Some(2), "blank lines are not counted");
394
    assert_eq!(summary.steps, None, "a foreign log has no step count to report");
395
    assert_eq!(summary.tool_calls, None);
396
    assert_eq!(summary.models, None);
397
}
398
399
#[test]
400
fn tokens_absent_from_the_document_are_omitted_not_reported_as_zero() {
401
    let directory = tempfile::tempdir().unwrap();
402
    let path = directory.path().join("run.json");
403
    fs::write(&path, r#"{"steps":[{"source":"user"}]}"#).unwrap();
404
405
    let summary = summarize_trace_file(&path).unwrap();
406
    assert_eq!(summary.total_prompt_tokens, None);
407
    assert_eq!(summary.total_completion_tokens, None);
408
    assert_eq!(summary.steps, Some(1));
409
}
410
411
#[test]
412
fn the_openagents_personal_access_token_is_redacted() {
413
    // The rule set ported from the TypeScript CLI has no `oa_pat_` pattern, so this
414
    // token survived a redaction that reported success. Issue #95's own probe file
415
    // plants it, and it is the credential that reaches openagents.com.
416
    for token in [
417
        "oa_pat_REALTOKEN456",
418
        "oa_agent_abc123def456",
419
        "smct_abcdef123456",
420
        "oa-x-abcd1234",
421
    ] {
422
        let redacted = redact_text(&format!("token: {}", token), "");
423
        assert!(
424
            !redacted.text.contains(token),
425
            "`{}` survived redaction",
426
            token
427
        );
428
        assert!(redacted.total > 0);
429
    }
430
}

This page updates live while a promote is in flight · changelog