stringcup 3.23.0__tar.gz → 3.24.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stringcup
3
- Version: 3.23.0
3
+ Version: 3.24.0
4
4
  Summary: End-to-end encrypted agent-to-agent messaging: client library plus a local MCP server
5
5
  Author: Owen Borseth
6
6
  License: Apache-2.0
@@ -22,9 +22,15 @@ build-backend = "setuptools.build_meta"
22
22
  [project]
23
23
  name = "stringcup"
24
24
  description = "End-to-end encrypted agent-to-agent messaging: client library plus a local MCP server"
25
- # The DISTRIBUTION's long description, not the client README.
26
- # clients/python/README.md is a doc-switch target AND would be this page;
27
- # long_description is immutable per version, so the two must not be one file.
25
+ # THE DISTRIBUTION'S OWN LONG DESCRIPTION, not clients/python/README.md.
26
+ #
27
+ # long_description is FROZEN AT UPLOAD and immutable for the life of a version.
28
+ # The client README could not serve here: it is simultaneously relay-served at
29
+ # /clients/README.md -- so a `pip install stringcup` line in it would publish an
30
+ # instruction that 404s before the package exists -- and a target of
31
+ # apply-published-docs.py, so it MOVES after publication while the uploaded copy
32
+ # cannot. This file is only ever read from PyPI, where the package exists by
33
+ # construction, so it can lead with pip today.
28
34
  readme = "PYPI-README.md"
29
35
  license = {text = "Apache-2.0"}
30
36
  requires-python = ">=3.7"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stringcup
3
- Version: 3.23.0
3
+ Version: 3.24.0
4
4
  Summary: End-to-end encrypted agent-to-agent messaging: client library plus a local MCP server
5
5
  Author: Owen Borseth
6
6
  License: Apache-2.0
@@ -75,10 +75,10 @@ except ImportError as _exc: # pragma: no cover
75
75
  "On Python 3.7 pin it below 46 (see requirements.txt) — 46 drops 3.7."
76
76
  ) from _exc
77
77
 
78
- __version__ = "3.22.0"
78
+ __version__ = "3.24.0"
79
79
 
80
80
  #: Numeric form, for comparisons. Compare this, never `__version__`.
81
- version_info = (3, 22, 0)
81
+ version_info = (3, 24, 0)
82
82
 
83
83
  #: Version of the PyPI DISTRIBUTION, which ships this module and
84
84
  #: `stringcup_mcp.py` together. **This is a third number and it is not
@@ -109,7 +109,7 @@ version_info = (3, 22, 0)
109
109
  #: It must increase whenever either module's version does.
110
110
  #: `clients/python/test_contract.py` snapshots all three and fails on any
111
111
  #: change, so bumping a module forces a decision about this one.
112
- __dist_version__ = "3.23.0"
112
+ __dist_version__ = "3.24.0"
113
113
 
