@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,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"]}
|