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.
- {abstract_toolserver-0.0.76/src/abstract_toolserver.egg-info → abstract_toolserver-0.0.78}/PKG-INFO +10 -1
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/README.md +9 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/setup.py +1 -1
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/app.py +23 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/cli.py +12 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/client.py +7 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/discovery.py +6 -2
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/mcp.py +8 -1
- abstract_toolserver-0.0.78/src/abstract_toolserver/nodeauth.py +397 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/permits.py +6 -1
- abstract_toolserver-0.0.78/src/abstract_toolserver/router.py +173 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78/src/abstract_toolserver.egg-info}/PKG-INFO +10 -1
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver.egg-info/SOURCES.txt +2 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/LICENSE +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/pyproject.toml +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/setup.cfg +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/__init__.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/__main__.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/aim.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/assess.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/b.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/board_format.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/boxes.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/browser.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/canvas.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/catalog.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/channels.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/claudeauth.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/claudedirs.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/clients.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/comms.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/conversation_archive.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/db.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/events.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/exchanges.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/fileshare.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/gates.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/grid.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/handoff.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/hugpy_home.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/image.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/instructions.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/issues.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/labcheck.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/ledgers.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/loci.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/messages.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/metrics.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/pkg_versions.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/pkgvault.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/prompt.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/provider.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/reuse_gate.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/rolling.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/rolling_reduce.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/routes/__init__.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/seat.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/session_auth.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/sessions.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/__init__.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/adapter.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/app.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/base.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/browser.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/layout.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/state.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/toolcalls.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/views/__init__.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/views/composer.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/views/listview.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/views/modals.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/views/panels.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/views/text.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/views/theme.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/station/views/transcript.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/surfaces.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/todo.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/tools.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/uitools.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/vault.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/vl.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/vmgui.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/vmhosts.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/vmpage.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/vmpool.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver.egg-info/dependency_links.txt +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver.egg-info/entry_points.txt +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver.egg-info/requires.txt +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver.egg-info/top_level.txt +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_auth.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_board_format.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_boxes_install_link.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_browser_aim.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_catalog_start.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_channels.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_client.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_client_session_key.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_clients_beat_rate.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_comms.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_comms_claim.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_comms_claim_live.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_db_query_errors.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_discovery.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_exchanges_ledgers.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_gate_ufw_apply.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_handoff.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_instructions.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_issues.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_lease_comms.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_mcp_governor.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_mct_archive.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_metrics.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_rolling_reduce.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_session_key_gate.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_sessions.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_todo_id_prefix.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_ui_tools.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_vault.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_vmgui.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_vmgui_live.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_vmhosts.py +0 -0
- {abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/tests/test_vms_page.py +0 -0
{abstract_toolserver-0.0.76/src/abstract_toolserver.egg-info → abstract_toolserver-0.0.78}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: abstract_toolserver
|
|
3
|
-
Version: 0.0.
|
|
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.
|
|
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
|
{abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/discovery.py
RENAMED
|
@@ -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
|
-
"""
|
|
367
|
-
|
|
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
|
-
|
|
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
|
{abstract_toolserver-0.0.76 → abstract_toolserver-0.0.78}/src/abstract_toolserver/permits.py
RENAMED
|
@@ -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
|