abstract-toolserver 0.0.76__tar.gz → 0.0.78__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.
Files changed (122) hide show
  1. {abstract_toolserver-0.0.76/src/abstract_toolserver.egg-info → abstract_toolserver-0.0.78}/PKG-INFO +10 -1
  2. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/README.md +9 -0
  3. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/setup.py +1 -1
  4. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/app.py +23 -0
  5. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/cli.py +12 -0
  6. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/client.py +7 -0
  7. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/discovery.py +6 -2
  8. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/mcp.py +8 -1
  9. abstract_toolserver-0.0.78/src/abstract_toolserver/nodeauth.py +397 -0
  10. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/permits.py +6 -1
  11. abstract_toolserver-0.0.78/src/abstract_toolserver/router.py +173 -0
  12. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78/src/abstract_toolserver.egg-info}/PKG-INFO +10 -1
  13. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver.egg-info/SOURCES.txt +2 -0
  14. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/LICENSE +0 -0
  15. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/pyproject.toml +0 -0
  16. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/setup.cfg +0 -0
  17. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/__init__.py +0 -0
  18. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/__main__.py +0 -0
  19. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/aim.py +0 -0
  20. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/assess.py +0 -0
  21. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/b.py +0 -0
  22. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/board_format.py +0 -0
  23. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/boxes.py +0 -0
  24. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/browser.py +0 -0
  25. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/canvas.py +0 -0
  26. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/catalog.py +0 -0
  27. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/channels.py +0 -0
  28. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/claudeauth.py +0 -0
  29. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/claudedirs.py +0 -0
  30. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/clients.py +0 -0
  31. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/comms.py +0 -0
  32. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/conversation_archive.py +0 -0
  33. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/db.py +0 -0
  34. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/events.py +0 -0
  35. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/exchanges.py +0 -0
  36. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/fileshare.py +0 -0
  37. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/gates.py +0 -0
  38. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/grid.py +0 -0
  39. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/handoff.py +0 -0
  40. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/hugpy_home.py +0 -0
  41. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/image.py +0 -0
  42. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/instructions.py +0 -0
  43. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/issues.py +0 -0
  44. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/labcheck.py +0 -0
  45. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/ledgers.py +0 -0
  46. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/loci.py +0 -0
  47. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/messages.py +0 -0
  48. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/metrics.py +0 -0
  49. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/pkg_versions.py +0 -0
  50. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/pkgvault.py +0 -0
  51. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/prompt.py +0 -0
  52. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/provider.py +0 -0
  53. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/reuse_gate.py +0 -0
  54. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/rolling.py +0 -0
  55. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/rolling_reduce.py +0 -0
  56. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/routes/__init__.py +0 -0
  57. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/seat.py +0 -0
  58. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/session_auth.py +0 -0
  59. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/sessions.py +0 -0
  60. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/__init__.py +0 -0
  61. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/adapter.py +0 -0
  62. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/app.py +0 -0
  63. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/base.py +0 -0
  64. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/browser.py +0 -0
  65. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/layout.py +0 -0
  66. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/state.py +0 -0
  67. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/toolcalls.py +0 -0
  68. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/views/__init__.py +0 -0
  69. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/views/composer.py +0 -0
  70. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/views/listview.py +0 -0
  71. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/views/modals.py +0 -0
  72. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/views/panels.py +0 -0
  73. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/views/text.py +0 -0
  74. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/views/theme.py +0 -0
  75. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/views/transcript.py +0 -0
  76. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/surfaces.py +0 -0
  77. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/todo.py +0 -0
  78. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/tools.py +0 -0
  79. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/uitools.py +0 -0
  80. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/vault.py +0 -0
  81. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/vl.py +0 -0
  82. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/vmgui.py +0 -0
  83. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/vmhosts.py +0 -0
  84. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/vmpage.py +0 -0
  85. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/vmpool.py +0 -0
  86. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver.egg-info/dependency_links.txt +0 -0
  87. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver.egg-info/entry_points.txt +0 -0
  88. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver.egg-info/requires.txt +0 -0
  89. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver.egg-info/top_level.txt +0 -0
  90. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_auth.py +0 -0
  91. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_board_format.py +0 -0
  92. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_boxes_install_link.py +0 -0
  93. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_browser_aim.py +0 -0
  94. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_catalog_start.py +0 -0
  95. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_channels.py +0 -0
  96. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_client.py +0 -0
  97. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_client_session_key.py +0 -0
  98. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_clients_beat_rate.py +0 -0
  99. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_comms.py +0 -0
  100. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_comms_claim.py +0 -0
  101. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_comms_claim_live.py +0 -0
  102. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_db_query_errors.py +0 -0
  103. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_discovery.py +0 -0
  104. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_exchanges_ledgers.py +0 -0
  105. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_gate_ufw_apply.py +0 -0
  106. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_handoff.py +0 -0
  107. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_instructions.py +0 -0
  108. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_issues.py +0 -0
  109. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_lease_comms.py +0 -0
  110. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_mcp_governor.py +0 -0
  111. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_mct_archive.py +0 -0
  112. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_metrics.py +0 -0
  113. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_rolling_reduce.py +0 -0
  114. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_session_key_gate.py +0 -0
  115. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_sessions.py +0 -0
  116. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_todo_id_prefix.py +0 -0
  117. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_ui_tools.py +0 -0
  118. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_vault.py +0 -0
  119. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_vmgui.py +0 -0
  120. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_vmgui_live.py +0 -0
  121. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_vmhosts.py +0 -0
  122. {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_vms_page.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: abstract_toolserver
3
- Version: 0.0.76
3
+ Version: 0.0.78
4
4
  Summary: The abstract_* ecosystem exposed as an API-callable AI toolset: functions become self-describing Flask endpoints with /endpoints discovery and ?help.
5
5
  Home-page: https://github.com/AbstractEndeavors/abstract-toolserver
6
6
  Author: putkoff
@@ -322,6 +322,15 @@ record `requester_node` / `owner_node`: `abstract-permit log --node <id>`. For t
322
322
  `abstract-toolserver node --dropin` prints a unit drop-in (`LogExtraFields=NODE_ID=…`) so one
323
323
  node's log is `journalctl NODE_ID=<id>`.
324
324
 
325
+ **Session keys and the router (2026-10-08).** Every session registers with its user's node
326
+ at start (`abstract-toolserver session register --hook` from a SessionStart hook) and gets a
327
+ rotating key `tsa_…` (24 h, 15 min overlap on rotation; the hub stores only its hash).
328
+ `abstract-toolserver session headers` prints `{"Authorization": "Bearer tsa_…", "X-Session-Id": …}`,
329
+ rotating when close to expiry — usable as an MCP `headersHelper`. Nodes accept `tsa_` keys of
330
+ their own user (verified by the hub; operator-only tools denied). `abstract-toolserver router`
331
+ (default 127.0.0.1:7210) is the one public entry point: it routes by session id to that
332
+ session's node and streams the response; nodes stay on loopback.
333
+
325
334
  `serve` refuses only when **this user** already has a live node (one node per user); other
326
335
  users' nodes on the host are not duplicates. The consumer hook `ensure_endpoint` (hugpy
327
336
  orbit) still uses an existing host toolserver rather than starting one.
