handshake-prompt 0.1.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.
- handshake_prompt-0.1.0/LICENSE +21 -0
- handshake_prompt-0.1.0/PKG-INFO +149 -0
- handshake_prompt-0.1.0/README.md +116 -0
- handshake_prompt-0.1.0/handshake_prompt/__init__.py +27 -0
- handshake_prompt-0.1.0/handshake_prompt/manager.py +353 -0
- handshake_prompt-0.1.0/handshake_prompt/session.py +190 -0
- handshake_prompt-0.1.0/handshake_prompt/store.py +56 -0
- handshake_prompt-0.1.0/handshake_prompt.egg-info/PKG-INFO +149 -0
- handshake_prompt-0.1.0/handshake_prompt.egg-info/SOURCES.txt +12 -0
- handshake_prompt-0.1.0/handshake_prompt.egg-info/dependency_links.txt +1 -0
- handshake_prompt-0.1.0/handshake_prompt.egg-info/requires.txt +8 -0
- handshake_prompt-0.1.0/handshake_prompt.egg-info/top_level.txt +1 -0
- handshake_prompt-0.1.0/pyproject.toml +47 -0
- handshake_prompt-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 handshake-prompt contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: handshake-prompt
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Handshake Prompt Protocol - grant AI Agents access to web services via a single copy-paste prompt
|
|
5
|
+
Author: handshake-prompt contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/CGandGameEngineLearner/handshake-prompt
|
|
8
|
+
Project-URL: Repository, https://github.com/CGandGameEngineLearner/handshake-prompt
|
|
9
|
+
Project-URL: Issues, https://github.com/CGandGameEngineLearner/handshake-prompt/issues
|
|
10
|
+
Keywords: ai,agent,protocol,websocket,handshake,llm,automation
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
22
|
+
Requires-Python: >=3.8
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: flask>=2.0
|
|
26
|
+
Requires-Dist: flask-sock>=0.7.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-flask>=1.3; extra == "dev"
|
|
30
|
+
Requires-Dist: build; extra == "dev"
|
|
31
|
+
Requires-Dist: twine; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# handshake-prompt (Python Server SDK)
|
|
35
|
+
|
|
36
|
+
> Server-side SDK for the **Handshake Prompt Protocol (HPP)** — a lightweight
|
|
37
|
+
> way to grant any AI Agent access to your web service via a single
|
|
38
|
+
> copy-paste prompt. No API keys, no MCP servers, no env vars for end users.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install handshake-prompt
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Quick start (Flask)
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from flask import Flask
|
|
50
|
+
from flask_sock import Sock
|
|
51
|
+
from handshake_prompt import HandshakeManager
|
|
52
|
+
|
|
53
|
+
app = Flask(__name__)
|
|
54
|
+
sock = Sock(app)
|
|
55
|
+
hm = HandshakeManager(app, sock) # done! HPP endpoints are now mounted.
|
|
56
|
+
|
|
57
|
+
if __name__ == '__main__':
|
|
58
|
+
app.run(port=5000)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
That's it. Your service now exposes:
|
|
62
|
+
|
|
63
|
+
| Endpoint | Method | Purpose |
|
|
64
|
+
|----------|--------|---------|
|
|
65
|
+
| `/handshake/session` | POST | Browser creates a handshake session |
|
|
66
|
+
| `/handshake/context/<sid>` | GET | Agent reads current state (token-auth) |
|
|
67
|
+
| `/handshake/action/<sid>` | POST | Agent submits actions (token-auth) |
|
|
68
|
+
| `/handshake/notify/<sid>` | POST | Browser reports user edits |
|
|
69
|
+
| `/handshake/diff/<sid>` | GET | Agent fetches incremental changes |
|
|
70
|
+
| `/ws/handshake/<sid>` | WS | Real-time push channel (token-auth) |
|
|
71
|
+
|
|
72
|
+
## Build a handshake prompt
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
prompt_text = hm.build_prompt(session, base_url='https://your-service.com')
|
|
76
|
+
# Display this text in the UI, let user copy-paste it to their Agent.
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Configure schema
|
|
80
|
+
|
|
81
|
+
Schemas describe what fields the Agent should fill. Sent by the browser
|
|
82
|
+
when creating a session:
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"mode": "form-fill",
|
|
87
|
+
"schema": [
|
|
88
|
+
{"key": "name", "label": "Name", "type": "string", "required": true, "example": "Alice"},
|
|
89
|
+
{"key": "age", "label": "Age", "type": "int", "example": 30},
|
|
90
|
+
{"key": "vip", "label": "VIP", "type": "bool"}
|
|
91
|
+
],
|
|
92
|
+
"context": {} // current state, used by browser to pre-populate
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Supported types: `string` / `int` / `float` / `bool` / `datetime` /
|
|
97
|
+
`array<string>` / `enum`. Custom validators can be plugged in via
|
|
98
|
+
`HandshakeManager(validator=...)`.
|
|
99
|
+
|
|
100
|
+
## Hooks
|
|
101
|
+
|
|
102
|
+
Attach custom logic at key lifecycle points:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
@hm.on_create_session
|
|
106
|
+
def bind_owner(sess, request):
|
|
107
|
+
"""Bind a session to the current logged-in user"""
|
|
108
|
+
from flask import session as flask_session
|
|
109
|
+
sess.owner = flask_session.get('user_id')
|
|
110
|
+
|
|
111
|
+
@hm.on_action
|
|
112
|
+
def audit(sess, action, request):
|
|
113
|
+
"""Audit every action; return False to veto"""
|
|
114
|
+
print(f'[AUDIT] sid={sess.sid} user={sess.owner} action={action}')
|
|
115
|
+
|
|
116
|
+
@hm.on_done
|
|
117
|
+
def notify_done(sess, applied, rejected, errors):
|
|
118
|
+
"""Called after each batch of actions"""
|
|
119
|
+
pass
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Browser auth for `/notify`
|
|
123
|
+
|
|
124
|
+
By default `/notify/<sid>` accepts any request (it's only used by
|
|
125
|
+
browsers within the same origin). For stricter setups, supply a callable:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
def my_auth(request, sess):
|
|
129
|
+
return flask_session.get('user_id') == sess.owner
|
|
130
|
+
|
|
131
|
+
hm = HandshakeManager(app, sock, require_browser_auth=my_auth)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Security defaults
|
|
135
|
+
|
|
136
|
+
- **Token entropy**: 192 bits (`secrets.token_urlsafe(24)`)
|
|
137
|
+
- **Session ID entropy**: 128 bits (`secrets.token_hex(16)`)
|
|
138
|
+
- **Timing-safe comparison**: `secrets.compare_digest`
|
|
139
|
+
- **TTL**: 30 minutes default
|
|
140
|
+
- **Rate limit**: 60 requests / minute / session
|
|
141
|
+
- **User-data protection**: AI **cannot** overwrite fields marked
|
|
142
|
+
`by=user` or `by=user_edit`
|
|
143
|
+
- **WebSocket auth**: connection-time token verification
|
|
144
|
+
|
|
145
|
+
See `SPEC.md` of the main repository for full protocol details.
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
MIT
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# handshake-prompt (Python Server SDK)
|
|
2
|
+
|
|
3
|
+
> Server-side SDK for the **Handshake Prompt Protocol (HPP)** — a lightweight
|
|
4
|
+
> way to grant any AI Agent access to your web service via a single
|
|
5
|
+
> copy-paste prompt. No API keys, no MCP servers, no env vars for end users.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install handshake-prompt
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start (Flask)
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from flask import Flask
|
|
17
|
+
from flask_sock import Sock
|
|
18
|
+
from handshake_prompt import HandshakeManager
|
|
19
|
+
|
|
20
|
+
app = Flask(__name__)
|
|
21
|
+
sock = Sock(app)
|
|
22
|
+
hm = HandshakeManager(app, sock) # done! HPP endpoints are now mounted.
|
|
23
|
+
|
|
24
|
+
if __name__ == '__main__':
|
|
25
|
+
app.run(port=5000)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
That's it. Your service now exposes:
|
|
29
|
+
|
|
30
|
+
| Endpoint | Method | Purpose |
|
|
31
|
+
|----------|--------|---------|
|
|
32
|
+
| `/handshake/session` | POST | Browser creates a handshake session |
|
|
33
|
+
| `/handshake/context/<sid>` | GET | Agent reads current state (token-auth) |
|
|
34
|
+
| `/handshake/action/<sid>` | POST | Agent submits actions (token-auth) |
|
|
35
|
+
| `/handshake/notify/<sid>` | POST | Browser reports user edits |
|
|
36
|
+
| `/handshake/diff/<sid>` | GET | Agent fetches incremental changes |
|
|
37
|
+
| `/ws/handshake/<sid>` | WS | Real-time push channel (token-auth) |
|
|
38
|
+
|
|
39
|
+
## Build a handshake prompt
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
prompt_text = hm.build_prompt(session, base_url='https://your-service.com')
|
|
43
|
+
# Display this text in the UI, let user copy-paste it to their Agent.
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Configure schema
|
|
47
|
+
|
|
48
|
+
Schemas describe what fields the Agent should fill. Sent by the browser
|
|
49
|
+
when creating a session:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"mode": "form-fill",
|
|
54
|
+
"schema": [
|
|
55
|
+
{"key": "name", "label": "Name", "type": "string", "required": true, "example": "Alice"},
|
|
56
|
+
{"key": "age", "label": "Age", "type": "int", "example": 30},
|
|
57
|
+
{"key": "vip", "label": "VIP", "type": "bool"}
|
|
58
|
+
],
|
|
59
|
+
"context": {} // current state, used by browser to pre-populate
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Supported types: `string` / `int` / `float` / `bool` / `datetime` /
|
|
64
|
+
`array<string>` / `enum`. Custom validators can be plugged in via
|
|
65
|
+
`HandshakeManager(validator=...)`.
|
|
66
|
+
|
|
67
|
+
## Hooks
|
|
68
|
+
|
|
69
|
+
Attach custom logic at key lifecycle points:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
@hm.on_create_session
|
|
73
|
+
def bind_owner(sess, request):
|
|
74
|
+
"""Bind a session to the current logged-in user"""
|
|
75
|
+
from flask import session as flask_session
|
|
76
|
+
sess.owner = flask_session.get('user_id')
|
|
77
|
+
|
|
78
|
+
@hm.on_action
|
|
79
|
+
def audit(sess, action, request):
|
|
80
|
+
"""Audit every action; return False to veto"""
|
|
81
|
+
print(f'[AUDIT] sid={sess.sid} user={sess.owner} action={action}')
|
|
82
|
+
|
|
83
|
+
@hm.on_done
|
|
84
|
+
def notify_done(sess, applied, rejected, errors):
|
|
85
|
+
"""Called after each batch of actions"""
|
|
86
|
+
pass
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Browser auth for `/notify`
|
|
90
|
+
|
|
91
|
+
By default `/notify/<sid>` accepts any request (it's only used by
|
|
92
|
+
browsers within the same origin). For stricter setups, supply a callable:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
def my_auth(request, sess):
|
|
96
|
+
return flask_session.get('user_id') == sess.owner
|
|
97
|
+
|
|
98
|
+
hm = HandshakeManager(app, sock, require_browser_auth=my_auth)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Security defaults
|
|
102
|
+
|
|
103
|
+
- **Token entropy**: 192 bits (`secrets.token_urlsafe(24)`)
|
|
104
|
+
- **Session ID entropy**: 128 bits (`secrets.token_hex(16)`)
|
|
105
|
+
- **Timing-safe comparison**: `secrets.compare_digest`
|
|
106
|
+
- **TTL**: 30 minutes default
|
|
107
|
+
- **Rate limit**: 60 requests / minute / session
|
|
108
|
+
- **User-data protection**: AI **cannot** overwrite fields marked
|
|
109
|
+
`by=user` or `by=user_edit`
|
|
110
|
+
- **WebSocket auth**: connection-time token verification
|
|
111
|
+
|
|
112
|
+
See `SPEC.md` of the main repository for full protocol details.
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
MIT
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# encoding=utf-8
|
|
2
|
+
"""
|
|
3
|
+
handshake-prompt
|
|
4
|
+
================
|
|
5
|
+
|
|
6
|
+
Server-side SDK for the Handshake Prompt Protocol (HPP).
|
|
7
|
+
|
|
8
|
+
A lightweight protocol that lets users grant any AI Agent access to a web
|
|
9
|
+
service via a single copy-paste of a "handshake prompt". No API keys, no MCP
|
|
10
|
+
servers, no environment variables required for the end user.
|
|
11
|
+
|
|
12
|
+
Quick start (Flask)::
|
|
13
|
+
|
|
14
|
+
from flask import Flask
|
|
15
|
+
from flask_sock import Sock
|
|
16
|
+
from handshake_prompt import HandshakeManager
|
|
17
|
+
|
|
18
|
+
app = Flask(__name__)
|
|
19
|
+
sock = Sock(app)
|
|
20
|
+
hm = HandshakeManager(app, sock)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from .session import Session
|
|
24
|
+
from .manager import HandshakeManager
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
27
|
+
__all__ = ["HandshakeManager", "Session", "__version__"]
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
# encoding=utf-8
|
|
2
|
+
"""
|
|
3
|
+
HandshakeManager - 把 HPP 协议集成到 Flask 应用
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
import secrets
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from .session import Session
|
|
10
|
+
from .store import SessionStore
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# WebSocket 客户端的协议事件名称
|
|
14
|
+
EVT_CONNECTED = 'connected'
|
|
15
|
+
EVT_ACTION = 'action'
|
|
16
|
+
EVT_DONE = 'done'
|
|
17
|
+
EVT_ERROR = 'error'
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HandshakeManager:
|
|
21
|
+
"""
|
|
22
|
+
将 HPP 注册到 Flask app + flask_sock 实例。
|
|
23
|
+
|
|
24
|
+
使用:
|
|
25
|
+
|
|
26
|
+
from flask import Flask
|
|
27
|
+
from flask_sock import Sock
|
|
28
|
+
from handshake_prompt import HandshakeManager
|
|
29
|
+
|
|
30
|
+
app = Flask(__name__)
|
|
31
|
+
sock = Sock(app)
|
|
32
|
+
hm = HandshakeManager(app, sock)
|
|
33
|
+
|
|
34
|
+
# 可选:通过钩子绑定业务身份
|
|
35
|
+
@hm.on_create_session
|
|
36
|
+
def bind_owner(sess, request):
|
|
37
|
+
sess.owner = session.get('user_id') # Flask session
|
|
38
|
+
|
|
39
|
+
路由(默认 prefix='/handshake'):
|
|
40
|
+
POST /handshake/session 创建会话
|
|
41
|
+
GET /handshake/context/<sid> Agent 读取上下文(需 token)
|
|
42
|
+
POST /handshake/action/<sid> Agent 执行操作(需 token)
|
|
43
|
+
POST /handshake/notify/<sid> 浏览器通知用户编辑(需 session 鉴权)
|
|
44
|
+
GET /handshake/diff/<sid> Agent 增量变更(需 token)
|
|
45
|
+
WS /ws/handshake/<sid> 实时推送通道(需 token)
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, app, sock, prefix='/handshake', ws_prefix='/ws/handshake',
|
|
49
|
+
ttl=1800, rate_limit_per_min=60,
|
|
50
|
+
store=None, validator=None,
|
|
51
|
+
require_browser_auth=None):
|
|
52
|
+
self.app = app
|
|
53
|
+
self.sock = sock
|
|
54
|
+
self.prefix = prefix.rstrip('/')
|
|
55
|
+
self.ws_prefix = ws_prefix.rstrip('/')
|
|
56
|
+
self.ttl = ttl
|
|
57
|
+
self.rate_limit_per_min = rate_limit_per_min
|
|
58
|
+
self.store = store or SessionStore()
|
|
59
|
+
self.validator = validator
|
|
60
|
+
self.require_browser_auth = require_browser_auth # callable(request) -> bool
|
|
61
|
+
|
|
62
|
+
# 钩子
|
|
63
|
+
self._on_create_callbacks = []
|
|
64
|
+
self._on_action_callbacks = []
|
|
65
|
+
self._on_done_callbacks = []
|
|
66
|
+
|
|
67
|
+
self._register(app, sock)
|
|
68
|
+
self.store.start_cleanup_thread()
|
|
69
|
+
|
|
70
|
+
# ── 钩子注册 ──────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
def on_create_session(self, fn):
|
|
73
|
+
"""装饰器:会话创建时调用 fn(session, request)"""
|
|
74
|
+
self._on_create_callbacks.append(fn)
|
|
75
|
+
return fn
|
|
76
|
+
|
|
77
|
+
def on_action(self, fn):
|
|
78
|
+
"""装饰器:Agent 提交 action 时调用 fn(session, action, request)"""
|
|
79
|
+
self._on_action_callbacks.append(fn)
|
|
80
|
+
return fn
|
|
81
|
+
|
|
82
|
+
def on_done(self, fn):
|
|
83
|
+
"""装饰器:单次 action 批次完成后调用 fn(session, applied, rejected, errors)"""
|
|
84
|
+
self._on_done_callbacks.append(fn)
|
|
85
|
+
return fn
|
|
86
|
+
|
|
87
|
+
# ── 工具 ────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def _check_token(sess, request):
|
|
91
|
+
token = request.headers.get('X-Handshake-Token') or request.args.get('token')
|
|
92
|
+
if not token:
|
|
93
|
+
return False
|
|
94
|
+
return secrets.compare_digest(token, sess.token)
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _parse_ws_token(ws):
|
|
98
|
+
query = ws.environ.get('QUERY_STRING', '')
|
|
99
|
+
for part in query.split('&'):
|
|
100
|
+
if part.startswith('token='):
|
|
101
|
+
return part[6:]
|
|
102
|
+
return ''
|
|
103
|
+
|
|
104
|
+
def build_prompt(self, sess, base_url, instructions=None):
|
|
105
|
+
"""
|
|
106
|
+
生成标准格式的握手提示词。
|
|
107
|
+
服务可调用此方法或自行实现,下方提供默认模板。
|
|
108
|
+
"""
|
|
109
|
+
lines = [
|
|
110
|
+
f"# Handshake Prompt - {sess.mode}",
|
|
111
|
+
"",
|
|
112
|
+
"## Credentials",
|
|
113
|
+
f"- sessionId: {sess.sid}",
|
|
114
|
+
f"- token: {sess.token}",
|
|
115
|
+
f"- baseUrl: {base_url}",
|
|
116
|
+
f"- mode: {sess.mode}",
|
|
117
|
+
f"- expiresIn: {sess.expires_in()}s",
|
|
118
|
+
"",
|
|
119
|
+
]
|
|
120
|
+
if instructions:
|
|
121
|
+
lines += ["## Instructions", instructions, ""]
|
|
122
|
+
lines += [
|
|
123
|
+
"## Usage",
|
|
124
|
+
"1. GET {base}/handshake/context/{sid} (header X-Handshake-Token: <token>)",
|
|
125
|
+
"2. POST {base}/handshake/action/{sid} {\"actions\":[{\"type\":\"set\",\"key\":...,\"value\":...}]}",
|
|
126
|
+
"",
|
|
127
|
+
"## Security Notes",
|
|
128
|
+
"- This token is single-use and expires in 30 minutes",
|
|
129
|
+
"- Do NOT forward this prompt to others",
|
|
130
|
+
"- The server will reject any AI attempt to overwrite user-filled fields",
|
|
131
|
+
"",
|
|
132
|
+
"## Waiting for user's request...",
|
|
133
|
+
]
|
|
134
|
+
return "\n".join(lines)
|
|
135
|
+
|
|
136
|
+
# ── 路由注册 ─────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
def _register(self, app, sock):
|
|
139
|
+
from flask import request, jsonify
|
|
140
|
+
|
|
141
|
+
prefix = self.prefix
|
|
142
|
+
|
|
143
|
+
def err(msg, code=400):
|
|
144
|
+
return jsonify({'error': msg}), code
|
|
145
|
+
|
|
146
|
+
# POST /session
|
|
147
|
+
@app.route(f'{prefix}/session', methods=['POST'])
|
|
148
|
+
def _hpp_session():
|
|
149
|
+
body = request.get_json(force=True, silent=True) or {}
|
|
150
|
+
mode = body.get('mode', 'default')
|
|
151
|
+
schema = body.get('schema', [])
|
|
152
|
+
context = body.get('context', {})
|
|
153
|
+
meta = body.get('meta', {})
|
|
154
|
+
|
|
155
|
+
sid = secrets.token_hex(16) # 128bit
|
|
156
|
+
sess = Session(
|
|
157
|
+
sid=sid, mode=mode, schema=schema, context=context,
|
|
158
|
+
ttl=self.ttl,
|
|
159
|
+
rate_limit_per_min=self.rate_limit_per_min,
|
|
160
|
+
meta=meta,
|
|
161
|
+
validator=self.validator,
|
|
162
|
+
)
|
|
163
|
+
# 钩子可写入 owner / 校验权限等
|
|
164
|
+
for cb in self._on_create_callbacks:
|
|
165
|
+
try:
|
|
166
|
+
cb(sess, request)
|
|
167
|
+
except Exception as e:
|
|
168
|
+
return err(str(e), 403)
|
|
169
|
+
|
|
170
|
+
self.store.put(sess)
|
|
171
|
+
|
|
172
|
+
return jsonify({
|
|
173
|
+
'sessionId': sid,
|
|
174
|
+
'token': sess.token,
|
|
175
|
+
'wsUrl': f'{self.ws_prefix}/{sid}',
|
|
176
|
+
'contextUrl': f'{prefix}/context/{sid}',
|
|
177
|
+
'actionUrl': f'{prefix}/action/{sid}',
|
|
178
|
+
'diffUrl': f'{prefix}/diff/{sid}',
|
|
179
|
+
'notifyUrl': f'{prefix}/notify/{sid}',
|
|
180
|
+
'expiresIn': self.ttl,
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
# GET /context/<sid>
|
|
184
|
+
@app.route(f'{prefix}/context/<sid>', methods=['GET'])
|
|
185
|
+
def _hpp_context(sid):
|
|
186
|
+
sess = self.store.get(sid)
|
|
187
|
+
if not sess:
|
|
188
|
+
return err('session not found or expired', 404)
|
|
189
|
+
if not self._check_token(sess, request):
|
|
190
|
+
return err('invalid token', 403)
|
|
191
|
+
if not sess.check_rate_limit():
|
|
192
|
+
return err('rate limit exceeded', 429)
|
|
193
|
+
return jsonify(sess.to_dict())
|
|
194
|
+
|
|
195
|
+
# POST /action/<sid>
|
|
196
|
+
@app.route(f'{prefix}/action/<sid>', methods=['POST'])
|
|
197
|
+
def _hpp_action(sid):
|
|
198
|
+
sess = self.store.get(sid)
|
|
199
|
+
if not sess:
|
|
200
|
+
return err('session not found or expired', 404)
|
|
201
|
+
if not self._check_token(sess, request):
|
|
202
|
+
return err('invalid token', 403)
|
|
203
|
+
if not sess.check_rate_limit():
|
|
204
|
+
return err('rate limit exceeded', 429)
|
|
205
|
+
|
|
206
|
+
body = request.get_json(force=True, silent=True) or {}
|
|
207
|
+
actions = body.get('actions', [])
|
|
208
|
+
stream = body.get('stream', True)
|
|
209
|
+
interval = max(0, body.get('intervalMs', 300)) / 1000.0
|
|
210
|
+
|
|
211
|
+
applied = []
|
|
212
|
+
rejected = []
|
|
213
|
+
errors = []
|
|
214
|
+
|
|
215
|
+
for act in actions:
|
|
216
|
+
if act.get('type') != 'set':
|
|
217
|
+
errors.append({'action': act, 'msg': 'unsupported action type'})
|
|
218
|
+
continue
|
|
219
|
+
key = act.get('key')
|
|
220
|
+
value = act.get('value')
|
|
221
|
+
if key is None:
|
|
222
|
+
errors.append({'action': act, 'msg': 'missing key'})
|
|
223
|
+
continue
|
|
224
|
+
|
|
225
|
+
# 钩子:业务可以拒绝某个 action
|
|
226
|
+
veto = False
|
|
227
|
+
for cb in self._on_action_callbacks:
|
|
228
|
+
try:
|
|
229
|
+
result = cb(sess, act, request)
|
|
230
|
+
if result is False:
|
|
231
|
+
veto = True
|
|
232
|
+
break
|
|
233
|
+
except Exception as e:
|
|
234
|
+
errors.append({'key': key, 'msg': str(e)})
|
|
235
|
+
veto = True
|
|
236
|
+
break
|
|
237
|
+
if veto:
|
|
238
|
+
rejected.append(key)
|
|
239
|
+
continue
|
|
240
|
+
|
|
241
|
+
# schema 类型校验
|
|
242
|
+
ok, msg = sess.validate_value(key, value)
|
|
243
|
+
if not ok:
|
|
244
|
+
errors.append({'key': key, 'msg': msg})
|
|
245
|
+
continue
|
|
246
|
+
|
|
247
|
+
# 写入(AI 不得覆盖用户字段)
|
|
248
|
+
ok = sess.set_value(key, value, 'ai')
|
|
249
|
+
if not ok:
|
|
250
|
+
rejected.append(key)
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
applied.append(key)
|
|
254
|
+
if stream:
|
|
255
|
+
sess.broadcast({'type': EVT_ACTION, 'key': key, 'value': value})
|
|
256
|
+
if interval > 0:
|
|
257
|
+
time.sleep(interval)
|
|
258
|
+
|
|
259
|
+
missing = sess.get_missing()
|
|
260
|
+
sess.broadcast({
|
|
261
|
+
'type': EVT_DONE,
|
|
262
|
+
'applied': len(applied),
|
|
263
|
+
'rejected': rejected,
|
|
264
|
+
'missing': missing,
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
for cb in self._on_done_callbacks:
|
|
268
|
+
try:
|
|
269
|
+
cb(sess, applied, rejected, errors)
|
|
270
|
+
except Exception:
|
|
271
|
+
pass
|
|
272
|
+
|
|
273
|
+
return jsonify({
|
|
274
|
+
'ok': True,
|
|
275
|
+
'applied': applied,
|
|
276
|
+
'rejected': rejected,
|
|
277
|
+
'errors': errors,
|
|
278
|
+
'missing': missing,
|
|
279
|
+
'context': sess.get_context(),
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
# POST /notify/<sid>
|
|
283
|
+
@app.route(f'{prefix}/notify/<sid>', methods=['POST'])
|
|
284
|
+
def _hpp_notify(sid):
|
|
285
|
+
sess = self.store.get(sid)
|
|
286
|
+
if not sess:
|
|
287
|
+
return err('session not found or expired', 404)
|
|
288
|
+
# 浏览器身份校验(可选 hook)
|
|
289
|
+
if self.require_browser_auth is not None:
|
|
290
|
+
if not self.require_browser_auth(request, sess):
|
|
291
|
+
return err('browser auth failed', 403)
|
|
292
|
+
body = request.get_json(force=True, silent=True) or {}
|
|
293
|
+
key = body.get('key')
|
|
294
|
+
value = body.get('value')
|
|
295
|
+
if key is None:
|
|
296
|
+
return err('key is required')
|
|
297
|
+
sess.set_value(key, value, 'user')
|
|
298
|
+
return jsonify({'ok': True})
|
|
299
|
+
|
|
300
|
+
# GET /diff/<sid>
|
|
301
|
+
@app.route(f'{prefix}/diff/<sid>', methods=['GET'])
|
|
302
|
+
def _hpp_diff(sid):
|
|
303
|
+
sess = self.store.get(sid)
|
|
304
|
+
if not sess:
|
|
305
|
+
return err('session not found or expired', 404)
|
|
306
|
+
if not self._check_token(sess, request):
|
|
307
|
+
return err('invalid token', 403)
|
|
308
|
+
if not sess.check_rate_limit():
|
|
309
|
+
return err('rate limit exceeded', 429)
|
|
310
|
+
since = request.args.get('since', '')
|
|
311
|
+
changes = sess.get_diff_since(since) if since else sess.changes[-50:]
|
|
312
|
+
return jsonify({'sessionId': sid, 'since': since, 'changes': changes})
|
|
313
|
+
|
|
314
|
+
# WS /ws/handshake/<sid>
|
|
315
|
+
@sock.route(f'{self.ws_prefix}/<sid>')
|
|
316
|
+
def _hpp_ws(ws, sid):
|
|
317
|
+
sess = self.store.get(sid)
|
|
318
|
+
if not sess:
|
|
319
|
+
ws.send(json.dumps({'type': EVT_ERROR, 'msg': 'session not found'}))
|
|
320
|
+
ws.close()
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
token = self._parse_ws_token(ws)
|
|
324
|
+
if not token or not secrets.compare_digest(token, sess.token):
|
|
325
|
+
ws.send(json.dumps({'type': EVT_ERROR, 'msg': 'invalid token'}))
|
|
326
|
+
ws.close()
|
|
327
|
+
return
|
|
328
|
+
|
|
329
|
+
sess.ws_clients.add(ws)
|
|
330
|
+
ws.send(json.dumps({
|
|
331
|
+
'type': EVT_CONNECTED,
|
|
332
|
+
'sessionId': sid,
|
|
333
|
+
'mode': sess.mode,
|
|
334
|
+
}))
|
|
335
|
+
|
|
336
|
+
try:
|
|
337
|
+
while True:
|
|
338
|
+
raw = ws.receive(timeout=60)
|
|
339
|
+
if raw is None:
|
|
340
|
+
break
|
|
341
|
+
try:
|
|
342
|
+
msg = json.loads(raw)
|
|
343
|
+
except Exception:
|
|
344
|
+
continue
|
|
345
|
+
if msg.get('type') == 'userEdit':
|
|
346
|
+
key = msg.get('key')
|
|
347
|
+
value = msg.get('value')
|
|
348
|
+
if key is not None:
|
|
349
|
+
sess.set_value(key, value, 'user')
|
|
350
|
+
except Exception:
|
|
351
|
+
pass
|
|
352
|
+
finally:
|
|
353
|
+
sess.ws_clients.discard(ws)
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# encoding=utf-8
|
|
2
|
+
"""
|
|
3
|
+
Session 对象 - 一个握手会话的完整状态
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import secrets
|
|
8
|
+
import time
|
|
9
|
+
from collections import deque
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _now_iso():
|
|
14
|
+
return datetime.now(timezone.utc).astimezone().isoformat()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _validate_type(value, ftype):
|
|
18
|
+
"""简易类型校验,可被 HandshakeManager(validator=...) 覆盖"""
|
|
19
|
+
if ftype in ('string', 'str'):
|
|
20
|
+
return isinstance(value, str)
|
|
21
|
+
if ftype in ('int', 'integer'):
|
|
22
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
23
|
+
if ftype in ('float', 'number'):
|
|
24
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
25
|
+
if ftype in ('bool', 'boolean'):
|
|
26
|
+
return isinstance(value, bool)
|
|
27
|
+
if ftype == 'datetime':
|
|
28
|
+
return isinstance(value, str) and bool(
|
|
29
|
+
re.match(r'\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}', value))
|
|
30
|
+
if ftype.startswith('array'):
|
|
31
|
+
return isinstance(value, list)
|
|
32
|
+
return True
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Session:
|
|
36
|
+
"""
|
|
37
|
+
单个握手会话。
|
|
38
|
+
|
|
39
|
+
存储:
|
|
40
|
+
- sid / token 会话标识 + 一次性访问令牌
|
|
41
|
+
- schema 字段定义 list[dict]
|
|
42
|
+
- values 当前值 {key: {value, by, at}}
|
|
43
|
+
- changes 变更历史 (最近 200)
|
|
44
|
+
- ws_clients 订阅的 WebSocket 集合
|
|
45
|
+
|
|
46
|
+
by 字段含义:
|
|
47
|
+
- 'empty' 未填
|
|
48
|
+
- 'user' 用户主动填写
|
|
49
|
+
- 'ai' Agent 填写
|
|
50
|
+
- 'user_edit' AI 填后被用户修改
|
|
51
|
+
|
|
52
|
+
安全:
|
|
53
|
+
- AI 不能覆盖 by=user 或 by=user_edit 的字段(在 set_value 中强制)
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, sid, mode='default', schema=None, context=None,
|
|
57
|
+
ttl=1800, rate_limit_per_min=60,
|
|
58
|
+
owner=None, meta=None, validator=None):
|
|
59
|
+
self.sid = sid
|
|
60
|
+
self.token = secrets.token_urlsafe(24) # 192 bit
|
|
61
|
+
self.mode = mode
|
|
62
|
+
self.schema = schema or []
|
|
63
|
+
self.owner = owner # 任意类型,用于绑定创建者身份
|
|
64
|
+
self.meta = dict(meta or {}) # 任意业务元数据
|
|
65
|
+
self.ttl = ttl
|
|
66
|
+
self.created_at = time.time()
|
|
67
|
+
self.touched_at = time.time()
|
|
68
|
+
|
|
69
|
+
self._rate_limit = rate_limit_per_min
|
|
70
|
+
self._rate_window = deque()
|
|
71
|
+
self._validator = validator or _validate_type
|
|
72
|
+
|
|
73
|
+
# 字段值
|
|
74
|
+
self.values = {}
|
|
75
|
+
for key, val in (context or {}).items():
|
|
76
|
+
by = 'user' if val not in (None, '', [], {}) else 'empty'
|
|
77
|
+
self.values[key] = {'value': val, 'by': by, 'at': _now_iso()}
|
|
78
|
+
|
|
79
|
+
# 变更历史
|
|
80
|
+
self.changes = []
|
|
81
|
+
|
|
82
|
+
# 订阅广播的 WebSocket
|
|
83
|
+
self.ws_clients = set()
|
|
84
|
+
|
|
85
|
+
# ── 生命周期 ─────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
def touch(self):
|
|
88
|
+
self.touched_at = time.time()
|
|
89
|
+
|
|
90
|
+
def is_expired(self):
|
|
91
|
+
return time.time() - self.touched_at > self.ttl
|
|
92
|
+
|
|
93
|
+
def expires_in(self):
|
|
94
|
+
return max(0, int(self.ttl - (time.time() - self.touched_at)))
|
|
95
|
+
|
|
96
|
+
# ── 频率限制 ─────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
def check_rate_limit(self):
|
|
99
|
+
now = time.time()
|
|
100
|
+
while self._rate_window and self._rate_window[0] < now - 60:
|
|
101
|
+
self._rate_window.popleft()
|
|
102
|
+
if len(self._rate_window) >= self._rate_limit:
|
|
103
|
+
return False
|
|
104
|
+
self._rate_window.append(now)
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
# ── 数据 ────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
def set_value(self, key, value, source):
|
|
110
|
+
"""
|
|
111
|
+
设置字段。source ∈ {'ai', 'user'}
|
|
112
|
+
|
|
113
|
+
AI 试图覆盖 by=user / user_edit 时返回 False(不修改)。
|
|
114
|
+
用户修改 AI 填的字段时,by 自动升级为 user_edit。
|
|
115
|
+
"""
|
|
116
|
+
current_by = self.values.get(key, {}).get('by', 'empty')
|
|
117
|
+
|
|
118
|
+
if source == 'ai' and current_by in ('user', 'user_edit'):
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
if source == 'user' and current_by == 'ai':
|
|
122
|
+
source = 'user_edit'
|
|
123
|
+
|
|
124
|
+
old = self.values.get(key, {}).get('value')
|
|
125
|
+
now = _now_iso()
|
|
126
|
+
self.values[key] = {'value': value, 'by': source, 'at': now}
|
|
127
|
+
self.changes.append({
|
|
128
|
+
'key': key, 'from': old, 'to': value, 'by': source, 'at': now,
|
|
129
|
+
})
|
|
130
|
+
if len(self.changes) > 200:
|
|
131
|
+
self.changes.pop(0)
|
|
132
|
+
self.touch()
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
def validate_value(self, key, value):
|
|
136
|
+
"""根据 schema 校验单个字段值,返回 (ok, msg)"""
|
|
137
|
+
field_def = next((f for f in self.schema if f.get('key') == key), None)
|
|
138
|
+
if not field_def:
|
|
139
|
+
return True, None
|
|
140
|
+
ftype = field_def.get('type', 'string')
|
|
141
|
+
if not self._validator(value, ftype):
|
|
142
|
+
return False, f'type mismatch, expected {ftype}'
|
|
143
|
+
if ftype == 'enum':
|
|
144
|
+
options = [o.get('value') for o in field_def.get('options', [])]
|
|
145
|
+
if options and value not in options:
|
|
146
|
+
return False, f'value not in allowed options {options}'
|
|
147
|
+
return True, None
|
|
148
|
+
|
|
149
|
+
def get_context(self):
|
|
150
|
+
"""完整上下文快照"""
|
|
151
|
+
return {k: v.copy() for k, v in self.values.items()}
|
|
152
|
+
|
|
153
|
+
def get_diff_since(self, since_iso):
|
|
154
|
+
return [c for c in self.changes if c['at'] > since_iso]
|
|
155
|
+
|
|
156
|
+
def get_missing(self):
|
|
157
|
+
"""必填且未填的字段 key 列表"""
|
|
158
|
+
return [
|
|
159
|
+
f['key'] for f in self.schema
|
|
160
|
+
if f.get('required')
|
|
161
|
+
and self.values.get(f['key'], {}).get('value') in (None, '', [], {})
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
# ── WebSocket 广播 ──────────────────────────────────
|
|
165
|
+
|
|
166
|
+
def broadcast(self, msg):
|
|
167
|
+
dead = set()
|
|
168
|
+
payload = json.dumps(msg, ensure_ascii=False)
|
|
169
|
+
for ws in list(self.ws_clients):
|
|
170
|
+
try:
|
|
171
|
+
ws.send(payload)
|
|
172
|
+
except Exception:
|
|
173
|
+
dead.add(ws)
|
|
174
|
+
self.ws_clients -= dead
|
|
175
|
+
|
|
176
|
+
# ── 序列化 ──────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
def to_dict(self, include_token=False):
|
|
179
|
+
out = {
|
|
180
|
+
'sessionId': self.sid,
|
|
181
|
+
'mode': self.mode,
|
|
182
|
+
'schema': self.schema,
|
|
183
|
+
'context': self.get_context(),
|
|
184
|
+
'missing': self.get_missing(),
|
|
185
|
+
'meta': self.meta,
|
|
186
|
+
'expiresIn': self.expires_in(),
|
|
187
|
+
}
|
|
188
|
+
if include_token:
|
|
189
|
+
out['token'] = self.token
|
|
190
|
+
return out
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# encoding=utf-8
|
|
2
|
+
"""
|
|
3
|
+
Session 仓库 - 线程安全的会话存储与清理
|
|
4
|
+
"""
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from .session import Session
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SessionStore:
|
|
12
|
+
"""
|
|
13
|
+
内存型会话仓库。生产环境可继承并替换为 Redis 等持久化方案。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self):
|
|
17
|
+
self._sessions = {}
|
|
18
|
+
self._lock = threading.Lock()
|
|
19
|
+
|
|
20
|
+
def put(self, sess: Session):
|
|
21
|
+
with self._lock:
|
|
22
|
+
self._sessions[sess.sid] = sess
|
|
23
|
+
|
|
24
|
+
def get(self, sid):
|
|
25
|
+
with self._lock:
|
|
26
|
+
sess = self._sessions.get(sid)
|
|
27
|
+
if sess and sess.is_expired():
|
|
28
|
+
with self._lock:
|
|
29
|
+
self._sessions.pop(sid, None)
|
|
30
|
+
return None
|
|
31
|
+
if sess:
|
|
32
|
+
sess.touch()
|
|
33
|
+
return sess
|
|
34
|
+
|
|
35
|
+
def remove(self, sid):
|
|
36
|
+
with self._lock:
|
|
37
|
+
self._sessions.pop(sid, None)
|
|
38
|
+
|
|
39
|
+
def cleanup_expired(self):
|
|
40
|
+
with self._lock:
|
|
41
|
+
expired = [k for k, v in self._sessions.items() if v.is_expired()]
|
|
42
|
+
for k in expired:
|
|
43
|
+
del self._sessions[k]
|
|
44
|
+
return len(expired)
|
|
45
|
+
|
|
46
|
+
def start_cleanup_thread(self, interval=300):
|
|
47
|
+
def _loop():
|
|
48
|
+
while True:
|
|
49
|
+
time.sleep(interval)
|
|
50
|
+
try:
|
|
51
|
+
self.cleanup_expired()
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
t = threading.Thread(target=_loop, daemon=True)
|
|
55
|
+
t.start()
|
|
56
|
+
return t
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: handshake-prompt
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Handshake Prompt Protocol - grant AI Agents access to web services via a single copy-paste prompt
|
|
5
|
+
Author: handshake-prompt contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/CGandGameEngineLearner/handshake-prompt
|
|
8
|
+
Project-URL: Repository, https://github.com/CGandGameEngineLearner/handshake-prompt
|
|
9
|
+
Project-URL: Issues, https://github.com/CGandGameEngineLearner/handshake-prompt/issues
|
|
10
|
+
Keywords: ai,agent,protocol,websocket,handshake,llm,automation
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
22
|
+
Requires-Python: >=3.8
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: flask>=2.0
|
|
26
|
+
Requires-Dist: flask-sock>=0.7.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-flask>=1.3; extra == "dev"
|
|
30
|
+
Requires-Dist: build; extra == "dev"
|
|
31
|
+
Requires-Dist: twine; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# handshake-prompt (Python Server SDK)
|
|
35
|
+
|
|
36
|
+
> Server-side SDK for the **Handshake Prompt Protocol (HPP)** — a lightweight
|
|
37
|
+
> way to grant any AI Agent access to your web service via a single
|
|
38
|
+
> copy-paste prompt. No API keys, no MCP servers, no env vars for end users.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install handshake-prompt
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Quick start (Flask)
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from flask import Flask
|
|
50
|
+
from flask_sock import Sock
|
|
51
|
+
from handshake_prompt import HandshakeManager
|
|
52
|
+
|
|
53
|
+
app = Flask(__name__)
|
|
54
|
+
sock = Sock(app)
|
|
55
|
+
hm = HandshakeManager(app, sock) # done! HPP endpoints are now mounted.
|
|
56
|
+
|
|
57
|
+
if __name__ == '__main__':
|
|
58
|
+
app.run(port=5000)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
That's it. Your service now exposes:
|
|
62
|
+
|
|
63
|
+
| Endpoint | Method | Purpose |
|
|
64
|
+
|----------|--------|---------|
|
|
65
|
+
| `/handshake/session` | POST | Browser creates a handshake session |
|
|
66
|
+
| `/handshake/context/<sid>` | GET | Agent reads current state (token-auth) |
|
|
67
|
+
| `/handshake/action/<sid>` | POST | Agent submits actions (token-auth) |
|
|
68
|
+
| `/handshake/notify/<sid>` | POST | Browser reports user edits |
|
|
69
|
+
| `/handshake/diff/<sid>` | GET | Agent fetches incremental changes |
|
|
70
|
+
| `/ws/handshake/<sid>` | WS | Real-time push channel (token-auth) |
|
|
71
|
+
|
|
72
|
+
## Build a handshake prompt
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
prompt_text = hm.build_prompt(session, base_url='https://your-service.com')
|
|
76
|
+
# Display this text in the UI, let user copy-paste it to their Agent.
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Configure schema
|
|
80
|
+
|
|
81
|
+
Schemas describe what fields the Agent should fill. Sent by the browser
|
|
82
|
+
when creating a session:
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"mode": "form-fill",
|
|
87
|
+
"schema": [
|
|
88
|
+
{"key": "name", "label": "Name", "type": "string", "required": true, "example": "Alice"},
|
|
89
|
+
{"key": "age", "label": "Age", "type": "int", "example": 30},
|
|
90
|
+
{"key": "vip", "label": "VIP", "type": "bool"}
|
|
91
|
+
],
|
|
92
|
+
"context": {} // current state, used by browser to pre-populate
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Supported types: `string` / `int` / `float` / `bool` / `datetime` /
|
|
97
|
+
`array<string>` / `enum`. Custom validators can be plugged in via
|
|
98
|
+
`HandshakeManager(validator=...)`.
|
|
99
|
+
|
|
100
|
+
## Hooks
|
|
101
|
+
|
|
102
|
+
Attach custom logic at key lifecycle points:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
@hm.on_create_session
|
|
106
|
+
def bind_owner(sess, request):
|
|
107
|
+
"""Bind a session to the current logged-in user"""
|
|
108
|
+
from flask import session as flask_session
|
|
109
|
+
sess.owner = flask_session.get('user_id')
|
|
110
|
+
|
|
111
|
+
@hm.on_action
|
|
112
|
+
def audit(sess, action, request):
|
|
113
|
+
"""Audit every action; return False to veto"""
|
|
114
|
+
print(f'[AUDIT] sid={sess.sid} user={sess.owner} action={action}')
|
|
115
|
+
|
|
116
|
+
@hm.on_done
|
|
117
|
+
def notify_done(sess, applied, rejected, errors):
|
|
118
|
+
"""Called after each batch of actions"""
|
|
119
|
+
pass
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Browser auth for `/notify`
|
|
123
|
+
|
|
124
|
+
By default `/notify/<sid>` accepts any request (it's only used by
|
|
125
|
+
browsers within the same origin). For stricter setups, supply a callable:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
def my_auth(request, sess):
|
|
129
|
+
return flask_session.get('user_id') == sess.owner
|
|
130
|
+
|
|
131
|
+
hm = HandshakeManager(app, sock, require_browser_auth=my_auth)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Security defaults
|
|
135
|
+
|
|
136
|
+
- **Token entropy**: 192 bits (`secrets.token_urlsafe(24)`)
|
|
137
|
+
- **Session ID entropy**: 128 bits (`secrets.token_hex(16)`)
|
|
138
|
+
- **Timing-safe comparison**: `secrets.compare_digest`
|
|
139
|
+
- **TTL**: 30 minutes default
|
|
140
|
+
- **Rate limit**: 60 requests / minute / session
|
|
141
|
+
- **User-data protection**: AI **cannot** overwrite fields marked
|
|
142
|
+
`by=user` or `by=user_edit`
|
|
143
|
+
- **WebSocket auth**: connection-time token verification
|
|
144
|
+
|
|
145
|
+
See `SPEC.md` of the main repository for full protocol details.
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
handshake_prompt/__init__.py
|
|
5
|
+
handshake_prompt/manager.py
|
|
6
|
+
handshake_prompt/session.py
|
|
7
|
+
handshake_prompt/store.py
|
|
8
|
+
handshake_prompt.egg-info/PKG-INFO
|
|
9
|
+
handshake_prompt.egg-info/SOURCES.txt
|
|
10
|
+
handshake_prompt.egg-info/dependency_links.txt
|
|
11
|
+
handshake_prompt.egg-info/requires.txt
|
|
12
|
+
handshake_prompt.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
handshake_prompt
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "handshake-prompt"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Handshake Prompt Protocol - grant AI Agents access to web services via a single copy-paste prompt"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["ai", "agent", "protocol", "websocket", "handshake", "llm", "automation"]
|
|
13
|
+
authors = [{ name = "handshake-prompt contributors" }]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
19
|
+
"Programming Language :: Python :: 3.8",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
25
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"flask>=2.0",
|
|
29
|
+
"flask-sock>=0.7.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/CGandGameEngineLearner/handshake-prompt"
|
|
34
|
+
Repository = "https://github.com/CGandGameEngineLearner/handshake-prompt"
|
|
35
|
+
Issues = "https://github.com/CGandGameEngineLearner/handshake-prompt/issues"
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
dev = [
|
|
39
|
+
"pytest>=7.0",
|
|
40
|
+
"pytest-flask>=1.3",
|
|
41
|
+
"build",
|
|
42
|
+
"twine",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[tool.setuptools.packages.find]
|
|
46
|
+
where = ["."]
|
|
47
|
+
include = ["handshake_prompt*"]
|