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.
@@ -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,8 @@
1
+ flask>=2.0
2
+ flask-sock>=0.7.0
3
+
4
+ [dev]
5
+ pytest>=7.0
6
+ pytest-flask>=1.3
7
+ build
8
+ twine
@@ -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*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+