@agentsdock/server 1.0.7-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +19 -0
  3. package/README.md +60 -0
  4. package/npm/cli.cjs +289 -0
  5. package/package.json +32 -0
  6. package/server/LICENSE +202 -0
  7. package/server/NOTICE +19 -0
  8. package/server/VERSION +1 -0
  9. package/server/activation_transaction.py +2458 -0
  10. package/server/agent_server.py +93226 -0
  11. package/server/agentsdock_chats.py +1237 -0
  12. package/server/agentsdock_emergency.py +338 -0
  13. package/server/agentsdock_jobs.py +640 -0
  14. package/server/agentsdock_mail.py +325 -0
  15. package/server/agentsdock_publish.py +404 -0
  16. package/server/agentsdock_team.py +793 -0
  17. package/server/agentsdock_team_hub/__init__.py +33 -0
  18. package/server/agentsdock_team_hub/auth.py +710 -0
  19. package/server/agentsdock_team_hub/cli.py +647 -0
  20. package/server/agentsdock_team_hub/database.py +310 -0
  21. package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
  22. package/server/agentsdock_team_hub/mail_hints.py +201 -0
  23. package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
  24. package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
  25. package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
  26. package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
  27. package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
  28. package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
  29. package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
  30. package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
  31. package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
  32. package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
  33. package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
  34. package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
  35. package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
  36. package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
  37. package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
  38. package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
  39. package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
  40. package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
  41. package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
  42. package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
  43. package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
  44. package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
  45. package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
  46. package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
  47. package/server/agentsdock_team_hub/notification_hints.py +306 -0
  48. package/server/agentsdock_team_hub/secure_peer.py +11215 -0
  49. package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
  50. package/server/agentsdock_team_hub/security.py +318 -0
  51. package/server/agentsdock_team_hub/service.py +1737 -0
  52. package/server/agentsdock_team_hub/store.py +16276 -0
  53. package/server/chat_mailbox.py +448 -0
  54. package/server/claude_background_reconciliation.py +92 -0
  55. package/server/claude_goals.py +245 -0
  56. package/server/claude_history_provenance.py +137 -0
  57. package/server/claude_history_repair.py +1601 -0
  58. package/server/claude_sdk_client.py +3731 -0
  59. package/server/claude_side_question.py +212 -0
  60. package/server/codex_app_server.py +3030 -0
  61. package/server/codex_auth.py +93 -0
  62. package/server/codex_history_repair.py +1247 -0
  63. package/server/codex_provider.py +1120 -0
  64. package/server/codex_side_question.py +301 -0
  65. package/server/cursor_agent_client.py +654 -0
  66. package/server/cursor_process_guard.py +347 -0
  67. package/server/execution_activation.py +610 -0
  68. package/server/execution_control.py +233 -0
  69. package/server/execution_durability.py +184 -0
  70. package/server/execution_http.py +256 -0
  71. package/server/execution_install.py +792 -0
  72. package/server/execution_legacy_runner.py +406 -0
  73. package/server/execution_maintenance.py +137 -0
  74. package/server/execution_manage.py +615 -0
  75. package/server/execution_ownership.py +83 -0
  76. package/server/execution_preparation.py +362 -0
  77. package/server/execution_recovery.py +845 -0
  78. package/server/execution_recovery_status.py +195 -0
  79. package/server/execution_service.py +387 -0
  80. package/server/execution_transport.py +622 -0
  81. package/server/execution_uninstall.py +406 -0
  82. package/server/execution_update_status.py +49 -0
  83. package/server/install.sh +7074 -0
  84. package/server/interactive_chat_controls.py +153 -0
  85. package/server/interactive_chat_native.py +66 -0
  86. package/server/interactive_chat_projection.py +152 -0
  87. package/server/interactive_chat_runtime.py +132 -0
  88. package/server/interactive_chat_share_routes.py +534 -0
  89. package/server/interactive_chat_share_web.py +12 -0
  90. package/server/interactive_chat_shares.py +238 -0
  91. package/server/provider_commands.py +425 -0
  92. package/server/public_chat_share_routes.py +426 -0
  93. package/server/public_chat_shares.py +759 -0
  94. package/server/public_chat_transcript.py +357 -0
  95. package/server/pyproject.toml +29 -0
  96. package/server/release-public-key.pem +3 -0
  97. package/server/secure_peer_delivery.py +1236 -0
  98. package/server/secure_peer_runtime.py +7694 -0
  99. package/server/shared_chat_video_stream.py +262 -0
  100. package/server/shared_chat_videos.py +155 -0
  101. package/server/side_questions.py +594 -0
  102. package/server/team_hub_host.py +1354 -0
  103. package/server/team_mail_grants.py +136 -0
  104. package/server/team_mail_runtime.py +302 -0
  105. package/server/team_mail_websocket.py +211 -0
  106. package/server/title_generation.py +333 -0
  107. package/server/uninstall.sh +359 -0
  108. package/server/update_handoff.py +237 -0
  109. package/server/update_preparation.py +264 -0
  110. package/server/update_recovery.py +140 -0
  111. package/server/update_runner.py +1819 -0
  112. package/server/uv.lock +1101 -0
  113. package/server/workspace_git.py +545 -0
