@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,359 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ INSTALL_ROOT="${AGENTS_SERVER_INSTALL_DIR:-$HOME/.local/share/agents-server}"
5
+ CONFIG_ROOT="${AGENTS_SERVER_CONFIG_DIR:-$HOME/.config/agents-server}"
6
+ LEGACY_STATE_ROOT="$HOME/.zenithbot-agent"
7
+ if [[ -n "${AGENTSDOCK_STATE_DIR:-}" ]]; then
8
+ STATE_ROOT="$AGENTSDOCK_STATE_DIR"
9
+ elif [[ -n "${AGENTS_SERVER_STATE_DIR:-}" ]]; then
10
+ STATE_ROOT="$AGENTS_SERVER_STATE_DIR"
11
+ elif [[ -n "${ZENITHBOT_AGENT_DIR:-}" ]]; then
12
+ STATE_ROOT="$ZENITHBOT_AGENT_DIR"
13
+ else
14
+ STATE_ROOT="$HOME/.agentsdock"
15
+ fi
16
+ SERVICE_NAME="agents-server"
17
+ OS_NAME="$(uname -s)"
18
+
19
+ ASSUME_YES="false"
20
+ PURGE_STATE="false"
21
+
22
+ normalize_managed_path() {
23
+ local label="$1"
24
+ local candidate="$2"
25
+ local parent leaf physical_parent normalized home_physical
26
+
27
+ if [[ -z "$candidate" || "$candidate" != /* || "$candidate" == *$'\n'* || "$candidate" == *$'\r'* ]]; then
28
+ echo "$label must be a non-empty absolute path without control characters." >&2
29
+ return 1
30
+ fi
31
+ while [[ "$candidate" != "/" && "$candidate" == */ ]]; do
32
+ candidate="${candidate%/}"
33
+ done
34
+ case "$candidate/" in
35
+ *"/../"*|*"/./"*|*"//"*)
36
+ echo "$label must not contain '.', '..', or repeated-slash path components: $candidate" >&2
37
+ return 1
38
+ ;;
39
+ esac
40
+
41
+ # Resolve existing parent directories without following the final component.
42
+ # Removing a final symlink must remove only that link, while an intermediate
43
+ # symlink still needs to be accounted for by the safety checks below.
44
+ parent="$(dirname -- "$candidate")"
45
+ leaf="$(basename -- "$candidate")"
46
+ while [[ ! -d "$parent" && "$parent" != "/" ]]; do
47
+ leaf="$(basename -- "$parent")/$leaf"
48
+ parent="$(dirname -- "$parent")"
49
+ done
50
+ physical_parent="$(cd -P -- "$parent" 2>/dev/null && pwd -P)" || {
51
+ echo "Could not resolve the parent of $label: $candidate" >&2
52
+ return 1
53
+ }
54
+ if [[ "$physical_parent" == "/" ]]; then
55
+ normalized="/$leaf"
56
+ else
57
+ normalized="$physical_parent/$leaf"
58
+ fi
59
+ home_physical="$(cd -P -- "$HOME" 2>/dev/null && pwd -P)" || {
60
+ echo "Could not resolve HOME safely: $HOME" >&2
61
+ return 1
62
+ }
63
+
64
+ case "$normalized" in
65
+ "/"|"/Applications"|"/Library"|"/System"|"/Users"|"/Volumes"|"/bin"|"/etc"|"/home"|"/opt"|"/private"|"/sbin"|"/tmp"|"/usr"|"/var"|\
66
+ "$home_physical"|"$home_physical/.config"|"$home_physical/.local"|"$home_physical/.local/share"|"$home_physical/Library"|"$home_physical/Library/LaunchAgents")
67
+ echo "Refusing unsafe $label target: $normalized" >&2
68
+ return 1
69
+ ;;
70
+ esac
71
+ printf '%s\n' "$normalized"
72
+ }
73
+
74
+ paths_overlap() {
75
+ local first="$1"
76
+ local second="$2"
77
+ [[ "$first" == "$second" || "$first" == "$second/"* || "$second" == "$first/"* ]]
78
+ }
79
+
80
+ INSTALL_ROOT="$(normalize_managed_path AGENTS_SERVER_INSTALL_DIR "$INSTALL_ROOT")" || exit 2
81
+ CONFIG_ROOT="$(normalize_managed_path AGENTS_SERVER_CONFIG_DIR "$CONFIG_ROOT")" || exit 2
82
+ STATE_ROOT="$(normalize_managed_path AGENTSDOCK_STATE_DIR "$STATE_ROOT")" || exit 2
83
+ for managed_root in "$INSTALL_ROOT" "$CONFIG_ROOT" "$STATE_ROOT"; do
84
+ if [[ -L "$managed_root" ]]; then
85
+ echo "Refusing symbolic-link managed root: $managed_root" >&2
86
+ exit 2
87
+ fi
88
+ done
89
+ if paths_overlap "$INSTALL_ROOT" "$CONFIG_ROOT" || \
90
+ paths_overlap "$INSTALL_ROOT" "$STATE_ROOT" || \
91
+ paths_overlap "$CONFIG_ROOT" "$STATE_ROOT"; then
92
+ echo "Refusing overlapping install, configuration, and state roots; each must be a separate directory." >&2
93
+ exit 2
94
+ fi
95
+
96
+ usage() {
97
+ cat <<USAGE
98
+ Usage: ./uninstall.sh [--yes] [--purge-state]
99
+
100
+ Stops and removes the AgentsServer user service, versioned release runtime,
101
+ and generated configuration (including the access token). Chat history, jobs,
102
+ files, secure-peer credentials, and terminals under $STATE_ROOT are preserved
103
+ by default so a later ./install.sh picks ordinary AgentsServer state back up.
104
+ Preserved Team Hub state is not auto-reactivated in this beta; it requires
105
+ signed managed recovery or support-assisted restoration.
106
+
107
+ --yes Do not prompt before removing the service, releases, and
108
+ configuration.
109
+ --purge-state Also delete $STATE_ROOT (chat history, jobs, files, tokens,
110
+ and secure-peer credentials). This cannot be undone and
111
+ always requires typing the exact state path interactively;
112
+ --yes never bypasses it.
113
+ USAGE
114
+ }
115
+
116
+ while (($#)); do
117
+ case "$1" in
118
+ --yes) ASSUME_YES="true"; shift ;;
119
+ --purge-state) PURGE_STATE="true"; shift ;;
120
+ -h|--help) usage; exit 0 ;;
121
+ *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
122
+ esac
123
+ done
124
+
125
+ # The split lifecycle validates its installed layout, obtains an exact idle
126
+ # execution hold, and owns the shared lock through both native service stops
127
+ # and filesystem removal. Keep legacy uninstall unchanged for old layouts.
128
+ if [[ -f "$INSTALL_ROOT/execution-layout.json" && ! -L "$INSTALL_ROOT/execution-layout.json" ]]; then
129
+ UNINSTALL_SOURCE="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
130
+ UNINSTALL_PYTHON="$UNINSTALL_SOURCE/.venv/bin/python"
131
+ if [[ ! -x "$UNINSTALL_PYTHON" ]]; then
132
+ UNINSTALL_PYTHON="$(command -v python3 || true)"
133
+ fi
134
+ if [[ -z "$UNINSTALL_PYTHON" || ! -f "$UNINSTALL_SOURCE/execution_uninstall.py" || -L "$UNINSTALL_SOURCE/execution_uninstall.py" ]]; then
135
+ echo "Cannot uninstall the separate gateway/execution layout: its maintained Python helper is unavailable; no services were changed." >&2
136
+ exit 1
137
+ fi
138
+ execution_arguments=(--root "$INSTALL_ROOT" --config-root "$CONFIG_ROOT" --state-root "$STATE_ROOT" --home "$HOME")
139
+ [[ "$ASSUME_YES" != "true" ]] || execution_arguments+=(--yes)
140
+ [[ "$PURGE_STATE" != "true" ]] || execution_arguments+=(--purge-state)
141
+ exec "$UNINSTALL_PYTHON" -B "$UNINSTALL_SOURCE/execution_uninstall.py" "${execution_arguments[@]}"
142
+ fi
143
+
144
+ refuse_execution_layout() {
145
+ local marker=""
146
+ for marker in "$INSTALL_ROOT/execution-layout.json" "$INSTALL_ROOT/.execution-transaction"; do
147
+ if [[ -e "$marker" || -L "$marker" ]]; then
148
+ echo "This uninstaller does not support the separate gateway/execution layout or its pending transaction." >&2
149
+ echo "Use the execution-aware managed lifecycle; the pinned worker has not been changed." >&2
150
+ return 1
151
+ fi
152
+ done
153
+ }
154
+
155
+ refuse_execution_layout || exit 1
156
+
157
+ INSTALL_LOCK_DIR="$INSTALL_ROOT/.install-lock"
158
+ UNINSTALL_LOCK_HELD="false"
159
+ INSTALL_ROOT_CREATED_FOR_LOCK="false"
160
+
161
+ release_operation_lock() {
162
+ [[ "$UNINSTALL_LOCK_HELD" == "true" ]] || return 0
163
+ if [[ "$(cat "$INSTALL_LOCK_DIR/pid" 2>/dev/null || true)" == "$$" ]]; then
164
+ rm -rf "$INSTALL_LOCK_DIR"
165
+ fi
166
+ UNINSTALL_LOCK_HELD="false"
167
+ if [[ "$INSTALL_ROOT_CREATED_FOR_LOCK" == "true" ]]; then
168
+ rmdir "$INSTALL_ROOT" 2>/dev/null || true
169
+ fi
170
+ }
171
+
172
+ acquire_operation_lock() {
173
+ if [[ ! -d "$INSTALL_ROOT" ]]; then
174
+ mkdir -p "$INSTALL_ROOT"
175
+ INSTALL_ROOT_CREATED_FOR_LOCK="true"
176
+ fi
177
+ if ! mkdir "$INSTALL_LOCK_DIR" 2>/dev/null; then
178
+ local owner_pid=""
179
+ owner_pid="$(cat "$INSTALL_LOCK_DIR/pid" 2>/dev/null || true)"
180
+ if [[ "$owner_pid" =~ ^[0-9]+$ ]] && kill -0 "$owner_pid" >/dev/null 2>&1; then
181
+ echo "Refusing to uninstall while an AgentsServer install/update is active (PID $owner_pid)." >&2
182
+ return 1
183
+ fi
184
+ local stale_lock="$INSTALL_ROOT/.uninstall-stale-lock-$$"
185
+ if ! mv "$INSTALL_LOCK_DIR" "$stale_lock" 2>/dev/null; then
186
+ echo "An AgentsServer install/update started while uninstall was checking the operation lock." >&2
187
+ return 1
188
+ fi
189
+ rm -rf "$stale_lock"
190
+ if ! mkdir "$INSTALL_LOCK_DIR" 2>/dev/null; then
191
+ echo "An AgentsServer install/update is already running." >&2
192
+ return 1
193
+ fi
194
+ fi
195
+ printf '%s\n' "$$" > "$INSTALL_LOCK_DIR/pid"
196
+ chmod 700 "$INSTALL_LOCK_DIR"
197
+ UNINSTALL_LOCK_HELD="true"
198
+ }
199
+
200
+ acquire_operation_lock || exit 1
201
+ trap release_operation_lock EXIT
202
+ trap 'exit 130' HUP INT TERM
203
+ refuse_execution_layout || exit 1
204
+
205
+ confirm() {
206
+ local prompt="$1"
207
+ [[ "$ASSUME_YES" != "true" ]] || return 0
208
+ if [[ ! -t 0 ]]; then
209
+ echo "Refusing to $prompt without --yes on a non-interactive run." >&2
210
+ return 1
211
+ fi
212
+ local reply=""
213
+ read -r -p "$prompt [y/N] " reply || return 1
214
+ case "$reply" in
215
+ y|Y|yes|YES) return 0 ;;
216
+ *) return 1 ;;
217
+ esac
218
+ }
219
+
220
+ confirm_state_purge() {
221
+ local reply=""
222
+ if [[ ! -t 0 ]]; then
223
+ echo "Refusing to purge state without an interactive terminal; --yes never bypasses the state guard." >&2
224
+ return 1
225
+ fi
226
+ echo "Permanent deletion requested for chat history, jobs, files, and tokens."
227
+ read -r -p "Type the exact state path to confirm ($STATE_ROOT): " reply || return 1
228
+ if [[ "$reply" != "$STATE_ROOT" ]]; then
229
+ echo "State path did not match; nothing was changed." >&2
230
+ return 1
231
+ fi
232
+ }
233
+
234
+ if [[ "$PURGE_STATE" == "true" ]] && ! confirm_state_purge; then
235
+ exit 1
236
+ fi
237
+
238
+ echo "This will stop and remove the AgentsServer service, release runtime at"
239
+ echo "$INSTALL_ROOT, and configuration at $CONFIG_ROOT (including the access token)."
240
+ if confirm "Proceed?"; then
241
+ :
242
+ else
243
+ echo "Aborted; nothing was changed." >&2
244
+ exit 1
245
+ fi
246
+
247
+ case "$OS_NAME" in
248
+ Darwin)
249
+ LABEL="com.agentsdock.server"
250
+ PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
251
+ SERVICE_TARGET="gui/$(id -u)/$LABEL"
252
+ if launchctl print "$SERVICE_TARGET" >/dev/null 2>&1; then
253
+ echo "Stopping $LABEL"
254
+ if ! launchctl bootout "$SERVICE_TARGET" >/dev/null 2>&1 && \
255
+ launchctl print "$SERVICE_TARGET" >/dev/null 2>&1; then
256
+ echo "Could not stop $LABEL; no files were removed." >&2
257
+ exit 1
258
+ fi
259
+ stopped="false"
260
+ for ((_attempt = 1; _attempt <= 50; _attempt++)); do
261
+ if ! launchctl print "$SERVICE_TARGET" >/dev/null 2>&1; then
262
+ stopped="true"
263
+ break
264
+ fi
265
+ sleep 0.1
266
+ done
267
+ if [[ "$stopped" != "true" ]]; then
268
+ echo "$LABEL did not stop; no files were removed." >&2
269
+ exit 1
270
+ fi
271
+ fi
272
+ if [[ -f "$PLIST" ]]; then
273
+ echo "Removing $PLIST"
274
+ rm -f "$PLIST"
275
+ fi
276
+ LOG_DIR="$HOME/Library/Logs/AgentsServer"
277
+ if [[ -d "$LOG_DIR" ]]; then
278
+ echo "Removing $LOG_DIR"
279
+ rm -rf "$LOG_DIR"
280
+ fi
281
+ ;;
282
+ Linux)
283
+ SERVICE_FILE="$HOME/.config/systemd/user/$SERVICE_NAME.service"
284
+ if ! systemctl --user show-environment >/dev/null 2>&1; then
285
+ echo "The systemd user service manager is unavailable; no files were removed." >&2
286
+ exit 1
287
+ fi
288
+ if [[ -f "$SERVICE_FILE" ]] || systemctl --user is-active --quiet "$SERVICE_NAME.service"; then
289
+ echo "Stopping $SERVICE_NAME.service"
290
+ if ! systemctl --user disable --now "$SERVICE_NAME.service" >/dev/null 2>&1 && \
291
+ systemctl --user is-active --quiet "$SERVICE_NAME.service"; then
292
+ echo "Could not stop $SERVICE_NAME.service; no files were removed." >&2
293
+ exit 1
294
+ fi
295
+ stopped="false"
296
+ for ((_attempt = 1; _attempt <= 50; _attempt++)); do
297
+ if ! systemctl --user is-active --quiet "$SERVICE_NAME.service"; then
298
+ stopped="true"
299
+ break
300
+ fi
301
+ sleep 0.1
302
+ done
303
+ if [[ "$stopped" != "true" ]]; then
304
+ echo "$SERVICE_NAME.service did not stop; no files were removed." >&2
305
+ exit 1
306
+ fi
307
+ fi
308
+ if [[ -f "$SERVICE_FILE" ]]; then
309
+ echo "Removing $SERVICE_FILE"
310
+ rm -f "$SERVICE_FILE"
311
+ systemctl --user daemon-reload || true
312
+ fi
313
+ ;;
314
+ *)
315
+ echo "Unsupported host OS: $OS_NAME; no files were removed." >&2
316
+ exit 1
317
+ ;;
318
+ esac
319
+
320
+ if [[ -d "$INSTALL_ROOT" ]]; then
321
+ echo "Removing release runtime at $INSTALL_ROOT"
322
+ shopt -s dotglob nullglob
323
+ for install_entry in "$INSTALL_ROOT"/*; do
324
+ [[ "$install_entry" == "$INSTALL_LOCK_DIR" ]] || rm -rf -- "$install_entry"
325
+ done
326
+ shopt -u dotglob nullglob
327
+ fi
328
+
329
+ if [[ -d "$CONFIG_ROOT" ]]; then
330
+ echo "Removing configuration at $CONFIG_ROOT"
331
+ rm -rf "$CONFIG_ROOT"
332
+ fi
333
+
334
+ if [[ "$PURGE_STATE" == "true" ]]; then
335
+ if [[ -e "$STATE_ROOT" || -L "$LEGACY_STATE_ROOT" ]]; then
336
+ [[ ! -L "$LEGACY_STATE_ROOT" ]] || rm -f "$LEGACY_STATE_ROOT"
337
+ [[ ! -e "$STATE_ROOT" ]] || rm -rf "$STATE_ROOT"
338
+ echo "Deleted $STATE_ROOT"
339
+ fi
340
+ elif [[ -e "$STATE_ROOT" ]]; then
341
+ echo "Preserved chat history, jobs, files, tokens, and secure-peer credentials at $STATE_ROOT."
342
+ echo "Re-running ./install.sh will pick ordinary AgentsServer state back up. Pass --purge-state to also delete it."
343
+ if [[ -e "$STATE_ROOT/team-hub/team-hub.sqlite3" || -L "$STATE_ROOT/team-hub/team-hub.sqlite3" ]]; then
344
+ echo "Preserved Team Hub state is not auto-reactivated in this beta; use a signed managed recovery or support-assisted restoration."
345
+ fi
346
+ fi
347
+
348
+ # Keep the shared install/update lock alive until every requested removal has
349
+ # completed. Removing the install root while the lock still lives inside it
350
+ # would let a new installer race with configuration or state deletion.
351
+ release_operation_lock
352
+ if [[ -d "$INSTALL_ROOT" ]] && ! rmdir "$INSTALL_ROOT"; then
353
+ echo "Could not remove the now-unlocked install directory at $INSTALL_ROOT." >&2
354
+ exit 1
355
+ fi
356
+
357
+ echo "AgentsServer service, release runtime, and configuration removed."
358
+ echo "Note: any persistent chat terminals (tmux sessions named zd_*) keep running"
359
+ echo "independently and are not touched by this script. List them with: tmux ls"
@@ -0,0 +1,237 @@
1
+ """Release only an unchanged, idle split-worker handoff before installer entry.
2
+
3
+ The detached update runner first settles its own failed status and Hub fence.
4
+ Once it invokes the installer, the installer's activation journals exclusively
5
+ own recovery. This helper never stops processes, changes update status, deletes
6
+ evidence, or guesses that a legacy monolith owns a split-worker lease.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import fcntl
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+ import re
16
+ import stat
17
+ from typing import Any
18
+ import uuid
19
+
20
+ import execution_install as files
21
+ from execution_manage import InstallationLock, NativeServices, WorkerControl
22
+
23
+
24
+ def _no_activation(root: Path) -> None:
25
+ for name in (".activation-transaction", ".execution-transaction"):
26
+ path = root / name
27
+ if path.exists() or path.is_symlink():
28
+ raise RuntimeError("activation already owns handoff recovery")
29
+
30
+
31
+ def _no_handoff_recovery_overlap(root: Path) -> None:
32
+ _no_activation(root)
33
+ marker = root / ".execution-uninstall.json"
34
+ if marker.exists() or marker.is_symlink():
35
+ raise RuntimeError("uninstall already owns execution recovery")
36
+
37
+
38
+ def _read_handoff(root: Path, path: Path) -> dict[str, Any]:
39
+ path = files._path(path)
40
+ if (path.parent.parent != root / ".update-preparations"
41
+ or re.fullmatch(r"[0-9a-f]{32}", path.parent.name) is None
42
+ or re.fullmatch(r"handoff-[0-9a-f]{32}\.json", path.name) is None):
43
+ raise ValueError("handoff is outside its owned preparation directory")
44
+ files._owned_directory(path.parent.parent, private=True)
45
+ files._owned_directory(path.parent, private=True)
46
+ data, _mode = files._read_file(path, private=True)
47
+
48
+ def unique(pairs):
49
+ value = {}
50
+ for key, item in pairs:
51
+ if key in value:
52
+ raise ValueError("duplicate handoff field")
53
+ value[key] = item
54
+ return value
55
+
56
+ value = json.loads(data, object_pairs_hook=unique)
57
+ expected = {"schema", "operation_id", "worker_instance_id", "lease_id", "expected_server_identity"}
58
+ if (not isinstance(value, dict) or set(value) != expected
59
+ or type(value["schema"]) is not int or value["schema"] != 1):
60
+ raise ValueError("invalid handoff schema")
61
+ for key in ("operation_id", "lease_id"):
62
+ if not isinstance(value[key], str) or str(uuid.UUID(value[key])) != value[key]:
63
+ raise ValueError("invalid handoff operation or lease")
64
+ for key in ("worker_instance_id", "expected_server_identity"):
65
+ if (not isinstance(value[key], str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", value[key])):
66
+ raise ValueError("invalid handoff worker or server identity")
67
+ return value
68
+
69
+
70
+ def _installed_layout(root: Path) -> files.ExecutionLayout:
71
+ data, _mode = files._read_file(root / files.LAYOUT_NAME, private=True)
72
+ value = json.loads(data)
73
+ if not isinstance(value, dict):
74
+ raise ValueError("installed execution layout is invalid")
75
+ layout = files.ExecutionLayout.from_dict(value.get("layout"))
76
+ if layout.install_root != root or value != files.layout_manifest(layout):
77
+ raise ValueError("installed execution layout changed")
78
+ return layout
79
+
80
+
81
+ def _matching_status(handoff: dict, layout: files.ExecutionLayout, record: dict,
82
+ status: dict, native: dict, *, allow_released: bool = False) -> bool:
83
+ lease = status.get("lease")
84
+ blockers = status.get("blockers")
85
+ unheld = allow_released and "lease" in status and lease is None
86
+ if ((not unheld and record.get("instance_id") != handoff["worker_instance_id"])
87
+ or status.get("worker_instance_id") != record.get("instance_id")
88
+ or record.get("release_root") != str(layout.worker_release)
89
+ or native.get("worker", {}).get("state") != "running"
90
+ or type(record.get("pid")) is not int or record["pid"] <= 0
91
+ or native["worker"].get("pid") != record["pid"]):
92
+ raise RuntimeError("native worker no longer owns the handoff")
93
+ if (not isinstance(blockers, dict) or not blockers
94
+ or any(type(value) is not int or value < 0 for value in blockers.values())):
95
+ raise RuntimeError("worker cleanup or admission has not settled")
96
+ if unheld:
97
+ return False
98
+ if status.get("idle") is not True or any(blockers.values()):
99
+ raise RuntimeError("worker cleanup or admission has not settled")
100
+ if (not isinstance(lease, dict) or lease.get("operation_id") != handoff["operation_id"]
101
+ or lease.get("lease_id") != handoff["lease_id"] or lease.get("sealed") is not True
102
+ or lease.get("expires_at", "missing") is not None):
103
+ raise RuntimeError("worker no longer holds the exact sealed handoff")
104
+ return True
105
+
106
+
107
+ def _held_state_ownership(layout: files.ExecutionLayout) -> tuple[int, int]:
108
+ """Prove an exclusive state owner exists, without claiming flock exposes PID.
109
+
110
+ Native service and authenticated callback probes bind the maintained worker
111
+ PID separately. This checks that its lifetime ownership boundary is intact.
112
+ """
113
+ parent = files._path(layout.state_root / "admin")
114
+ files._owned_directory(parent, private=True)
115
+ path = parent / "state-owner.lock"
116
+ descriptor = os.open(path, os.O_RDWR | os.O_NOFOLLOW | os.O_NONBLOCK)
117
+ try:
118
+ info = os.fstat(descriptor)
119
+ if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid()
120
+ or info.st_nlink != 1 or stat.S_IMODE(info.st_mode) != 0o600):
121
+ raise PermissionError("worker state ownership lock is unsafe")
122
+ try:
123
+ fcntl.flock(descriptor, fcntl.LOCK_SH | fcntl.LOCK_NB)
124
+ except BlockingIOError:
125
+ pass
126
+ else:
127
+ raise RuntimeError("replacement worker does not hold exclusive state ownership")
128
+ linked = path.lstat()
129
+ identity = info.st_dev, info.st_ino
130
+ if (linked.st_dev, linked.st_ino) != identity:
131
+ raise RuntimeError("worker state ownership lock changed")
132
+ return identity
133
+ finally:
134
+ os.close(descriptor)
135
+
136
+
137
+ def release_existing_handoff(root: Path, handoff_file: Path, *,
138
+ control: WorkerControl | None = None,
139
+ services: NativeServices | None = None) -> dict[str, Any]:
140
+ """Release once with epoch/operation/lease CAS; propagate every uncertain result.
141
+
142
+ The caller must have atomically changed only its owned update to failed and
143
+ settled provider teardown. The private status confirms quiescence again.
144
+ ``control`` and ``services`` are injectable for isolated boundary tests.
145
+ """
146
+ return _release_handoff(root, handoff_file, control=control, services=services)
147
+
148
+
149
+ def retry_failed_handoff(root: Path, status_path: Path, expected_status: dict[str, Any], *,
150
+ control: WorkerControl | None = None,
151
+ services: NativeServices | None = None) -> dict[str, Any]:
152
+ """Finish only the exact saved failed cleanup, including a lost release reply.
153
+
154
+ The caller holds its update operation lock, never the status flock across
155
+ this call, then CASes its status after success. No saved evidence is changed.
156
+ An unheld authenticated replacement worker needs no maintenance POST either;
157
+ its native process and exclusive state ownership must also remain unchanged.
158
+ """
159
+ root, status_path = files._path(root), files._path(status_path)
160
+ if not isinstance(expected_status, dict):
161
+ raise ValueError("failed handoff status is invalid")
162
+ # Freeze the caller's snapshot across callback requests.
163
+ expected_status = json.loads(json.dumps(expected_status))
164
+ update_id = expected_status.get("update_id")
165
+ preparation_id = expected_status.get("preparation_id") or update_id
166
+ if (not isinstance(update_id, str) or re.fullmatch(r"[0-9a-f]{32}", update_id) is None
167
+ or not isinstance(preparation_id, str) or re.fullmatch(r"[0-9a-f]{32}", preparation_id) is None
168
+ or expected_status.get("phase") != "failed"
169
+ or expected_status.get("error_code") != "server_update_handoff_release_failed"
170
+ or expected_status.get("retryable") is not True
171
+ or expected_status.get("runner_pid") is not None):
172
+ raise ValueError("status does not own a failed preinstaller handoff")
173
+ handoff_file = root / ".update-preparations" / preparation_id / f"handoff-{update_id}.json"
174
+
175
+ def verify_status(layout: files.ExecutionLayout, handoff: dict) -> None:
176
+ if status_path != layout.state_root / "admin/server-update.json":
177
+ raise ValueError("handoff status is outside the installed state directory")
178
+ files._owned_directory(status_path.parent, private=True)
179
+ data, _mode = files._read_file(status_path, private=True)
180
+ if json.loads(data) != expected_status:
181
+ raise RuntimeError("failed update status changed before handoff recovery")
182
+ if (expected_status.get("_execution_handoff") != handoff
183
+ or handoff["operation_id"] != str(uuid.UUID(hex=update_id))):
184
+ raise RuntimeError("saved failed status does not own this handoff")
185
+
186
+ return _release_handoff(root, handoff_file, control=control, services=services,
187
+ allow_released=True, verify_status=verify_status)
188
+
189
+
190
+ def _release_handoff(root: Path, handoff_file: Path, *, control=None, services=None,
191
+ allow_released: bool = False, verify_status=None) -> dict[str, Any]:
192
+ root = files._path(root)
193
+ files._owned_directory(root)
194
+ with InstallationLock(root):
195
+ _no_handoff_recovery_overlap(root)
196
+ handoff = _read_handoff(root, handoff_file)
197
+ layout = _installed_layout(root)
198
+ if verify_status is not None:
199
+ verify_status(layout, handoff)
200
+ control = control or WorkerControl()
201
+ services = services or NativeServices(layout)
202
+ record, status = control.status(layout)
203
+ _matching_status(handoff, layout, record, status, services.snapshot(), allow_released=allow_released)
204
+ replacement = allow_released and record.get("instance_id") != handoff["worker_instance_id"]
205
+ state_owner = _held_state_ownership(layout) if replacement else None
206
+ # Authenticate the stable worker callback itself; a public gateway can
207
+ # be unavailable without changing ownership of this execution epoch.
208
+ health = control.callback_health(layout, record)
209
+ execution = health.get("execution_service")
210
+ if (health.get("ok") is not True or health.get("server_identity") != handoff["expected_server_identity"]
211
+ or not isinstance(execution, dict) or execution.get("instance_id") != record["instance_id"]
212
+ or execution.get("pid") != record["pid"]):
213
+ raise RuntimeError("authenticated worker identity does not match handoff")
214
+ # Re-read local provenance and live admission after the health request.
215
+ # A takeover or failed provider close must not disappear in that gap.
216
+ _no_handoff_recovery_overlap(root)
217
+ if _read_handoff(root, handoff_file) != handoff or _installed_layout(root) != layout:
218
+ raise RuntimeError("handoff provenance changed before release")
219
+ latest_record, latest_status = control.status(layout)
220
+ held = _matching_status(handoff, layout, latest_record, latest_status, services.snapshot(),
221
+ allow_released=allow_released)
222
+ if latest_record != record:
223
+ raise RuntimeError("worker process receipt changed before release")
224
+ if replacement and _held_state_ownership(layout) != state_owner:
225
+ raise RuntimeError("worker state ownership changed before acknowledgment")
226
+ if verify_status is not None:
227
+ verify_status(layout, handoff)
228
+ if not held:
229
+ return {"released": True, "already_released": True, "operation_id": handoff["operation_id"],
230
+ "worker_instance_id": record["instance_id"]}
231
+ result = control._maintenance(layout, latest_record, action="release",
232
+ operation=handoff["operation_id"], lease_id=handoff["lease_id"])
233
+ if (result.get("worker_instance_id") != handoff["worker_instance_id"]
234
+ or result.get("lease", "missing") is not None):
235
+ raise RuntimeError("worker did not confirm exact handoff release")
236
+ return {"released": True, "already_released": False, "operation_id": handoff["operation_id"],
237
+ "worker_instance_id": handoff["worker_instance_id"]}