@@ -250,6 +250,15 @@ record `requester_node` / `owner_node`: `abstract-permit log --node <id>`. For t
250
250
  `abstract-toolserver node --dropin` prints a unit drop-in (`LogExtraFields=NODE_ID=…`) so one
251
251
  node's log is `journalctl NODE_ID=<id>`.
252
252
 
253
+ **Session keys and the router (2026-10-08).** Every session registers with its user's node
254
+ at start (`abstract-toolserver session register --hook` from a SessionStart hook) and gets a
255
+ rotating key `tsa_…` (24 h, 15 min overlap on rotation; the hub stores only its hash).
256
+ `abstract-toolserver session headers` prints `{"Authorization": "Bearer tsa_…", "X-Session-Id": …}`,
257
+ rotating when close to expiry — usable as an MCP `headersHelper`. Nodes accept `tsa_` keys of
258
+ their own user (verified by the hub; operator-only tools denied). `abstract-toolserver router`
259
+ (default 127.0.0.1:7210) is the one public entry point: it routes by session id to that
260
+ session's node and streams the response; nodes stay on loopback.
261
+
253
262
  `serve` refuses only when **this user** already has a live node (one node per user); other
254
263
  users' nodes on the host are not duplicates. The consumer hook `ensure_endpoint` (hugpy
255
264
  orbit) still uses an existing host toolserver rather than starting one.
@@ -7,7 +7,7 @@ SERVER = ['abstract_flask', 'abstract_utilities']
7
7
 