114
114
  __all__ = [
115
115
  "Client",
@@ -241,6 +241,8 @@ FEATURES = {
241
241
  "local_channel_labels": (3, 21, 0), # label_for(), stored client-side only
242
242
  # 3.22.0
243
243
  "label_addressing": (3, 22, 0), # a label works wherever an id does
244
+ "identity_source": (3, 24, 0), # load_or_register says which it did
245
+ "self_join_refused": (3, 24, 0), # join_rendezvous detects a shared identity
244
246
  }
245
247
 
246
248
  DEFAULT_BASE_URL = "https://stringcup.com/api/v2"
@@ -1580,6 +1582,9 @@ class Client:
1580
1582
  if isinstance(trust_store, str):
1581
1583
  trust_store = TrustStore(trust_store)
1582
1584
  self.trust_store = trust_store
1585
+ #: "registered" if this identity was created by `load_or_register`,
1586
+ #: "loaded" if it came off disk, None when constructed directly.
1587
+ self.identity_source: Optional[str] = None
1583
1588
 
1584
1589
  # Optional append-only JSONL record of every message in and out.
1585
1590
  # The relay deletes a message once it is acknowledged, so without this
@@ -1710,11 +1715,21 @@ class Client:
1710
1715
 
1711
1716
  kwargs["transcript"] = transcript
1712
1717
 
1718
+ # WHICH OF THE TWO HAPPENED IS REPORTABLE, and that is not cosmetic.
1719
+ # Two sessions on one machine pointed at one identity file both get the
1720
+ # same identity: the first registers, the second LOADS -- and with
1721
+ # nothing distinguishing them, both narrate "identity registered" and
1722
+ # the collision is invisible in the only report anyone reads. Observed
1723
+ # on a real two-session install, where the visible symptom was a
1724
+ # pairing that never completed.
1713
1725
  if os.path.exists(path):
1714
- return cls(Identity.load(path), base_url=base_url, **kwargs)
1726
+ client = cls(Identity.load(path), base_url=base_url, **kwargs)
1727
+ client.identity_source = "loaded"
1728
+ return client
1715
1729
 
1716
1730
  client = cls.register(base_url=base_url, display_name=display_name, **kwargs)
1717
1731
  client.identity.save(path)
1732
+ client.identity_source = "registered"
1718
1733
  return client
1719
1734
 
1720
1735
  def update_identity(
@@ -2276,6 +2291,42 @@ class Client:
2276
2291
  detected. With it, a mismatch raises `VerificationFailed`.
2277
2292
  """
2278
2293
  info = self.rendezvous(token=token, wait=0)
2294
+
2295
+ # SELF-JOIN: you opened this rendezvous yourself, which means two
2296
+ # sessions are sharing one identity file.
2297
+ #
2298
+ # The relay hands back the role this identity ALREADY HOLDS, because
2299
+ # that is what lets a restart resume. So an identity that opened this
2300
+ # rendezvous and then tries to JOIN it is told "you are the initiator"
2301
+ # and waits for a responder that cannot arrive -- an infinite polite
2302
+ # retry, reported as a peer that never started. Observed in the field
2303
+ # on a documented install.
2304
+ #
2305
+ # THE RELAY CANNOT DETECT THIS AND THIS CLIENT CAN, which is why the
2306
+ # check is here. An initiator legitimately re-polling with its own
2307
+ # token is byte-identical on the wire to a self-join: same identity,
2308
+ # holds initiator, token supplied. The difference is INTENT, and only
2309
+ # the caller knows it -- `join_rendezvous` was called, so a reported
2310
+ # role of initiator is provably wrong. Supplying the intent to the
2311
+ # relay instead would mean a client naming its own role, which is the
2312
+ # thing that caused the original double-rendezvous deadlock.
2313
+ #
2314
+ # Terminal, not retryable: retrying cannot conjure a second identity,
2315
+ # and "call again" is exactly the advice that produced the infinite
2316
+ # wait.
2317
+ if info.get("role") == PAIRING_ROLES[0]:
2318
+ raise StringcupError(
2319
+ "You already hold the initiator side of this rendezvous, so "
2320
+ "you are trying to pair with yourself. Two sessions are "
2321
+ "almost certainly sharing one identity file: this identity "
2322
+ "(%s) opened the rendezvous you are joining. Check "
2323
+ "STRINGCUP_IDENTITY -- if it is set in a user-scope MCP "
2324
+ "config, every session on the machine shares one identity. "
2325
+ "Give each agent its own (STRINGCUP_IDENTITY_NAME=<name>), or "
2326
+ "unset it and let the per-directory default apply. Do not "
2327
+ "retry: there is no second party to wait for." % self.id
2328
+ )
2329
+
2279
2330
  if info.get("peer_id"):
2280
2331
  if secret:
2281
2332
  return self._verify_pairing(
@@ -10,14 +10,20 @@ server placed next to the relay would hold both agents' keys and destroy the
10
10
  end-to-end property that is the entire point of Stringcup. There is deliberately
11
11
  no remote/HTTP transport here.
12
12
 
13
- Configure (Claude Code, Claude Desktop, or any MCP host):
13
+ Configure (Claude Code, Claude Desktop, or any MCP host). With `uvx` there is
14
+ nothing to download and no path to get right:
14
15
 
15
16
  {"mcpServers": {"stringcup": {
16
17
  "command": "uvx",
17
- "args": ["--with", "cryptography", "python",
18
- "/path/to/stringcup_mcp.py"]}}}
18
+ "args": ["--from", "stringcup", "stringcup-mcp"]}}}
19
19
 
20
- Or, with `cryptography` already installed:
20
+ Or `pip install stringcup`, which provides a `stringcup-mcp` console script:
21
+
22
+ {"mcpServers": {"stringcup": {"command": "stringcup-mcp"}}}
23
+
24
+ Only if you are running this file straight from a `curl` and not installing --
25
+ this is the one variant that names a versioned file by path, and so the one
26
+ that breaks when it moves:
21
27
 
22
28
  {"mcpServers": {"stringcup": {
23
29
  "command": "python3", "args": ["/path/to/stringcup_mcp.py"]}}}
@@ -25,6 +31,11 @@ Or, with `cryptography` already installed:
25
31
  Environment:
26
32
 
27
33
  STRINGCUP_IDENTITY identity file path (default ~/.stringcup/identity.json)
34
+ STRINGCUP_IDENTITY_NAME a NAME, resolved beside the default identity, for
35
+ running more than one agent on one machine. The
36
+ default is one identity per USER, not per session, so
37
+ two sessions sharing it are the same agent and cannot
38
+ pair with each other
28
39
  STRINGCUP_BASE_URL relay base URL (default https://stringcup.com/api/v2)
29
40
  STRINGCUP_TRUST_STORE pinned peer fingerprints (default alongside identity)
30
41
  STRINGCUP_TRANSCRIPT JSONL log of every message in and out. ON BY DEFAULT:
@@ -48,6 +59,7 @@ from __future__ import annotations
48
59
 
49
60
  import json
50
61
  import binascii
62
+ import hashlib
51
63
  import os
52
64
  import sys
53
65
  import time
@@ -78,7 +90,7 @@ stringcup.require_features("short_timeouts", "sent_seq", "inbox_quota_errors",
78
90
  "verified_pairing_pins", "local_pairing_role",
79
91
  "header_framed_verify", "undecryptable_visible", "structural_pin_rollback")
80
92
 
81
- __version__ = "1.18.0"
93
+ __version__ = "1.19.0"
82
94
 
83
95
  #: The MCP revision this server implements.
84
96
  PROTOCOL_VERSION = "2025-06-18"
@@ -113,7 +125,7 @@ DEFAULT_IDENTITY = os.path.expanduser("~/.stringcup/identity.json")
113
125
  #:
114
126
  #: A newer library is NOT an error: it is usually fine and blocking it would
115
127
  #: break legitimate installs. It is reported, not refused.
116
- BUILT_AGAINST = (3, 22, 0)
128
+ BUILT_AGAINST = (3, 24, 0)
117
129
 
118
130
 
119
131
  def _version_note() -> Optional[str]:
@@ -197,7 +209,76 @@ _TRANSCRIPT: Optional[str] = None
197
209
 
198
210
 
199
211
  def _identity_path() -> str:
200
- return os.environ.get("STRINGCUP_IDENTITY") or DEFAULT_IDENTITY
212
+ """
213
+ Where this agent's identity lives.
214
+
215
+ TWO AGENTS ON ONE MACHINE MUST BE ABLE TO TALK TO EACH OTHER, and for one
216
+ release they could not. The default was one identity file per *user*, so
217
+ two sessions both loaded it, became the same identity, and the symptom was
218
+ not an error: the second rejoins the first's own rendezvous, is handed back
219
+ the role it already holds, and waits for a counterpart that cannot arrive.
220
+ Reported from a live two-session install where both agents printed the same
221
+ id and both said "identity registered".
222
+
223
+ Resolution order, and every step exists for a reason:
224
+
225
+ 1. `STRINGCUP_IDENTITY` -- an explicit path always wins. **Do not put this
226
+ in a USER-scope MCP config**: that is precisely what makes every session
227
+ on the machine share one identity, and it is how the collision was
228
+ found. Per-project config, or nothing at all, is correct.
229
+ 2. `STRINGCUP_IDENTITY_NAME` -- a name, not a path, resolved beside the
230
+ default. Short enough for a one-liner, stable across restarts.
231
+ 3. An existing `~/.stringcup/identity.json` -- **never break an installed
232
+ agent.** If the legacy single-file default is already there it keeps
233
+ being used, because silently resolving somewhere else would mint a new
234
+ identity and make that agent unreachable at the id its peers hold. That
235
+ is the worst failure this project has, so it is not risked for tidiness.
236
+ 4. Otherwise, per working directory: `agents/<dir>-<hash>.json`.
237
+
238
+ Step 4 is the one that makes the default safe, and it is a narrow use of
239
+ cwd. A cwd-*relative* file was rejected before and stays rejected -- it
240
+ breaks the moment you `cd`. This puts the file in the same private
241
+ directory as always and only uses cwd to NAME it, so an agent relaunched
242
+ in its own project gets its identity back while a different project gets
243
+ its own. The residual risk is renaming or moving a project directory, which
244
+ reads as a fresh identity; `whoami` reports `identity_source: registered`
245
+ and a new id when that happens, which is the signal an operator needs.
246
+
247
+ When cwd carries no useful scope -- `/` or the home directory itself --
248
+ step 4 would name every agent identically, so it falls back to the single
249
+ file rather than pretending to separate them.
250
+ """
251
+ explicit = os.environ.get("STRINGCUP_IDENTITY")
252
+ if explicit:
253
+ return explicit
254
+
255
+ home = os.path.dirname(DEFAULT_IDENTITY)
256
+ name = (os.environ.get("STRINGCUP_IDENTITY_NAME") or "").strip()
257
+ if name:
258
+ allowed = ("abcdefghijklmnopqrstuvwxyz"
259
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-")
260
+ safe = "".join(c if c in allowed else "-" for c in name).strip(".-")
261
+ # NOT "identity": a name that sanitises to nothing would land on the
262
+ # legacy default and silently share the identity this separates.
263
+ return os.path.join(home, (safe or "unnamed") + ".json")
264
+
265
+ if os.path.exists(DEFAULT_IDENTITY):
266
+ return DEFAULT_IDENTITY
267
+
268
+ try:
269
+ cwd = os.path.realpath(os.getcwd())
270
+ except OSError:
271
+ return DEFAULT_IDENTITY
272
+
273
+ if cwd in (os.sep, os.path.realpath(os.path.expanduser("~"))):
274
+ return DEFAULT_IDENTITY
275
+
276
+ allowed = ("abcdefghijklmnopqrstuvwxyz"
277
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-")
278
+ slug = "".join(c if c in allowed else "-"
279
+ for c in os.path.basename(cwd))[:32].strip(".-") or "agent"
280
+ digest = hashlib.sha256(cwd.encode("utf-8")).hexdigest()[:8]
281
+ return os.path.join(home, "agents", "%s-%s.json" % (slug, digest))
201
282
 
202
283
 
203
284
  #: Set STRINGCUP_TRANSCRIPT to this to turn the transcript off.
@@ -368,6 +449,13 @@ def tool_whoami(arguments: Dict[str, Any]) -> Dict[str, Any]:
368
449
  # reading source. It is on by default now, so most holders of one will
369
450
  # not have chosen it. null means disabled.
370
451
  "transcript_file": _TRANSCRIPT,
452
+ # "registered" means this call created the identity; "loaded" means it
453
+ # was already on disk. Load-bearing for the same reason identity_file
454
+ # is: two sessions pointed at one file both get the same identity, and
455
+ # without this an agent reports "identity registered" either way, so
456
+ # the collision never surfaces. If two agents on one machine report the
457
+ # same id, they ARE one agent and cannot pair with each other.
458
+ "identity_source": getattr(_client, "identity_source", None),
371
459
  }
372
460
 
373
461
 
File without changes
File without changes
File without changes
File without changes