@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.
- package/LICENSE +202 -0
- package/NOTICE +19 -0
- package/README.md +60 -0
- package/npm/cli.cjs +289 -0
- package/package.json +32 -0
- package/server/LICENSE +202 -0
- package/server/NOTICE +19 -0
- package/server/VERSION +1 -0
- package/server/activation_transaction.py +2458 -0
- package/server/agent_server.py +93226 -0
- package/server/agentsdock_chats.py +1237 -0
- package/server/agentsdock_emergency.py +338 -0
- package/server/agentsdock_jobs.py +640 -0
- package/server/agentsdock_mail.py +325 -0
- package/server/agentsdock_publish.py +404 -0
- package/server/agentsdock_team.py +793 -0
- package/server/agentsdock_team_hub/__init__.py +33 -0
- package/server/agentsdock_team_hub/auth.py +710 -0
- package/server/agentsdock_team_hub/cli.py +647 -0
- package/server/agentsdock_team_hub/database.py +310 -0
- package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
- package/server/agentsdock_team_hub/mail_hints.py +201 -0
- package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
- package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
- package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
- package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
- package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
- package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
- package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
- package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
- package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
- package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
- package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
- package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
- package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
- package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
- package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
- package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
- package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
- package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
- package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
- package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
- package/server/agentsdock_team_hub/notification_hints.py +306 -0
- package/server/agentsdock_team_hub/secure_peer.py +11215 -0
- package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
- package/server/agentsdock_team_hub/security.py +318 -0
- package/server/agentsdock_team_hub/service.py +1737 -0
- package/server/agentsdock_team_hub/store.py +16276 -0
- package/server/chat_mailbox.py +448 -0
- package/server/claude_background_reconciliation.py +92 -0
- package/server/claude_goals.py +245 -0
- package/server/claude_history_provenance.py +137 -0
- package/server/claude_history_repair.py +1601 -0
- package/server/claude_sdk_client.py +3731 -0
- package/server/claude_side_question.py +212 -0
- package/server/codex_app_server.py +3030 -0
- package/server/codex_auth.py +93 -0
- package/server/codex_history_repair.py +1247 -0
- package/server/codex_provider.py +1120 -0
- package/server/codex_side_question.py +301 -0
- package/server/cursor_agent_client.py +654 -0
- package/server/cursor_process_guard.py +347 -0
- package/server/execution_activation.py +610 -0
- package/server/execution_control.py +233 -0
- package/server/execution_durability.py +184 -0
- package/server/execution_http.py +256 -0
- package/server/execution_install.py +792 -0
- package/server/execution_legacy_runner.py +406 -0
- package/server/execution_maintenance.py +137 -0
- package/server/execution_manage.py +615 -0
- package/server/execution_ownership.py +83 -0
- package/server/execution_preparation.py +362 -0
- package/server/execution_recovery.py +845 -0
- package/server/execution_recovery_status.py +195 -0
- package/server/execution_service.py +387 -0
- package/server/execution_transport.py +622 -0
- package/server/execution_uninstall.py +406 -0
- package/server/execution_update_status.py +49 -0
- package/server/install.sh +7074 -0
- package/server/interactive_chat_controls.py +153 -0
- package/server/interactive_chat_native.py +66 -0
- package/server/interactive_chat_projection.py +152 -0
- package/server/interactive_chat_runtime.py +132 -0
- package/server/interactive_chat_share_routes.py +534 -0
- package/server/interactive_chat_share_web.py +12 -0
- package/server/interactive_chat_shares.py +238 -0
- package/server/provider_commands.py +425 -0
- package/server/public_chat_share_routes.py +426 -0
- package/server/public_chat_shares.py +759 -0
- package/server/public_chat_transcript.py +357 -0
- package/server/pyproject.toml +29 -0
- package/server/release-public-key.pem +3 -0
- package/server/secure_peer_delivery.py +1236 -0
- package/server/secure_peer_runtime.py +7694 -0
- package/server/shared_chat_video_stream.py +262 -0
- package/server/shared_chat_videos.py +155 -0
- package/server/side_questions.py +594 -0
- package/server/team_hub_host.py +1354 -0
- package/server/team_mail_grants.py +136 -0
- package/server/team_mail_runtime.py +302 -0
- package/server/team_mail_websocket.py +211 -0
- package/server/title_generation.py +333 -0
- package/server/uninstall.sh +359 -0
- package/server/update_handoff.py +237 -0
- package/server/update_preparation.py +264 -0
- package/server/update_recovery.py +140 -0
- package/server/update_runner.py +1819 -0
- package/server/uv.lock +1101 -0
- 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
|