@@ -0,0 +1,759 @@
1
+ """Private, immutable snapshots for explicitly shared, read-only conversations.
2
+
3
+ This module does not discover sessions, read transcripts, authenticate requests,
4
+ or choose a public origin. The caller must project and review the user/assistant
5
+ text before calling ``create_share``. Only the creation result contains the raw
6
+ bearer token; reopening or listing the store cannot recover a share URL.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ from contextlib import contextmanager
13
+ from datetime import datetime, timezone
14
+ import hashlib
15
+ import hmac
16
+ import html
17
+ import json
18
+ import math
19
+ import os
20
+ from pathlib import Path
21
+ import re
22
+ import secrets
23
+ import sqlite3
24
+ import stat
25
+ import time
26
+ from typing import Any, Callable, Iterator
27
+
28
+ from shared_chat_videos import normalize_shared_chat_videos
29
+
30
+
31
+ MAX_MESSAGE_TEXT_BYTES = 256 * 1024
32
+ MAX_SNAPSHOT_BYTES = 2 * 1024 * 1024
33
+ MAX_RENDER_BYTES = 16 * 1024 * 1024
34
+ MAX_SNAPSHOT_PAGE_MESSAGES = 100
35
+ MAX_TITLE_CHARACTERS = 256
36
+ MAX_LIST_LIMIT = 100
37
+ MAX_UNIX_TIMESTAMP = 253402300799
38
+ DEFAULT_TITLE = "Shared conversation"
39
+ TOKEN_PATTERN = re.compile(r"[A-Za-z0-9_-]{43}\Z", re.ASCII)
40
+ SHARE_ID_PATTERN = re.compile(r"share_[a-f0-9]{32}\Z", re.ASCII)
41
+
42
+
43
+ class PublicChatShareValidationError(ValueError):
44
+ """Input is not an already-projected, bounded public conversation."""
45
+
46
+
47
+ class PublicChatShareUnavailable(LookupError):
48
+ """Use the same public 404 response for every unavailable share."""
49
+
50
+ def __init__(self) -> None:
51
+ super().__init__("Shared conversation is unavailable.")
52
+
53
+
54
+ def _timestamp(value: Any, label: str) -> int | float:
55
+ if (
56
+ isinstance(value, bool)
57
+ or not isinstance(value, (int, float))
58
+ or not 0 <= value <= MAX_UNIX_TIMESTAMP
59
+ or not math.isfinite(value)
60
+ ):
61
+ raise PublicChatShareValidationError(f"{label} must be finite Unix seconds.")
62
+ return value
63
+
64
+
65
+ def _utf8_size(value: str, label: str, limit: int) -> int:
66
+ # Check character count first to avoid encoding a grossly oversized input.
67
+ if len(value) > limit:
68
+ raise PublicChatShareValidationError(f"{label} is too large.")
69
+ try:
70
+ length = len(value.encode("utf-8"))
71
+ except UnicodeEncodeError as exc:
72
+ raise PublicChatShareValidationError(f"{label} must be valid UTF-8.") from exc
73
+ if length > limit:
74
+ raise PublicChatShareValidationError(f"{label} is too large.")
75
+ return length
76
+
77
+
78
+ def _session_id(value: Any) -> str:
79
+ if not isinstance(value, str) or not value or "\x00" in value:
80
+ raise PublicChatShareValidationError("session_id must be a nonempty string.")
81
+ _utf8_size(value, "session_id", 1024)
82
+ return value
83
+
84
+
85
+ def _snapshot(messages: Any, title: Any, created_at: Any) -> tuple[dict[str, Any], bytes]:
86
+ if title is None:
87
+ title = DEFAULT_TITLE
88
+ if not isinstance(title, str) or not title.strip() or len(title) > MAX_TITLE_CHARACTERS:
89
+ raise PublicChatShareValidationError("title must contain 1 to 256 characters.")
90
+ total_bytes = _utf8_size(title, "title", MAX_TITLE_CHARACTERS * 4)
91
+ _timestamp(created_at, "created_at")
92
+ if not isinstance(messages, list) or not messages:
93
+ raise PublicChatShareValidationError("messages must contain at least one item.")
94
+ serialized_bytes = len(json.dumps({"title": title, "created_at": created_at, "messages": []},
95
+ ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8"))
96
+ projected: list[dict[str, Any]] = []
97
+ for message in messages:
98
+ if not isinstance(message, dict) or set(message) - {"role", "text", "timestamp", "videos"}:
99
+ raise PublicChatShareValidationError("Messages may contain only role, text, timestamp, and videos.")
100
+ role = message.get("role")
101
+ text = message.get("text")
102
+ if role not in ("user", "assistant") or not isinstance(text, str):
103
+ raise PublicChatShareValidationError("Each message needs a user/assistant role and text.")
104
+ total_bytes += _utf8_size(text, "message text", MAX_MESSAGE_TEXT_BYTES)
105
+ if total_bytes > MAX_SNAPSHOT_BYTES:
106
+ raise PublicChatShareValidationError("Snapshot is too large.")
107
+ item = {"role": role, "text": text}
108
+ if "videos" in message:
109
+ try:
110
+ videos = normalize_shared_chat_videos(message["videos"])
111
+ except ValueError as exc:
112
+ raise PublicChatShareValidationError("Invalid shared video metadata.") from exc
113
+ if videos:
114
+ item["videos"] = videos
115
+ if "timestamp" in message:
116
+ item["timestamp"] = _timestamp(message["timestamp"], "message timestamp")
117
+ serialized_bytes += len(json.dumps(item, ensure_ascii=False, separators=(",", ":"),
118
+ allow_nan=False).encode("utf-8")) + bool(projected)
119
+ if serialized_bytes > MAX_SNAPSHOT_BYTES:
120
+ raise PublicChatShareValidationError("Snapshot is too large.")
121
+ projected.append(item)
122
+ snapshot = {"title": title, "created_at": created_at, "messages": projected}
123
+ encoded = json.dumps(snapshot, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8")
124
+ if len(encoded) > MAX_SNAPSHOT_BYTES:
125
+ raise PublicChatShareValidationError("Snapshot is too large.")
126
+ return snapshot, encoded
127
+
128
+
129
+ class PublicChatShareStore:
130
+ """A dedicated owner-only directory containing a durable SQLite database.
131
+
132
+ A fresh SQLite connection is used per operation, so instances can be shared
133
+ between request threads. Snapshot rows and revocations are append-only.
134
+ Revocation blocks subsequent lookups; it cannot retract bytes already sent
135
+ by a request that read the snapshot before the revocation committed.
136
+ """
137
+
138
+ def __init__(self, storage_root: str | os.PathLike[str], *, now: Callable[[], float] = time.time):
139
+ root = Path(storage_root)
140
+ if not root.is_absolute():
141
+ raise PublicChatShareValidationError("storage_root must be an explicit absolute directory.")
142
+ root.mkdir(mode=0o700, parents=True, exist_ok=True)
143
+ self.storage_root = root
144
+ self.database_path = root / "snapshots.sqlite3"
145
+ self._now = now
146
+ self._check_directory()
147
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
148
+ try:
149
+ descriptor = os.open(self.database_path, flags, 0o600)
150
+ except FileExistsError:
151
+ # Inspect existing paths before opening them: a FIFO or device must
152
+ # not be opened, and an existing unsafe file must not be chmodded.
153
+ self._check_database_stat(self.database_path.lstat())
154
+ else:
155
+ try:
156
+ self._check_database_stat(os.fstat(descriptor))
157
+ finally:
158
+ os.close(descriptor)
159
+ with self._connection(write=True) as connection:
160
+ connection.execute("BEGIN IMMEDIATE")
161
+ self._ensure_schema(connection)
162
+
163
+ @staticmethod
164
+ def _create_tables(connection, suffix=""):
165
+ connection.execute(
166
+ f"""CREATE TABLE IF NOT EXISTS public_chat_shares{suffix} (
167
+ share_id TEXT PRIMARY KEY,
168
+ session_id TEXT NOT NULL,
169
+ token_hash BLOB NOT NULL UNIQUE CHECK(length(token_hash) = 32),
170
+ title TEXT NOT NULL,
171
+ created_at REAL NOT NULL,
172
+ expires_at REAL,
173
+ message_count INTEGER NOT NULL CHECK(message_count >= 1),
174
+ snapshot_json BLOB NOT NULL CHECK(length(snapshot_json) <= 2097152),
175
+ snapshot_sha256 BLOB NOT NULL CHECK(length(snapshot_sha256) = 32)
176
+ )"""
177
+ )
178
+ connection.execute(
179
+ f"""CREATE TABLE IF NOT EXISTS public_chat_share_revocations{suffix} (
180
+ share_id TEXT PRIMARY KEY REFERENCES public_chat_shares{suffix}(share_id),
181
+ revoked_at REAL NOT NULL
182
+ )"""
183
+ )
184
+
185
+ @classmethod
186
+ def _ensure_schema(cls, connection):
187
+ """Authenticated-write-only, atomic preservation of immutable v1 rows."""
188
+ version = connection.execute("PRAGMA user_version").fetchone()[0]
189
+ if version not in (0, 1, 2, 3):
190
+ raise OSError("Unsupported public chat share database version.")
191
+ if version == 1:
192
+ cls._create_tables(connection, "_v2")
193
+ connection.execute("INSERT INTO public_chat_shares_v2 SELECT * FROM public_chat_shares")
194
+ connection.execute("INSERT INTO public_chat_share_revocations_v2 SELECT * FROM public_chat_share_revocations")
195
+ connection.execute("DROP TABLE public_chat_share_revocations")
196
+ connection.execute("DROP TABLE public_chat_shares")
197
+ connection.execute("ALTER TABLE public_chat_shares_v2 RENAME TO public_chat_shares")
198
+ connection.execute("ALTER TABLE public_chat_share_revocations_v2 RENAME TO public_chat_share_revocations")
199
+ else:
200
+ cls._create_tables(connection)
201
+ connection.execute(
202
+ "CREATE INDEX IF NOT EXISTS public_chat_shares_session ON public_chat_shares(session_id, created_at DESC)"
203
+ )
204
+ for table in ("public_chat_shares", "public_chat_share_revocations"):
205
+ for operation in ("UPDATE", "DELETE"):
206
+ connection.execute(
207
+ f"CREATE TRIGGER IF NOT EXISTS {table}_no_{operation.lower()} "
208
+ f"BEFORE {operation} ON {table} BEGIN "
209
+ "SELECT RAISE(ABORT, 'Public chat shares are immutable'); END"
210
+ )
211
+ connection.execute("""CREATE TABLE IF NOT EXISTS public_chat_share_pages (
212
+ share_id TEXT NOT NULL REFERENCES public_chat_shares(share_id) DEFERRABLE INITIALLY DEFERRED,
213
+ page_index INTEGER NOT NULL CHECK(page_index >= 1),
214
+ snapshot_json BLOB NOT NULL CHECK(length(snapshot_json) <= 2097152),
215
+ snapshot_sha256 BLOB NOT NULL CHECK(length(snapshot_sha256) = 32),
216
+ PRIMARY KEY(share_id, page_index))""")
217
+ for operation in ("UPDATE", "DELETE"):
218
+ connection.execute(f"CREATE TRIGGER IF NOT EXISTS public_chat_share_pages_no_{operation.lower()} "
219
+ f"BEFORE {operation} ON public_chat_share_pages BEGIN "
220
+ "SELECT RAISE(ABORT, 'Public chat shares are immutable'); END")
221
+ connection.execute("PRAGMA user_version = 3")
222
+
223
+ @classmethod
224
+ def open_existing(
225
+ cls, storage_root: str | os.PathLike[str], *, now: Callable[[], float] = time.time,
226
+ ) -> "PublicChatShareStore":
227
+ """Open an initialized store without creating paths or writing schema.
228
+
229
+ Used by cold public viewers after restart. A damaged/missing/unknown
230
+ schema is not repaired by an anonymous request; only authenticated
231
+ creation may initialize a new store.
232
+ """
233
+ root = Path(storage_root)
234
+ if not root.is_absolute():
235
+ raise PublicChatShareValidationError("storage_root must be an explicit absolute directory.")
236
+ instance = cls.__new__(cls)
237
+ instance.storage_root = root
238
+ instance.database_path = root / "snapshots.sqlite3"
239
+ instance._now = now
240
+ with instance._connection() as connection:
241
+ if connection.execute("PRAGMA user_version").fetchone()[0] not in (1, 2, 3):
242
+ raise OSError("Unsupported public chat share database version.")
243
+ return instance
244
+
245
+ def _check_directory(self) -> None:
246
+ info = self.storage_root.lstat()
247
+ if not stat.S_ISDIR(info.st_mode) or info.st_mode & 0o077 or info.st_uid != os.getuid():
248
+ raise OSError("Public chat share storage must be a private, owned, non-symlink directory.")
249
+
250
+ @staticmethod
251
+ def _check_database_stat(info: os.stat_result) -> None:
252
+ if (
253
+ not stat.S_ISREG(info.st_mode)
254
+ or info.st_mode & 0o077
255
+ or info.st_uid != os.getuid()
256
+ or info.st_nlink != 1
257
+ ):
258
+ raise OSError("Public chat share database must be a private, owned, single-link regular file.")
259
+
260
+ @contextmanager
261
+ def _connection(self, *, write: bool = False) -> Iterator[sqlite3.Connection]:
262
+ self._check_directory()
263
+ self._check_database_stat(self.database_path.lstat())
264
+ # The checked database must already exist; SQLite must not create an
265
+ # alternate path. The private directory also protects rollback journals.
266
+ mode = "rw" if write else "ro"
267
+ connection = sqlite3.connect(f"{self.database_path.as_uri()}?mode={mode}", uri=True, timeout=5)
268
+ connection.row_factory = sqlite3.Row
269
+ try:
270
+ connection.execute("PRAGMA foreign_keys = ON")
271
+ connection.execute("PRAGMA trusted_schema = OFF")
272
+ if write:
273
+ connection.execute("PRAGMA synchronous = FULL")
274
+ with connection:
275
+ yield connection
276
+ finally:
277
+ connection.close()
278
+
279
+ @staticmethod
280
+ def _metadata(row: sqlite3.Row) -> dict[str, Any]:
281
+ return {key: row[key] for key in (
282
+ "share_id", "session_id", "title", "created_at", "expires_at", "revoked_at", "message_count"
283
+ )}
284
+
285
+ def create_share(
286
+ self,
287
+ session_id: str,
288
+ messages: list[dict[str, Any]],
289
+ *,
290
+ title: str | None = None,
291
+ expires_at: int | float | None = None,
292
+ ) -> dict[str, Any]:
293
+ session_id = _session_id(session_id)
294
+ created_at = _timestamp(self._now(), "current time")
295
+ if expires_at is not None and _timestamp(expires_at, "expires_at") <= created_at:
296
+ raise PublicChatShareValidationError("expires_at must be in the future.")
297
+ snapshot, encoded = _snapshot(messages, title, created_at)
298
+ token = secrets.token_urlsafe(32)
299
+ share_id = "share_" + secrets.token_hex(16)
300
+ token_hash = hashlib.sha256(token.encode("ascii")).digest()
301
+ with self._connection(write=True) as connection:
302
+ connection.execute("BEGIN IMMEDIATE")
303
+ # An anonymous view may already have cached a read-only v1 store.
304
+ self._ensure_schema(connection)
305
+ connection.execute(
306
+ """INSERT INTO public_chat_shares
307
+ (share_id, session_id, token_hash, title, created_at, expires_at,
308
+ message_count, snapshot_json, snapshot_sha256)
309
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
310
+ (share_id, session_id, token_hash, snapshot["title"], created_at, expires_at,
311
+ len(snapshot["messages"]), encoded, hashlib.sha256(encoded).digest()),
312
+ )
313
+ return {
314
+ "share_id": share_id, "session_id": session_id, "title": snapshot["title"],
315
+ "created_at": created_at, "expires_at": expires_at, "revoked_at": None,
316
+ "message_count": len(snapshot["messages"]), "token": token,
317
+ }
318
+
319
+ def list_shares(self, session_id: str, *, limit: int = MAX_LIST_LIMIT) -> list[dict[str, Any]]:
320
+ session_id = _session_id(session_id)
321
+ if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= MAX_LIST_LIMIT:
322
+ raise PublicChatShareValidationError("limit must be an integer between 1 and 100.")
323
+ with self._connection() as connection:
324
+ rows = connection.execute(
325
+ """SELECT s.share_id, s.session_id, s.title, s.created_at, s.expires_at,
326
+ s.message_count, r.revoked_at
327
+ FROM public_chat_shares AS s
328
+ LEFT JOIN public_chat_share_revocations AS r USING (share_id)
329
+ WHERE s.session_id = ? ORDER BY s.created_at DESC, s.share_id DESC LIMIT ?""",
330
+ (session_id, limit),
331
+ ).fetchall()
332
+ return [self._metadata(row) for row in rows]
333
+
334
+ def revoke_share(self, share_id: str, *, session_id: str) -> bool:
335
+ session_id = _session_id(session_id)
336
+ if not isinstance(share_id, str) or SHARE_ID_PATTERN.fullmatch(share_id) is None:
337
+ return False
338
+ revoked_at = _timestamp(self._now(), "current time")
339
+ with self._connection(write=True) as connection:
340
+ connection.execute("BEGIN IMMEDIATE")
341
+ exists = connection.execute(
342
+ "SELECT 1 FROM public_chat_shares WHERE share_id = ? AND session_id = ?",
343
+ (share_id, session_id),
344
+ ).fetchone()
345
+ if exists is None:
346
+ return False
347
+ connection.execute(
348
+ "INSERT OR IGNORE INTO public_chat_share_revocations (share_id, revoked_at) VALUES (?, ?)",
349
+ (share_id, revoked_at),
350
+ )
351
+ return True
352
+
353
+ def create_streamed_share(self, session_id: str, load_messages: Callable, *, title=None, expires_at=None) -> dict[str, Any]:
354
+ """Capture arbitrarily many bounded pages atomically from a message sink.
355
+
356
+ The loader must emit projected messages synchronously and raise before
357
+ returning if a reviewed digest changed. No whole-chat list/JSON is kept.
358
+ Existing snapshots retain their original first-page representation.
359
+ """
360
+ session_id = _session_id(session_id)
361
+ created_at = _timestamp(self._now(), "current time")
362
+ if expires_at is not None and _timestamp(expires_at, "expires_at") <= created_at:
363
+ raise PublicChatShareValidationError("expires_at must be in the future.")
364
+ # Validate metadata before starting the transcript scan.
365
+ seed, _ = _snapshot([{"role": "user", "text": ""}], title, created_at)
366
+ title = seed["title"]
367
+ token, share_id = secrets.token_urlsafe(32), "share_" + secrets.token_hex(16)
368
+ first_page = None
369
+ page: list[dict[str, Any]] = []
370
+ page_bytes = 0
371
+ page_index = 0
372
+ total = 0
373
+ overhead = len(json.dumps({"title": title, "created_at": created_at, "messages": []},
374
+ ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8"))
375
+ with self._connection(write=True) as connection:
376
+ connection.execute("BEGIN IMMEDIATE")
377
+ self._ensure_schema(connection)
378
+
379
+ def flush() -> None:
380
+ nonlocal first_page, page_index, page_bytes
381
+ if not page:
382
+ return
383
+ _, encoded = _snapshot(page, title, created_at)
384
+ if page_index == 0:
385
+ first_page = encoded
386
+ else:
387
+ connection.execute("INSERT INTO public_chat_share_pages VALUES(?,?,?,?)",
388
+ (share_id, page_index, encoded, hashlib.sha256(encoded).digest()))
389
+ page_index += 1
390
+ page.clear()
391
+ page_bytes = 0
392
+
393
+ def emit(message) -> None:
394
+ nonlocal total, page_bytes
395
+ checked, _ = _snapshot([message], title, created_at)
396
+ item = checked["messages"][0]
397
+ size = len(json.dumps(item, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8"))
398
+ if page and (len(page) >= MAX_SNAPSHOT_PAGE_MESSAGES or overhead + page_bytes + size + len(page) > MAX_SNAPSHOT_BYTES):
399
+ flush()
400
+ page.append(item)
401
+ page_bytes += size
402
+ total += 1
403
+
404
+ load_messages(emit)
405
+ flush()
406
+ if first_page is None:
407
+ raise PublicChatShareValidationError("messages must contain at least one item.")
408
+ connection.execute("""INSERT INTO public_chat_shares
409
+ (share_id,session_id,token_hash,title,created_at,expires_at,message_count,snapshot_json,snapshot_sha256)
410
+ VALUES(?,?,?,?,?,?,?,?,?)""", (share_id, session_id, hashlib.sha256(token.encode("ascii")).digest(),
411
+ title, created_at, expires_at, total, first_page, hashlib.sha256(first_page).digest()))
412
+ return {"share_id": share_id, "session_id": session_id, "title": title, "created_at": created_at,
413
+ "expires_at": expires_at, "revoked_at": None, "message_count": total, "token": token}
414
+
415
+ def get_snapshot(self, token: str, *, share_id: str | None = None) -> dict[str, Any]:
416
+ return self.get_snapshot_page(token, share_id=share_id, page=0)["snapshot"]
417
+
418
+ def get_snapshot_page(self, token: str, *, share_id: str | None = None, page: int | None = None) -> dict[str, Any]:
419
+ value = self._get_snapshot_page(token, share_id=share_id, page=page)
420
+ value.pop("session_id")
421
+ return value
422
+
423
+ def _get_snapshot_page(self, token: str, *, share_id: str | None = None, page: int | None = None) -> dict[str, Any]:
424
+ # Invalid input is rejected before storage I/O, without echoing a token.
425
+ if not isinstance(token, str) or TOKEN_PATTERN.fullmatch(token) is None:
426
+ raise PublicChatShareUnavailable()
427
+ if share_id is not None and (not isinstance(share_id, str) or SHARE_ID_PATTERN.fullmatch(share_id) is None):
428
+ raise PublicChatShareUnavailable()
429
+ if page is not None and (type(page) is not int or not 0 <= page <= 2**31 - 1):
430
+ raise PublicChatShareUnavailable()
431
+ token_hash = hashlib.sha256(token.encode("ascii")).digest()
432
+ current_time = _timestamp(self._now(), "current time")
433
+ with self._connection() as connection:
434
+ row = connection.execute(
435
+ """SELECT s.share_id, s.session_id, s.message_count, s.snapshot_json, s.snapshot_sha256 FROM public_chat_shares AS s
436
+ WHERE s.token_hash = ? AND (? IS NULL OR s.share_id = ?)
437
+ AND (s.expires_at IS NULL OR s.expires_at > ?)
438
+ AND NOT EXISTS (SELECT 1 FROM public_chat_share_revocations AS r
439
+ WHERE r.share_id = s.share_id)""",
440
+ (token_hash, share_id, share_id, current_time),
441
+ ).fetchone()
442
+ if row is None:
443
+ raise PublicChatShareUnavailable()
444
+ session_id = row["session_id"]
445
+ message_count = row["message_count"]
446
+ has_pages = connection.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='public_chat_share_pages'").fetchone()
447
+ page_count = 1 + (connection.execute("SELECT count(*) FROM public_chat_share_pages WHERE share_id=?", (row["share_id"],)).fetchone()[0] if has_pages else 0)
448
+ page = page_count - 1 if page is None else page
449
+ if page >= page_count:
450
+ raise PublicChatShareUnavailable()
451
+ if page:
452
+ row = connection.execute("SELECT snapshot_json,snapshot_sha256 FROM public_chat_share_pages WHERE share_id=? AND page_index=?", (row["share_id"], page)).fetchone()
453
+ if row is None:
454
+ raise PublicChatShareUnavailable()
455
+ encoded = row["snapshot_json"]
456
+ digest = row["snapshot_sha256"]
457
+ if (
458
+ not isinstance(encoded, bytes) or len(encoded) > MAX_SNAPSHOT_BYTES
459
+ or not isinstance(digest, bytes)
460
+ or not hmac.compare_digest(hashlib.sha256(encoded).digest(), digest)
461
+ ):
462
+ raise PublicChatShareUnavailable()
463
+ try:
464
+ decoded = json.loads(encoded)
465
+ if not isinstance(decoded, dict) or set(decoded) != {"title", "created_at", "messages"}:
466
+ raise PublicChatShareUnavailable()
467
+ snapshot, _ = _snapshot(decoded["messages"], decoded["title"], decoded["created_at"])
468
+ except (ValueError, UnicodeError, RecursionError) as exc:
469
+ raise PublicChatShareUnavailable() from exc
470
+ return {"snapshot": snapshot, "page": page, "page_count": page_count, "message_count": message_count,
471
+ "session_id": session_id}
472
+
473
+ def get_snapshot_video(self, token: str, *, page: int, message_index: int, video_index: int,
474
+ share_id: str | None = None) -> dict[str, Any]:
475
+ """Resolve only a descriptor already frozen in this exact bounded page."""
476
+ if any(type(index) is not int or not 0 <= index <= 2**31 - 1
477
+ for index in (page, message_index, video_index)):
478
+ raise PublicChatShareUnavailable()
479
+ value = self._get_snapshot_page(token, share_id=share_id, page=page)
480
+ try:
481
+ video = value["snapshot"]["messages"][message_index]["videos"][video_index]
482
+ except (IndexError, KeyError):
483
+ raise PublicChatShareUnavailable() from None
484
+ return {"session_id": value["session_id"], "video": video}
485
+
486
+ def authorize_access(self, token: str, *, share_id: str | None = None) -> None:
487
+ """Cheap read-only stream recheck; never reload or render a transcript page."""
488
+ if (not isinstance(token, str) or TOKEN_PATTERN.fullmatch(token) is None
489
+ or share_id is not None and (not isinstance(share_id, str)
490
+ or SHARE_ID_PATTERN.fullmatch(share_id) is None)):
491
+ raise PublicChatShareUnavailable()
492
+ token_hash = hashlib.sha256(token.encode("ascii")).digest()
493
+ current_time = _timestamp(self._now(), "current time")
494
+ with self._connection() as connection:
495
+ row = connection.execute(
496
+ """SELECT 1 FROM public_chat_shares AS s
497
+ WHERE s.token_hash = ? AND (? IS NULL OR s.share_id = ?)
498
+ AND (s.expires_at IS NULL OR s.expires_at > ?)
499
+ AND NOT EXISTS (SELECT 1 FROM public_chat_share_revocations AS r
500
+ WHERE r.share_id = s.share_id)""",
501
+ (token_hash, share_id, share_id, current_time),
502
+ ).fetchone()
503
+ if row is None:
504
+ raise PublicChatShareUnavailable()
505
+
506
+
507
+ _STYLE = """html{color-scheme:light dark;--page:#f7f8fa;--surface:#fff;--ink:#222831;--muted:#69727e;--line:#e4e7eb;--green:#287346;--user:#e6f3e9;--user-line:#d1e7d8;--code:#f3f5f7;--mark:#ecf5ee}
508
+ *{box-sizing:border-box}body{margin:0;background:var(--page);color:var(--ink);font:15px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;-webkit-font-smoothing:antialiased}::selection{background:#9cceb1;color:#14261c}
509
+ .masthead{height:76px;border-bottom:1px solid var(--line);background:var(--surface)}.masthead-inner{max-width:1008px;height:100%;margin:0 auto;padding:0 32px;display:flex;align-items:center;justify-content:space-between;gap:16px}.brand{display:flex;align-items:center;gap:10px;font-size:15px;font-weight:650;letter-spacing:-.35px}.brand-mark{width:26px;height:26px;border-radius:8px;background:var(--green);color:#fff;display:grid;place-items:center;font-size:17px;font-weight:750;line-height:1}.masthead-label{color:var(--muted);font-size:12px}
510
+ main{max-width:864px;margin:0 auto;padding:54px 32px 36px}.conversation-header{padding-bottom:30px;border-bottom:1px solid var(--line);margin-bottom:32px}.eyebrow{display:flex;align-items:center;gap:12px;margin-bottom:16px;color:var(--muted);font-size:11px;font-weight:650;letter-spacing:1.4px;text-transform:uppercase}.view-badge{display:inline-flex;align-items:center;gap:6px;padding:3px 9px;border:1px solid var(--user-line);border-radius:6px;background:var(--mark);color:var(--green);font-size:11px;line-height:1.5;letter-spacing:0;text-transform:none}.view-badge:before{content:"";width:5px;height:5px;border-radius:50%;background:currentColor}h1{margin:0;font-size:34px;line-height:1.2;font-weight:650;letter-spacing:-1.15px;overflow-wrap:anywhere}.description{margin:13px 0 17px;color:var(--muted);font-size:14px}.metadata{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px;color:var(--muted);font-size:12px}.metadata-separator{opacity:.5}
511
+ .transcript{display:flex;flex-direction:column;gap:28px}.message{min-width:0}.message-header{display:flex;align-items:center;gap:8px;margin-bottom:9px}.avatar{display:grid;place-items:center;width:25px;height:25px;border-radius:8px;background:var(--surface);border:1px solid var(--line);color:var(--muted);font-size:11px;font-weight:700}.message-label{margin:0;font-size:12px;font-weight:650;letter-spacing:.05px}.message time{font-size:11px;color:var(--muted);margin-left:3px}.message-body{padding:21px 24px;border:1px solid var(--line);border-radius:4px 16px 16px 16px;background:var(--surface);overflow-wrap:anywhere;min-width:0}.user{width:88%;align-self:flex-end}.user .message-header{justify-content:flex-end}.user .avatar{background:var(--mark);border-color:var(--user-line);color:var(--green)}.user .message-body{background:var(--user);border-color:var(--user-line);border-radius:16px 4px 16px 16px}.plain-text{white-space:pre-wrap;tab-size:4}
512
+ .markdown>:first-child{margin-top:0}.markdown>:last-child{margin-bottom:0}.markdown p{margin:0 0 16px;white-space:pre-wrap}.markdown h2,.markdown h3,.markdown h4,.markdown h5,.markdown h6{margin:24px 0 10px;font-weight:650;line-height:1.4;letter-spacing:-.25px}.markdown h2{font-size:21px}.markdown h3{font-size:18px}.markdown h4,.markdown h5,.markdown h6{font-size:16px}.markdown ul,.markdown ol{padding-left:23px;margin:10px 0 18px}.markdown li{padding-left:3px;margin:5px 0;white-space:pre-wrap}.markdown li::marker{color:var(--muted)}.markdown blockquote{margin:18px 0;padding:3px 0 3px 17px;border-left:3px solid var(--user-line);color:var(--muted);white-space:pre-wrap}.markdown code{font:12.5px/1.65 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;background:var(--code);border-radius:4px;padding:2px 5px}.code-block{margin:18px 0;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:var(--code)}.code-language{border-bottom:1px solid var(--line);padding:7px 14px;font-size:11px;color:var(--muted)}.code-block pre{margin:0;padding:14px 16px;overflow:auto;tab-size:4;white-space:pre;overscroll-behavior-x:contain}.code-block code{padding:0;border-radius:0;font-size:12px;background:transparent}.table-wrap{max-width:100%;overflow:auto;margin:18px 0;border:1px solid var(--line);border-radius:9px;overscroll-behavior-x:contain}.markdown table{width:100%;border-collapse:collapse;font-size:13px;line-height:1.55}.markdown th,.markdown td{padding:11px 13px;text-align:left;border-bottom:1px solid var(--line);min-width:90px;overflow-wrap:anywhere}.markdown th{background:var(--code);font-weight:600}.markdown tbody tr:last-child td{border-bottom:0}.markdown hr{border:0;border-top:1px solid var(--line);margin:24px 0}
513
+ .conversation-footer{margin-top:38px;padding-top:23px;border-top:1px solid var(--line);text-align:center;color:var(--muted);font-size:12px}.conversation-footer p{margin:0 0 4px}.footer-brand{font-size:11px;opacity:.8}
514
+ .unlock-main{max-width:520px;padding-top:76px}.unlock-card{padding:30px;border:1px solid var(--line);border-radius:18px;background:var(--surface)}.unlock-card h1{font-size:27px;letter-spacing:-.7px}.unlock-card label{display:block;font-size:13px;font-weight:600;margin:20px 0 7px}.unlock-card input[type=password]{width:100%;min-width:0;padding:12px;border:1px solid var(--line);border-radius:9px;background:var(--page);color:var(--ink);font:14px ui-monospace,SFMono-Regular,Menlo,monospace}.unlock-card input:focus-visible,.unlock-card button:focus-visible{outline:2px solid var(--green);outline-offset:3px}.unlock-card .remember{display:flex;gap:8px;align-items:center;margin:16px 0;color:var(--muted);font-size:12px;font-weight:400}.remember input{accent-color:var(--green)}.unlock-card button{width:100%;padding:12px 16px;border:1px solid var(--user-line);border-radius:9px;background:var(--user);color:var(--green);font:600 14px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;cursor:pointer}.unlock-error{font-size:13px;color:#bd4444}.unlock-help{margin:18px 0 0;font-size:12px;color:var(--muted)}
515
+ .pagination{display:flex;justify-content:space-between;align-items:center;gap:12px;margin:22px 0;font-size:12px;color:var(--muted)}.pagination a{display:inline-block;border:1px solid var(--line);border-radius:8px;padding:7px 12px;color:var(--green);background:var(--surface);text-decoration:none}.pagination a:hover{background:var(--mark)}.pagination a:focus-visible{outline:2px solid var(--green);outline-offset:3px}.pagination span{flex:1;text-align:center}
516
+ @media(prefers-color-scheme:dark){html{--page:#141619;--surface:#1b1e22;--ink:#e7e9ed;--muted:#9da5b0;--line:#2b3037;--green:#8bd5a5;--user:#213b2c;--user-line:#31513c;--code:#171a1e;--mark:#22372b}.brand-mark{background:#397f51;color:#f4fff7}}
517
+ @media(max-width:600px){.masthead{height:62px}.masthead-inner{padding:0 20px}.masthead-label{font-size:11px}main{padding:32px 18px 28px}h1{font-size:27px;letter-spacing:-.8px}.conversation-header{padding-bottom:24px;margin-bottom:25px}.eyebrow{margin-bottom:13px;font-size:10px}.description{font-size:13px}.metadata{font-size:11px;gap:5px 7px}.transcript{gap:24px}.user{width:94%}.message-body{padding:17px 18px;font-size:14px}.message time{font-size:10px}.markdown h2{font-size:19px}.markdown h3{font-size:17px}.code-block pre{padding:12px}.markdown th,.markdown td{padding:9px 11px}.conversation-footer{font-size:11px}}
518
+ @media print{html{color-scheme:light;--page:#fff;--surface:#fff;--ink:#111;--muted:#555;--line:#ddd;--user:#f0f7f2;--user-line:#ddd;--code:#f5f5f5;--mark:#f0f7f2;--green:#286b43}body{font-size:11pt}.masthead{height:48px}main{max-width:none;padding:24px 0}.message-body{break-inside:avoid}.code-block pre{white-space:pre-wrap;overflow-wrap:anywhere}.table-wrap{overflow:visible}.conversation-footer{margin-top:24px}}
519
+ """
520
+ _STYLE += "\n.shared-video{margin:16px 0 0;white-space:normal}.shared-video video{display:block;width:100%;max-height:65vh;background:#111;border-radius:10px}.shared-video figcaption,.video-caption{margin-top:7px;color:var(--muted);font-size:12px;overflow-wrap:anywhere}"
521
+ _STYLE_HASH = base64.b64encode(hashlib.sha256(_STYLE.encode("utf-8")).digest()).decode("ascii")
522
+
523
+
524
+ # A deliberately small formatting vocabulary. Every text fragment is escaped;
525
+ # raw HTML, links, images, embeds, and arbitrary attributes are never emitted.
526
+ # Keeping this local also avoids a Markdown dependency in the server runtime.
527
+ _INLINE_MARKDOWN = re.compile(r"`([^`\n]+)`|\*\*([^*\n]+)\*\*|\*([^*\n]+)\*")
528
+ _FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$")
529
+ _HEADING = re.compile(r"^ {0,3}(#{1,6})[ \t]+(.+)$")
530
+ _LIST_ITEM = re.compile(r"^ {0,3}(?:([-+*])|([0-9]{1,9})[.)])\s+(.+)$")
531
+ _TABLE_DIVIDER = re.compile(r"^:?-{3,}:?$")
532
+
533
+
534
+ def _render_inline_text(text: str) -> str:
535
+ parts = []
536
+ position = 0
537
+ for match in _INLINE_MARKDOWN.finditer(text):
538
+ parts.append(html.escape(text[position:match.start()], quote=True))
539
+ for index, tag in ((1, "code"), (2, "strong"), (3, "em")):
540
+ if match[index] is not None:
541
+ parts.append(f"<{tag}>{html.escape(match[index], quote=True)}</{tag}>")
542
+ break
543
+ position = match.end()
544
+ parts.append(html.escape(text[position:], quote=True))
545
+ return "".join(parts)
546
+
547
+
548
+ def _table_cells(line: str) -> list[str]:
549
+ return [cell.strip() for cell in line.strip().strip("|").split("|")]
550
+
551
+
552
+ def _render_message_markdown(text: str) -> str:
553
+ """Format common assistant Markdown without interpreting HTML or URLs."""
554
+ lines = text.splitlines()
555
+ parts: list[str] = []
556
+ paragraph: list[str] = []
557
+ index = 0
558
+
559
+ def flush_paragraph() -> None:
560
+ if paragraph:
561
+ parts.append(f'<p>{_render_inline_text(chr(10).join(paragraph))}</p>')
562
+ paragraph.clear()
563
+
564
+ while index < len(lines):
565
+ line = lines[index]
566
+ fence = _FENCE.match(line)
567
+ heading = _HEADING.match(line)
568
+ item = _LIST_ITEM.match(line)
569
+ if not line.strip():
570
+ flush_paragraph()
571
+ index += 1
572
+ continue
573
+ if fence:
574
+ flush_paragraph()
575
+ marker = fence[1]
576
+ label = html.escape(fence[2].strip(), quote=True)
577
+ code = []
578
+ index += 1
579
+ while index < len(lines):
580
+ closing = lines[index].strip()
581
+ if len(closing) >= len(marker) and set(closing) == {marker[0]}:
582
+ index += 1
583
+ break
584
+ code.append(lines[index])
585
+ index += 1
586
+ parts.append('<div class="code-block">')
587
+ if label:
588
+ parts.append(f'<div class="code-language">{label}</div>')
589
+ parts.append(f'<pre><code>{html.escape(chr(10).join(code), quote=True)}</code></pre></div>')
590
+ continue
591
+ if heading:
592
+ flush_paragraph()
593
+ level = min(len(heading[1]) + 1, 6)
594
+ parts.append(f"<h{level}>{_render_inline_text(heading[2])}</h{level}>")
595
+ elif line.strip() in ("---", "***", "___"):
596
+ flush_paragraph()
597
+ parts.append("<hr>")
598
+ elif item:
599
+ flush_paragraph()
600
+ tag = "ul" if item[1] else "ol"
601
+ start = f' start="{int(item[2])}"' if item[2] and item[2] != "1" else ""
602
+ parts.append(f"<{tag}{start}>")
603
+ while index < len(lines):
604
+ match = _LIST_ITEM.match(lines[index])
605
+ if not match or ("ul" if match[1] else "ol") != tag:
606
+ break
607
+ parts.append(f"<li>{_render_inline_text(match[3])}</li>")
608
+ index += 1
609
+ parts.append(f"</{tag}>")
610
+ continue
611
+ elif line.lstrip().startswith("> "):
612
+ flush_paragraph()
613
+ quote = []
614
+ while index < len(lines) and lines[index].lstrip().startswith("> "):
615
+ quote.append(lines[index].lstrip()[2:])
616
+ index += 1
617
+ parts.append(f'<blockquote>{_render_inline_text(chr(10).join(quote))}</blockquote>')
618
+ continue
619
+ elif "|" in line and index + 1 < len(lines) and "|" in lines[index + 1]:
620
+ columns = _table_cells(line)
621
+ dividers = _table_cells(lines[index + 1])
622
+ if len(columns) == len(dividers) and 1 < len(columns) <= 64 and all(
623
+ _TABLE_DIVIDER.fullmatch(cell) for cell in dividers
624
+ ):
625
+ flush_paragraph()
626
+ parts.append('<div class="table-wrap" role="region" aria-label="Conversation table" tabindex="0"><table><thead><tr>')
627
+ parts.extend(f'<th scope="col">{_render_inline_text(cell)}</th>' for cell in columns)
628
+ parts.append("</tr></thead><tbody>")
629
+ index += 2
630
+ while index < len(lines) and "|" in lines[index]:
631
+ cells = _table_cells(lines[index])
632
+ if len(cells) != len(columns):
633
+ break
634
+ parts.append("<tr>" + "".join(f"<td>{_render_inline_text(cell)}</td>" for cell in cells) + "</tr>")
635
+ index += 1
636
+ parts.append("</tbody></table></div>")
637
+ continue
638
+ paragraph.append(line)
639
+ else:
640
+ paragraph.append(line)
641
+ index += 1
642
+ flush_paragraph()
643
+ return "".join(parts)
644
+
645
+
646
+ def public_chat_share_headers(*, allow_unlock_form: bool = False) -> dict[str, str]:
647
+ """Use these headers for both successful viewer pages and unavailable pages."""
648
+ return {
649
+ "Content-Type": "text/html; charset=utf-8",
650
+ "Content-Security-Policy": (
651
+ "default-src 'none'; script-src 'none'; "
652
+ f"style-src 'sha256-{_STYLE_HASH}'; "
653
+ "img-src 'none'; media-src 'self'; connect-src 'none'; base-uri 'none'; "
654
+ + ("form-action 'self'; frame-ancestors 'none'; sandbox allow-forms allow-same-origin"
655
+ if allow_unlock_form else "form-action 'none'; frame-ancestors 'none'; sandbox allow-same-origin")
656
+ ),
657
+ "Cache-Control": "no-store",
658
+ # A native navigation form under no-referrer submits Origin: null in
659
+ # Chromium. Keep an origin on this token-free, same-origin entry page
660
+ # so strict CSRF validation works; transcript pages disclose no referrer.
661
+ "Referrer-Policy": "same-origin" if allow_unlock_form else "no-referrer",
662
+ "X-Content-Type-Options": "nosniff",
663
+ "X-Frame-Options": "DENY",
664
+ "X-Robots-Tag": "noindex, nofollow, noarchive",
665
+ }
666
+
667
+
668
+ def render_public_chat_unlock_html(share_id: str, *, invalid_token: bool = False) -> bytes:
669
+ """A generic token prompt; it never reads or discloses snapshot metadata."""
670
+ if not isinstance(share_id, str) or SHARE_ID_PATTERN.fullmatch(share_id) is None:
671
+ raise PublicChatShareUnavailable()
672
+ error = '<p class="unlock-error" role="alert">The token is invalid or this shared chat is no longer available.</p>' if invalid_token else ""
673
+ return (
674
+ '<!doctype html><html lang="en"><head><meta charset="utf-8">'
675
+ '<meta name="viewport" content="width=device-width,initial-scale=1">'
676
+ '<meta name="robots" content="noindex,nofollow,noarchive">'
677
+ f'<title>Open shared chat · AgentsDock</title><style>{_STYLE}</style></head><body>'
678
+ '<div class="masthead"><div class="masthead-inner"><div class="brand">'
679
+ '<span class="brand-mark" aria-hidden="true">A</span>AgentsDock</div>'
680
+ '<span class="masthead-label">Shared conversations</span></div></div>'
681
+ '<main class="unlock-main"><section class="unlock-card"><div class="eyebrow">Shared conversation'
682
+ '<span class="view-badge">View only</span></div><h1>Open your shared chat</h1>'
683
+ '<p class="description">Enter the access token provided by the person who shared this conversation.</p>'
684
+ f'{error}<form method="post" action="/shared-chat/{share_id}/unlock">'
685
+ '<label for="access-token">Access token</label>'
686
+ '<input id="access-token" name="access_token" type="password" required minlength="43" maxlength="43" '
687
+ 'pattern="[A-Za-z0-9_-]{43}" autocomplete="off" autocapitalize="none" spellcheck="false">'
688
+ '<label class="remember"><input type="checkbox" name="remember" value="1">Remember in this browser for 30 days</label>'
689
+ '<button type="submit">Open shared chat</button></form>'
690
+ '<p class="unlock-help">This opens a saved, read-only conversation.</p></section>'
691
+ '<footer class="conversation-footer"><p>Shared with AgentsDock</p></footer></main></body></html>'
692
+ ).encode("utf-8")
693
+
694
+
695
+ def render_public_chat_html(snapshot: dict[str, Any], *, page: int = 0, page_count: int = 1,
696
+ message_count: int | None = None, navigation_base: str | None = None) -> bytes:
697
+ """Render a static transcript with escaped, presentation-only Markdown."""
698
+ if not isinstance(snapshot, dict) or set(snapshot) != {"title", "created_at", "messages"}:
699
+ raise PublicChatShareValidationError("Invalid public snapshot fields.")
700
+ snapshot, _ = _snapshot(snapshot["messages"], snapshot["title"], snapshot["created_at"])
701
+ title = html.escape(snapshot["title"], quote=True)
702
+ created_at = datetime.fromtimestamp(snapshot["created_at"], timezone.utc)
703
+ created = created_at.strftime("%b %d, %Y · %H:%M UTC")
704
+ if (type(page) is not int or type(page_count) is not int or not 0 <= page < page_count
705
+ or message_count is not None and (type(message_count) is not int or message_count < len(snapshot["messages"]))):
706
+ raise PublicChatShareValidationError("Invalid snapshot page.")
707
+ if navigation_base is not None and re.fullmatch(r"/(?:shared-chat/share_[a-f0-9]{32}|share/[A-Za-z0-9_-]{43})", navigation_base) is None:
708
+ raise PublicChatShareValidationError("Invalid snapshot navigation.")
709
+ navigation = ""
710
+ if navigation_base is not None and page_count > 1:
711
+ older = f'<a href="{navigation_base}?page={page - 1}#conversation-end" rel="prev">← Older messages</a>' if page else ""
712
+ newer = f'<a href="{navigation_base}?page={page + 1}#conversation-end" rel="next">Newer messages →</a>' if page + 1 < page_count else ""
713
+ navigation = f'<nav class="pagination" aria-label="Conversation pages">{older}<span>Page {page + 1:,} of {page_count:,}</span>{newer}</nav>'
714
+ count = len(snapshot["messages"]) if message_count is None else message_count
715
+ count_label = "message" if count == 1 else "messages"
716
+ parts = [
717
+ '<!doctype html><html lang="en"><head><meta charset="utf-8">',
718
+ '<meta name="viewport" content="width=device-width,initial-scale=1">',
719
+ '<meta name="robots" content="noindex,nofollow,noarchive">',
720
+ f"<title>{title} · AgentsDock</title><style>{_STYLE}</style></head><body>",
721
+ '<div class="masthead"><div class="masthead-inner"><div class="brand">',
722
+ '<span class="brand-mark" aria-hidden="true">A</span>AgentsDock</div>',
723
+ '<span class="masthead-label">Shared conversations</span></div></div><main>',
724
+ '<header class="conversation-header"><div class="eyebrow">Conversation',
725
+ '<span class="view-badge">View only</span></div>',
726
+ f'<h1>{title}</h1><p class="description">A saved conversation, shared for reading.</p>',
727
+ f'<div class="metadata"><span>Shared <time datetime="{created_at.isoformat()}">{created}</time></span>',
728
+ f'<span class="metadata-separator" aria-hidden="true">·</span><span>{count:,} {count_label}</span>',
729
+ f'</div>{navigation}</header><div class="transcript" aria-label="Read-only conversation snapshot">',
730
+ ]
731
+ for index, message in enumerate(snapshot["messages"]):
732
+ role = message["role"]
733
+ label = "User" if role == "user" else "Assistant"
734
+ parts.append(f'<section class="message {role}" aria-labelledby="message-{index}"><div class="message-header">')
735
+ parts.append(f'<span class="avatar" aria-hidden="true">{label[0]}</span><h2 class="message-label" id="message-{index}">{label}</h2>')
736
+ if "timestamp" in message:
737
+ timestamp = datetime.fromtimestamp(message["timestamp"], timezone.utc)
738
+ stamp = timestamp.strftime("%b %d · %H:%M UTC")
739
+ parts.append(f'<time datetime="{timestamp.isoformat()}">{stamp}</time>')
740
+ parts.append('</div>')
741
+ content = (html.escape(message["text"], quote=True) if role == "user"
742
+ else _render_message_markdown(message["text"]))
743
+ parts.append(f'<div class="message-body {"plain-text" if role == "user" else "markdown"}">{content}')
744
+ for video_index, video in enumerate(message.get("videos", [])):
745
+ filename = html.escape(video["filename"], quote=True)
746
+ if navigation_base is None:
747
+ parts.append(f'<p class="video-caption">Video: {filename}</p>')
748
+ continue
749
+ source = f"{navigation_base}/media/{page}/{index}/{video_index}"
750
+ parts.append(f'<figure class="shared-video"><video controls preload="metadata" playsinline '
751
+ f'aria-label="{filename}" src="{source}">Your browser cannot play this video.</video>'
752
+ f'<figcaption>{filename}</figcaption></figure>')
753
+ parts.append('</div>')
754
+ parts.append('</section>')
755
+ parts.append(f'</div>{navigation}<footer class="conversation-footer"><p>This is a saved copy. Replies and updates stay in the original chat.</p><p class="footer-brand">Shared with AgentsDock</p></footer><div id="conversation-end"></div></main></body></html>')
756
+ rendered = "".join(parts).encode("utf-8")
757
+ if len(rendered) > MAX_RENDER_BYTES:
758
+ raise PublicChatShareValidationError("Rendered snapshot is too large.")
759
+ return rendered