8
8
  setuptools.setup(
9
9
  name='abstract_toolserver',
10
- version='0.0.76',
10
+ version='0.0.78',
11
11
  author='putkoff',
12
12
  author_email='partners@abstractendeavors.com',
13
13
  description="The abstract_* ecosystem exposed as an API-callable AI toolset: functions become self-describing Flask endpoints with /endpoints discovery and ?help.",
@@ -15,6 +15,7 @@ from flask import g, jsonify, request
15
15
 
16
16
  from . import discovery as _discovery
17
17
  from . import routes
18
+ from . import nodeauth as _nodeauth
18
19
  from . import session_auth as _session_auth
19
20
  from . import vmpage as _vmpage
20
21
 
@@ -80,6 +81,26 @@ def _admit_session_key(app, key):
80
81
  return None
81
82
 
82
83
 
84
+ def _admit_node_session(app, key):
85
+ """Admit a node-session key (tsa_, nodeauth.py): verified by the permit hub,
86
+ accepted only for this node's own user; operator-only tools stay denied."""
87
+ from .gates import ToolError
88
+ try:
89
+ v = _nodeauth.verify_for_node(key, request.headers.get("X-Session-Id", "").strip())
90
+ except ToolError as exc:
91
+ if "not reachable" in str(exc):
92
+ return jsonify({"error": "auth_unavailable", "reason": str(exc)}), 503
93
+ return jsonify({"error": "unauthorized", "reason": str(exc)}), 401
94
+ tool = _session_auth.name_for_request(request.path, request.get_json(silent=True))
95
+ if _session_auth.is_denied_for_hps(tool):
96
+ app.logger.info("tsa DENY session=%s -> operator-only tool %s", v["session_id"], tool)
97
+ return jsonify({"error": "forbidden", "reason": "operator_only", "tool": tool}), 403
98
+ g.principal = {"role": "session", "session_id": v["session_id"], "user": v["user"],
99
+ "node_id": v.get("node_id", "")}
100
+ app.logger.info("tsa ok session=%s user=%s -> %s", v["session_id"], v["user"], tool or request.path)
101
+ return None
102
+
103
+
83
104
  def _install_operator_gate(app):
84
105
  """The ONLY access gate: every request must carry the operator token as
85
106
  X-Operator-Token or 'Authorization: Bearer <token>' (TOOLSERVER_OPERATOR_TOKEN).
@@ -184,6 +205,8 @@ def _install_operator_gate(app):
184
205
  # STEP 2: a hugpy-issued session key (hps_) is verified by central
185
206
  # introspection, ALONGSIDE the static operator token. The static-token
186
207
  # paths below are untouched.
208
+ if _nodeauth.is_session_key(sent):
209
+ return _admit_node_session(app, sent)
187
210
  if _session_auth.is_session_key(sent):
188
211
  return _admit_session_key(app, sent)
189
212
  if not token:
@@ -15,6 +15,12 @@
15
15
  created on first use). --dropin prints the systemd drop-in that tags every
16
16
  journal line of the node's unit with NODE_ID / NODE_USER, so one node's log
17
17
  is `journalctl NODE_ID=<id>` (add --user for a user unit's own journal).
18
+ abstract-toolserver session register|headers|key|end|list
19
+ node-session keys (nodeauth.py): register at session start (--hook reads
20
+ the SessionStart JSON), `headers` prints the rotating auth headers.
21
+ abstract-toolserver router [--host 127.0.0.1] [--port 7210]
22
+ the session router (router.py): X-Session-Id + Bearer tsa_… -> that
23
+ session's node. The one piece meant to sit behind a public hostname.
18
24
  abstract-toolserver mcp the stdio MCP bridge (= abstract-toolserver-mcp)
19
25
  abstract-toolserver provider … the capability-provider loop (see provider.py)
20
26
 
@@ -105,6 +111,12 @@ def main(argv=None) -> int:
105
111
  from .mcp import main as mcp_main
106
112
  mcp_main()
107
113
  return 0
114
+ if argv and argv[0] == "session":
115
+ from .nodeauth import main_session
116
+ return main_session(argv[1:])
117
+ if argv and argv[0] == "router":
118
+ from .router import main as router_main
119
+ return router_main(argv[1:])
108
120
  if argv and argv[0] == "provider":
109
121
  from .provider import main as provider_main
110
122
  return provider_main(argv[1:])
@@ -216,6 +216,7 @@ class ToolserverClient:
216
216
  else:
217
217
  self.token, self.token_source = discovery.resolve_token(
218
218
  "", self.url, environ, advertised)
219
+ self._environ = environ
219
220
  self.timeout = float(timeout or CALL_TIMEOUT)
220
221
  self.probe_timeout = float(probe_timeout or PROBE_TIMEOUT)
221
222
  self.allow = list(allow or _list_env(environ, self.allow_env))
@@ -233,6 +234,12 @@ class ToolserverClient:
233
234
  # -- transport -------------------------------------------------------------
234
235
  def _headers(self) -> dict:
235
236
  h = {"Accept": "application/json", "Content-Type": "application/json"}
237
+ if self.token_source != "config":
238
+ from .nodeauth import session_headers_or_none
239
+ sess = session_headers_or_none(self._environ)
240
+ if sess:
241
+ h.update(sess)
242
+ return h
236
243
  if self.token:
237
244
  h["Authorization"] = "Bearer " + self.token
238
245
  # a session key goes as Bearer only; the static operator token as both
@@ -363,8 +363,12 @@ def validate(ad, probe=True, opener=None, hostname=None) -> tuple:
363
363
 
364
364
 
365
365
  def find_endpoint(environ=None, probe=True, opener=None) -> dict | None:
366
- """First VALID advertisement in search order (dict with url/…/path/source),
367
- else — with probe — a toolserver answering the default local port."""
366
+ """This user's own node first (one node per user), else the first VALID
367
+ advertisement in search order (dict with url/…/path/source), else — with
368
+ probe — a toolserver answering the default local port."""
369
+ own = own_endpoint(environ, probe=probe, opener=opener)
370
+ if own:
371
+ return own
368
372
  for path in endpoint_paths(environ):
369
373
  ad = read_advertisement(path)
370
374
  if not ad:
@@ -150,7 +150,14 @@ def _http(method, path, body=None, timeout=None, bounded=False):
150
150
  req.add_header("Accept", "application/json")
151
151
  if data is not None:
152
152
  req.add_header("Content-Type", "application/json")
153
- if TOKEN:
153
+ from .nodeauth import session_headers_or_none
154
+ sess = session_headers_or_none()
155
+ if sess:
156
+ # inside a session: its rotating key + id (nodeauth.py); the router and the
157
+ # session's own node accept these, and the static token never travels
158
+ for k, v in sess.items():
159
+ req.add_header(k, v)
160
+ elif TOKEN:
154
161
  # both spellings: the toolserver checks X-Operator-Token and also
155
162
  # honours Authorization: Bearer; sending both survives either deployment
156
163
  req.add_header("X-Operator-Token", TOKEN)
@@ -0,0 +1,397 @@
1
+ """
2
+ Session auth for the node mesh: every session registers with its user's node and
3
+ gets a rotating key; the router (router.py) uses session id + key to reach the
4
+ right node.
5
+
6
+ session start (hook) abstract-toolserver session register --hook
7
+ -> hub records session_id <-> uid/node, mints a key
8
+ any client abstract-toolserver session headers
9
+ -> {"Authorization": "Bearer tsa_…", "X-Session-Id": …}
10
+ (rotates the key when it is close to expiry;
11
+ usable as an MCP headersHelper)
12
+ router X-Session-Id + Bearer tsa_… -> hub "route" -> node url
13
+ node gate (app.py) Bearer tsa_… -> hub "verify_key" -> principal, only for
14
+ keys of the node's own user
15
+
16
+ Keys: "tsa_" + 32 random bytes; the hub stores only sha256. TTL
17
+ TOOLSERVER_SESSION_KEY_TTL (default 24 h). Each rotation mints a new key and
18
+ lets the previous one live TOOLSERVER_SESSION_KEY_GRACE more seconds (default
19
+ 900) so in-flight clients are not cut off. A session belongs to the uid that
20
+ registered it (kernel-attested on the hub socket); nobody else can mint its keys.
21
+
22
+ The hub ops live here as a mixin of permits.Hub; the client helpers keep a
23
+ per-session key file (~/.config/abstract-toolserver/sessions/<id>.json, 0600).
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import hashlib
28
+ import json
29
+ import os
30
+ import pwd
31
+ import re
32
+ import secrets
33
+ import time
34
+
35
+ from .gates import ToolError
36
+
37
+ KEY_PREFIX = "tsa_"
38
+ SESSION_RE = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
39
+ TTL = int(os.environ.get("TOOLSERVER_SESSION_KEY_TTL", "86400"))
40
+ GRACE = int(os.environ.get("TOOLSERVER_SESSION_KEY_GRACE", "900"))
41
+ REFRESH_BEFORE = min(3600, TTL // 4)
42
+ SESSION_ENV = ("TOOLSERVER_SESSION_ID", "CLAUDE_CODE_SESSION_ID", "CLAUDE_SESSION_ID", "CODEX_SESSION_ID")
43
+
44
+
45
+ def _user(uid):
46
+ try:
47
+ return pwd.getpwuid(uid).pw_name
48
+ except KeyError:
49
+ return str(uid)
50
+
51
+
52
+ def key_hash(key):
53
+ return hashlib.sha256(str(key or "").encode()).hexdigest()
54
+
55
+
56
+ def is_session_key(cred):
57
+ return str(cred or "").startswith(KEY_PREFIX)
58
+
59
+
60
+ def _sid(v):
61
+ v = str(v or "").strip()
62
+ if not SESSION_RE.match(v):
63
+ raise ToolError("session_id must match [A-Za-z0-9._:-]{1,128}")
64
+ return v
65
+
66
+
67
+ # ──────────────────────────────────────────────────────────
68
+ # Hub side (mixed into permits.Hub; uses self._conn, self.lock, self.node_of,
69
+ # self.operators, self.uid, self._need_operator)
70
+ # ──────────────────────────────────────────────────────────
71
+ class SessionOps:
72
+ def _ensure_sessions(self):
73
+ if getattr(self, "_sessions_ensured", False):
74
+ return
75
+ with self._conn() as c, c.cursor() as cur:
76
+ cur.execute("""
77
+ CREATE TABLE IF NOT EXISTS node_sessions (
78
+ session_id TEXT PRIMARY KEY,
79
+ uid INTEGER NOT NULL,
80
+ usr TEXT NOT NULL,
81
+ node_id TEXT NOT NULL DEFAULT '',
82
+ harness TEXT NOT NULL DEFAULT '',
83
+ label TEXT NOT NULL DEFAULT '',
84
+ pid INTEGER NOT NULL DEFAULT 0,
85
+ created BIGINT NOT NULL,
86
+ last_seen BIGINT NOT NULL,
87
+ ended BIGINT NOT NULL DEFAULT 0
88
+ )""")
89
+ cur.execute("""
90
+ CREATE TABLE IF NOT EXISTS node_session_keys (
91
+ key_hash TEXT PRIMARY KEY,
92
+ key_hint TEXT NOT NULL,
93
+ session_id TEXT NOT NULL,
94
+ uid INTEGER NOT NULL,
95
+ created BIGINT NOT NULL,
96
+ expires BIGINT NOT NULL,
97
+ revoked BIGINT NOT NULL DEFAULT 0,
98
+ replaced_by TEXT NOT NULL DEFAULT ''
99
+ )""")
100
+ cur.execute("CREATE INDEX IF NOT EXISTS node_session_keys_sid_idx ON node_session_keys(session_id)")
101
+ c.commit()
102
+ self._sessions_ensured = True
103
+
104
+ def _session_row(self, cur, sid):
105
+ cur.execute("SELECT * FROM node_sessions WHERE session_id=%s", (sid,))
106
+ r = cur.fetchone()
107
+ return dict(r) if r else None
108
+
109
+ def _mint(self, cur, sid, uid, now):
110
+ key = KEY_PREFIX + secrets.token_urlsafe(32)
111
+ h = key_hash(key)
112
+ cur.execute("UPDATE node_session_keys SET expires=LEAST(expires, %s), replaced_by=%s"
113
+ " WHERE session_id=%s AND revoked=0 AND replaced_by='' AND expires>%s",
114
+ (now + GRACE, h[:12], sid, now))
115
+ cur.execute("INSERT INTO node_session_keys (key_hash, key_hint, session_id, uid, created, expires)"
116
+ " VALUES (%s,%s,%s,%s,%s,%s)", (h, key[:10], sid, uid, now, now + TTL))
117
+ return key, now + TTL
118
+
119
+ def op_session_register(self, peer, msg):
120
+ pid, uid, _ = peer
121
+ self._ensure()
122
+ self._ensure_sessions()
123
+ sid = _sid(msg.get("session_id"))
124
+ now = int(time.time())
125
+ with self._conn() as c, c.cursor() as cur:
126
+ row = self._session_row(cur, sid)
127
+ if row and row["uid"] != uid:
128
+ raise ToolError(f"session {sid} belongs to {row['usr']}")
129
+ node_id = self.node_of.get(uid, "")
130
+ if row:
131
+ cur.execute("UPDATE node_sessions SET node_id=%s, harness=%s, label=%s, pid=%s, last_seen=%s,"
132
+ " ended=0 WHERE session_id=%s",
133
+ (node_id or row["node_id"], str(msg.get("harness") or row["harness"])[:40],
134
+ str(msg.get("label") or row["label"])[:200], int(msg.get("pid") or pid), now, sid))
135
+ else:
136
+ cur.execute("INSERT INTO node_sessions (session_id, uid, usr, node_id, harness, label, pid, created,"
137
+ " last_seen) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)",
138
+ (sid, uid, _user(uid), node_id, str(msg.get("harness") or "")[:40],
139
+ str(msg.get("label") or "")[:200], int(msg.get("pid") or pid), now, now))
140
+ key, expires = self._mint(cur, sid, uid, now)
141
+ c.commit()
142
+ self._log_info("session %s registered for %s (node %s)", sid, _user(uid), node_id or "-")
143
+ return {"session_id": sid, "user": _user(uid), "node_id": node_id, "key": key, "expires": expires}
144
+
145
+ def op_session_key(self, peer, msg):
146
+ """Mint the next key for one of the caller's own sessions (rotation)."""
147
+ uid = peer[1]
148
+ self._ensure_sessions()
149
+ sid = _sid(msg.get("session_id"))
150
+ now = int(time.time())
151
+ with self._conn() as c, c.cursor() as cur:
152
+ row = self._session_row(cur, sid)
153
+ if not row:
154
+ raise ToolError(f"no session {sid} — register it first")
155
+ if row["uid"] != uid:
156
+ raise ToolError(f"session {sid} belongs to {row['usr']}")
157
+ if row["ended"]:
158
+ raise ToolError(f"session {sid} has ended")
159
+ key, expires = self._mint(cur, sid, uid, now)
160
+ cur.execute("UPDATE node_sessions SET last_seen=%s WHERE session_id=%s", (now, sid))
161
+ c.commit()
162
+ return {"session_id": sid, "key": key, "expires": expires}
163
+
164
+ def _check_key(self, key, sid=""):
165
+ self._ensure_sessions()
166
+ now = int(time.time())
167
+ with self._conn() as c, c.cursor() as cur:
168
+ cur.execute("SELECT k.*, s.ended, s.usr, s.node_id FROM node_session_keys k"
169
+ " JOIN node_sessions s ON s.session_id=k.session_id WHERE k.key_hash=%s",
170
+ (key_hash(key),))
171
+ r = cur.fetchone()
172
+ if not r or r["revoked"] or r["expires"] <= now or r["ended"]:
173
+ raise ToolError("invalid or expired session key")
174
+ if sid and r["session_id"] != sid:
175
+ raise ToolError("the key does not belong to that session")
176
+ cur.execute("UPDATE node_sessions SET last_seen=%s WHERE session_id=%s", (now, r["session_id"]))
177
+ c.commit()
178
+ return dict(r)
179
+
180
+ def op_verify_key(self, peer, msg):
181
+ """A node checks a presented key. A node may only verify its own user's
182
+ keys; operators (the router) may verify any."""
183
+ r = self._check_key(msg.get("key"), str(msg.get("session_id") or ""))
184
+ if peer[1] != r["uid"] and peer[1] not in self.operators:
185
+ raise ToolError("that key belongs to another user's node")
186
+ return {"session_id": r["session_id"], "uid": r["uid"], "user": r["usr"],
187
+ "node_id": self.node_of.get(r["uid"], r["node_id"]), "expires": r["expires"]}
188
+
189
+ def op_route(self, peer, msg):
190
+ """Router: session id + key -> the node url that serves that session."""
191
+ self._need_operator(peer)
192
+ sid = _sid(msg.get("session_id"))
193
+ r = self._check_key(msg.get("key"), sid)
194
+ node_id = self.node_of.get(r["uid"], "")
195
+ if not node_id:
196
+ raise ToolError(f"no node is connected for {r['usr']}")
197
+ with self._conn() as c, c.cursor() as cur:
198
+ cur.execute("SELECT url FROM permit_nodes WHERE node_id=%s AND retired=0", (node_id,))
199
+ n = cur.fetchone()
200
+ if not n or not n["url"]:
201
+ raise ToolError(f"node {node_id} has no url")
202
+ return {"session_id": sid, "user": r["usr"], "node_id": node_id, "url": n["url"],
203
+ "expires": r["expires"]}
204
+
205
+ def op_session_end(self, peer, msg):
206
+ uid = peer[1]
207
+ self._ensure_sessions()
208
+ sid = _sid(msg.get("session_id"))
209
+ now = int(time.time())
210
+ with self._conn() as c, c.cursor() as cur:
211
+ row = self._session_row(cur, sid)
212
+ if not row:
213
+ raise ToolError(f"no session {sid}")
214
+ if row["uid"] != uid and uid not in self.operators:
215
+ raise ToolError(f"session {sid} belongs to {row['usr']}")
216
+ cur.execute("UPDATE node_sessions SET ended=%s WHERE session_id=%s", (now, sid))
217
+ cur.execute("UPDATE node_session_keys SET revoked=%s WHERE session_id=%s AND revoked=0", (now, sid))
218
+ c.commit()
219
+ self._log_info("session %s ended by %s", sid, _user(uid))
220
+ return {"session_id": sid, "ended": now}
221
+
222
+ def op_sessions(self, peer, msg):
223
+ self._ensure_sessions()
224
+ now = int(time.time())
225
+ with self._conn() as c, c.cursor() as cur:
226
+ where = "" if msg.get("all") else " WHERE s.ended=0"
227
+ cur.execute("SELECT s.*, (SELECT max(expires) FROM node_session_keys k WHERE k.session_id=s.session_id"
228
+ " AND k.revoked=0) AS key_expires FROM node_sessions s" + where
229
+ + " ORDER BY s.last_seen DESC LIMIT 200")
230
+ rows = [dict(r) for r in cur.fetchall()]
231
+ if peer[1] not in self.operators:
232
+ rows = [r for r in rows if r["uid"] == peer[1]]
233
+ for r in rows:
234
+ r["key_live"] = bool(r.get("key_expires") and r["key_expires"] > now)
235
+ return rows
236
+
237
+
238
+ # ──────────────────────────────────────────────────────────
239
+ # Client side
240
+ # ──────────────────────────────────────────────────────────
241
+ def session_id_from_env(environ=None):
242
+ environ = os.environ if environ is None else environ
243
+ return next((environ[k].strip() for k in SESSION_ENV if environ.get(k, "").strip()), "")
244
+
245
+
246
+ def _key_dir():
247
+ return os.path.join(os.path.expanduser("~"), ".config", "abstract-toolserver", "sessions")
248
+
249
+
250
+ def _key_path(sid):
251
+ return os.path.join(_key_dir(), _sid(sid) + ".json")
252
+
253
+
254
+ def _save(sid, res):
255
+ os.makedirs(_key_dir(), mode=0o700, exist_ok=True)
256
+ path = _key_path(sid)
257
+ tmp = path + ".tmp"
258
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
259
+ with os.fdopen(fd, "w") as fh:
260
+ json.dump({"session_id": sid, "key": res["key"], "expires": res["expires"]}, fh)
261
+ os.replace(tmp, path)
262
+
263
+
264
+ def register(sid, harness="", label=""):
265
+ from . import permits
266
+ res = permits.call("session_register", timeout=30, session_id=_sid(sid), harness=harness,
267
+ label=label, pid=os.getppid())
268
+ _save(sid, res)
269
+ return res
270
+
271
+
272
+ def current_key(sid):
273
+ """The session's key, rotated when within REFRESH_BEFORE of expiry (or
274
+ registered when this user has never registered the session)."""
275
+ from . import permits
276
+ sid = _sid(sid)
277
+ try:
278
+ with open(_key_path(sid)) as fh:
279
+ d = json.load(fh)
280
+ if d.get("key") and int(d.get("expires") or 0) - time.time() > REFRESH_BEFORE:
281
+ return d
282
+ except (OSError, ValueError):
283
+ d = None
284
+ try:
285
+ res = permits.call("session_key", timeout=30, session_id=sid)
286
+ except ToolError as exc:
287
+ if "register it first" not in str(exc):
288
+ raise
289
+ res = register(sid)
290
+ _save(sid, res)
291
+ return res
292
+
293
+
294
+ def headers(sid):
295
+ d = current_key(sid)
296
+ return {"Authorization": "Bearer " + d["key"], "X-Session-Id": d["session_id"]}
297
+
298
+
299
+ def session_headers_or_none(environ=None):
300
+ """Clients (MCP bridge, ToolserverClient): this session's rotating headers
301
+ when the process runs inside a session (CLAUDE_CODE_SESSION_ID, …) and the
302
+ hub can issue a key; None otherwise, so the caller falls back to its static
303
+ token. TOOLSERVER_SESSION_AUTH=0 turns it off."""
304
+ environ = os.environ if environ is None else environ
305
+ if environ.get("TOOLSERVER_SESSION_AUTH", "1").strip().lower() in ("0", "false", "no", "off"):
306
+ return None
307
+ sid = session_id_from_env(environ)
308
+ if not sid or not SESSION_RE.match(sid):
309
+ return None
310
+ try:
311
+ return headers(sid)
312
+ except (ToolError, OSError, ValueError, KeyError):
313
+ return None
314
+
315
+
316
+ # ── node gate: verify a presented key with the hub (short cache) ─────────
317
+ _cache = {}
318
+
319
+
320
+ def verify_for_node(key, sid=""):
321
+ """{session_id, uid, user, node_id, expires} for a valid key of THIS node's
322
+ user, else raises ToolError. Cached 30 s per key."""
323
+ from . import permits
324
+ h = key_hash(key)
325
+ now = time.time()
326
+ hit = _cache.get(h)
327
+ if hit and hit[1] > now and (not sid or hit[0]["session_id"] == sid):
328
+ return hit[0]
329
+ v = permits.call("verify_key", timeout=10, key=key, session_id=sid)
330
+ if int(v.get("uid", -1)) != os.getuid():
331
+ raise ToolError("that key belongs to another user's node")
332
+ _cache[h] = (v, min(now + 30, v["expires"]))
333
+ if len(_cache) > 1000:
334
+ _cache.clear()
335
+ return v
336
+
337
+
338
+ def main_session(argv):
339
+ """abstract-toolserver session register [--hook] [--session ID] [--harness H] [--label L]
340
+ headers [--session ID] JSON headers (MCP headersHelper)
341
+ key [--session ID] the current key (rotates near expiry)
342
+ end [--session ID] | list [--all]"""
343
+ import argparse
344
+ import sys
345
+ from . import permits
346
+ ap = argparse.ArgumentParser(prog="abstract-toolserver session")
347
+ ap.add_argument("cmd", choices=("register", "headers", "key", "end", "list"))
348
+ ap.add_argument("--session", default="")
349
+ ap.add_argument("--hook", action="store_true", help="read the SessionStart hook JSON on stdin")
350
+ ap.add_argument("--harness", default="")
351
+ ap.add_argument("--label", default="")
352
+ ap.add_argument("--all", action="store_true")
353
+ a = ap.parse_args(argv)
354
+ sid = a.session
355
+ harness = a.harness
356
+ if a.hook:
357
+ try:
358
+ hook = json.load(sys.stdin)
359
+ except ValueError:
360
+ hook = {}
361
+ sid = sid or str(hook.get("session_id") or "")
362
+ harness = harness or ("claude-code" if hook.get("hook_event_name") else "")
363
+ sid = sid or session_id_from_env()
364
+ try:
365
+ if a.cmd == "list":
366
+ for r in permits.call("sessions", timeout=30, all=a.all):
367
+ state = "ended" if r["ended"] else ("key live" if r["key_live"] else "key expired")
368
+ print(f" {r['session_id']} {r['usr']:12} {r['node_id'] or '-':20} {r['harness'] or '-':12} {state}")
369
+ return 0
370
+ if not sid:
371
+ print("abstract-toolserver session: no session id (--session, or CLAUDE_CODE_SESSION_ID)",
372
+ file=sys.stderr)
373
+ return 0 if a.hook else 2
374
+ if a.cmd == "register":
375
+ res = register(sid, harness=harness, label=a.label)
376
+ if not a.hook:
377
+ print(f"registered {res['session_id']} for {res['user']} (node {res['node_id'] or '-'}),"
378
+ f" key valid until {time.strftime('%F %T', time.localtime(res['expires']))}")
379
+ return 0
380
+ if a.cmd == "headers":
381
+ print(json.dumps(headers(sid)))
382
+ return 0
383
+ if a.cmd == "key":
384
+ print(current_key(sid)["key"])
385
+ return 0
386
+ if a.cmd == "end":
387
+ permits.call("session_end", timeout=30, session_id=sid)
388
+ try:
389
+ os.remove(_key_path(sid))
390
+ except OSError:
391
+ pass
392
+ print(f"ended {sid}")
393
+ return 0
394
+ except ToolError as exc:
395
+ print(f"abstract-toolserver session: {exc}", file=sys.stderr)
396
+ return 0 if a.hook else 1
397
+ return 2
@@ -52,6 +52,7 @@ import threading
52
52
  import time
53
53
 
54
54
  from .gates import ToolError
55
+ from .nodeauth import SessionOps
55
56
 
56
57
  log = logging.getLogger("abstract_toolserver.permits")
57
58
 
@@ -230,7 +231,7 @@ class _AgentConn:
230
231
  return reply.get("result")
231
232
 
232
233
 
233
- class Hub:
234
+ class Hub(SessionOps):
234
235
  def __init__(self, path):
235
236
  self.path = path
236
237
  self.uid = os.getuid()
@@ -252,6 +253,10 @@ class Hub:
252
253
  self.wlock = threading.Lock()
253
254
  self._ensured = False
254
255
 
256
+ @staticmethod
257
+ def _log_info(fmt, *args):
258
+ log.info("permit " + fmt, *args)
259
+
255
260
  # ── storage ──────────────────────────────────────────
256
261
  def _conn(self):
257
262
  from . import db