agentworks-cli 0.2.1__py3-none-any.whl
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.
- agentworks/__init__.py +1 -0
- agentworks/agents/__init__.py +0 -0
- agentworks/agents/manager.py +1095 -0
- agentworks/agents/templates.py +145 -0
- agentworks/catalog.py +264 -0
- agentworks/catalog.toml +131 -0
- agentworks/cli.py +1462 -0
- agentworks/completions/__init__.py +33 -0
- agentworks/completions/bash.py +179 -0
- agentworks/completions/install.py +122 -0
- agentworks/completions/powershell.py +270 -0
- agentworks/completions/spec.py +216 -0
- agentworks/completions/zsh.py +256 -0
- agentworks/config.py +894 -0
- agentworks/db.py +1083 -0
- agentworks/doctor.py +430 -0
- agentworks/git_credentials/__init__.py +0 -0
- agentworks/git_credentials/azdo.py +29 -0
- agentworks/git_credentials/base.py +71 -0
- agentworks/git_credentials/github.py +22 -0
- agentworks/nerf-config.yaml +16 -0
- agentworks/output.py +296 -0
- agentworks/remote_exec.py +286 -0
- agentworks/sample-config.toml +289 -0
- agentworks/sessions/__init__.py +0 -0
- agentworks/sessions/console.py +164 -0
- agentworks/sessions/manager.py +1297 -0
- agentworks/sessions/templates.py +101 -0
- agentworks/sessions/tmux.py +503 -0
- agentworks/sources.py +303 -0
- agentworks/ssh.py +759 -0
- agentworks/ssh_config.py +255 -0
- agentworks/vm_hosts/__init__.py +0 -0
- agentworks/vm_hosts/manager.py +86 -0
- agentworks/vms/__init__.py +0 -0
- agentworks/vms/backup.py +409 -0
- agentworks/vms/base.py +56 -0
- agentworks/vms/bootstrap_script.py +185 -0
- agentworks/vms/cloud_init.py +55 -0
- agentworks/vms/initializer.py +1523 -0
- agentworks/vms/manager.py +1122 -0
- agentworks/vms/provisioners/__init__.py +0 -0
- agentworks/vms/provisioners/azure.py +602 -0
- agentworks/vms/provisioners/lima.py +295 -0
- agentworks/vms/provisioners/proxmox.py +279 -0
- agentworks/vms/provisioners/proxmox_api.py +261 -0
- agentworks/vms/provisioners/wsl2.py +340 -0
- agentworks/vms/templates.py +152 -0
- agentworks/workspaces/__init__.py +0 -0
- agentworks/workspaces/backends/__init__.py +0 -0
- agentworks/workspaces/backends/local.py +119 -0
- agentworks/workspaces/backends/vm.py +175 -0
- agentworks/workspaces/manager.py +1080 -0
- agentworks/workspaces/templates.py +76 -0
- agentworks/workspaces/tmuxinator.py +80 -0
- agentworks_cli-0.2.1.dist-info/METADATA +635 -0
- agentworks_cli-0.2.1.dist-info/RECORD +59 -0
- agentworks_cli-0.2.1.dist-info/WHEEL +4 -0
- agentworks_cli-0.2.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Session template resolution and processing.
|
|
2
|
+
|
|
3
|
+
Handles inheritance (depth-first, left-to-right), merge rules, and the
|
|
4
|
+
built-in default template fallback. Follows the same pattern as VM,
|
|
5
|
+
workspace, and agent templates.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from agentworks.config import Config, SessionTemplate
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ResolvedSessionTemplate:
|
|
19
|
+
"""A fully resolved session template with all inheritance applied."""
|
|
20
|
+
|
|
21
|
+
name: str
|
|
22
|
+
command: str = ""
|
|
23
|
+
description: str = "Login shell"
|
|
24
|
+
restart_command: str = ""
|
|
25
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _append_dedupe(target: list[str], source: list[str]) -> list[str]:
|
|
29
|
+
"""Append source items to target, skipping dupes. Preserves order."""
|
|
30
|
+
seen = set(target)
|
|
31
|
+
result = list(target)
|
|
32
|
+
for item in source:
|
|
33
|
+
if item not in seen:
|
|
34
|
+
seen.add(item)
|
|
35
|
+
result.append(item)
|
|
36
|
+
return result
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _merge_map(target: dict[str, str], source: dict[str, str]) -> dict[str, str]:
|
|
40
|
+
"""Merge source map into target. Source wins on key collision."""
|
|
41
|
+
return {**target, **source}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def resolve_from_dict(
|
|
45
|
+
templates: dict[str, SessionTemplate],
|
|
46
|
+
template_name: str | None = None,
|
|
47
|
+
) -> ResolvedSessionTemplate:
|
|
48
|
+
"""Resolve a session template from a templates dict (no Config required)."""
|
|
49
|
+
if template_name is not None and template_name != "default":
|
|
50
|
+
if template_name not in templates:
|
|
51
|
+
msg = f"Unknown session template: {template_name}"
|
|
52
|
+
raise ValueError(msg)
|
|
53
|
+
return _resolve(templates, template_name)
|
|
54
|
+
|
|
55
|
+
if "default" in templates:
|
|
56
|
+
return _resolve(templates, "default")
|
|
57
|
+
|
|
58
|
+
return ResolvedSessionTemplate(name="default")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def resolve_template(config: Config, template_name: str | None = None) -> ResolvedSessionTemplate:
|
|
62
|
+
"""Resolve a session template by name, applying inheritance."""
|
|
63
|
+
return resolve_from_dict(config.session_templates, template_name)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _resolve(templates: dict[str, SessionTemplate], name: str) -> ResolvedSessionTemplate:
|
|
67
|
+
"""Depth-first, left-to-right resolution."""
|
|
68
|
+
if name not in templates:
|
|
69
|
+
return ResolvedSessionTemplate(name=name)
|
|
70
|
+
|
|
71
|
+
tmpl = templates[name]
|
|
72
|
+
result = ResolvedSessionTemplate(name=name)
|
|
73
|
+
|
|
74
|
+
for parent_name in tmpl.inherits:
|
|
75
|
+
parent = _resolve(templates, parent_name)
|
|
76
|
+
_merge(result, parent)
|
|
77
|
+
|
|
78
|
+
_merge_template(result, tmpl)
|
|
79
|
+
result.name = name
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _merge(target: ResolvedSessionTemplate, source: ResolvedSessionTemplate) -> None:
|
|
84
|
+
"""Merge source into target. Scalars: source wins. Maps: merge with source wins."""
|
|
85
|
+
target.command = source.command
|
|
86
|
+
target.description = source.description
|
|
87
|
+
target.restart_command = source.restart_command
|
|
88
|
+
target.env = _merge_map(target.env, source.env)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _merge_template(target: ResolvedSessionTemplate, tmpl: SessionTemplate) -> None:
|
|
92
|
+
"""Merge a raw SessionTemplate into a ResolvedSessionTemplate. None = not set, skip.
|
|
93
|
+
Scalars: child overrides. Maps: merge with child wins."""
|
|
94
|
+
if tmpl.command is not None:
|
|
95
|
+
target.command = tmpl.command
|
|
96
|
+
if tmpl.description is not None:
|
|
97
|
+
target.description = tmpl.description
|
|
98
|
+
if tmpl.restart_command is not None:
|
|
99
|
+
target.restart_command = tmpl.restart_command
|
|
100
|
+
if tmpl.env is not None:
|
|
101
|
+
target.env = _merge_map(target.env, tmpl.env)
|
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
"""tmux session management for agentworks sessions.
|
|
2
|
+
|
|
3
|
+
Each session gets a locked-down tmux session. Session names are globally
|
|
4
|
+
unique and used directly as the tmux session name. A restricted tmux config
|
|
5
|
+
disables all interactive session management (no splits, no new windows, no
|
|
6
|
+
prefix key) while keeping a large scrollback buffer.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import shlex
|
|
12
|
+
from typing import TYPE_CHECKING, Protocol
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
|
|
17
|
+
from agentworks.ssh import ExecTarget
|
|
18
|
+
|
|
19
|
+
RESTRICTED_CONFIG_PATH = "/opt/agentworks/tmux-session.conf"
|
|
20
|
+
DEFAULT_HISTORY_LIMIT = 50_000
|
|
21
|
+
|
|
22
|
+
# Agent tmux socket infrastructure
|
|
23
|
+
AGENT_SOCKET_ROOT = "/run/agentworks/agent-tmux-sockets"
|
|
24
|
+
AGENT_SOCKET_GROUP = "tmux-agent-access"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RunCommand(Protocol):
|
|
28
|
+
"""Callable that runs a shell command on a target (e.g. partial of ssh.run)."""
|
|
29
|
+
|
|
30
|
+
def __call__(self, command: str, *, check: bool = True) -> object: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def agent_socket_path(linux_user: str, session_name: str) -> str:
|
|
34
|
+
"""Return the tmux socket path for an agent-mode session."""
|
|
35
|
+
return f"{AGENT_SOCKET_ROOT}/{linux_user}/{session_name}.sock"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def ensure_agent_socket_root(
|
|
39
|
+
target: ExecTarget,
|
|
40
|
+
admin_username: str,
|
|
41
|
+
*,
|
|
42
|
+
warn_if_missing: bool = True,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Create the agent tmux socket root directory and group (idempotent).
|
|
45
|
+
|
|
46
|
+
Fast-paths when the directory already exists with the correct group and
|
|
47
|
+
permissions (probe + group membership check).
|
|
48
|
+
|
|
49
|
+
Pass ``warn_if_missing=False`` when the caller already knows the directory
|
|
50
|
+
won't exist (e.g. first-time VM init), to avoid a misleading warning.
|
|
51
|
+
"""
|
|
52
|
+
grp = shlex.quote(AGENT_SOCKET_GROUP)
|
|
53
|
+
q_root = shlex.quote(AGENT_SOCKET_ROOT)
|
|
54
|
+
|
|
55
|
+
probe = target.run(
|
|
56
|
+
f'if test -d {q_root}; then stat -c "%G %a" {q_root} 2>/dev/null || echo PROBE_FAILED; '
|
|
57
|
+
f"else echo MISSING; fi",
|
|
58
|
+
sudo=True,
|
|
59
|
+
check=False,
|
|
60
|
+
)
|
|
61
|
+
stdout = probe.stdout.strip()
|
|
62
|
+
if stdout == f"{AGENT_SOCKET_GROUP} 2771":
|
|
63
|
+
# Directory is correct, but still ensure admin is in the group.
|
|
64
|
+
admin = shlex.quote(admin_username)
|
|
65
|
+
result = target.run(f"usermod -aG {grp} {admin}", sudo=True, check=False)
|
|
66
|
+
if not result.ok:
|
|
67
|
+
from agentworks import output
|
|
68
|
+
|
|
69
|
+
output.warn(f"Failed to add {admin_username} to {AGENT_SOCKET_GROUP}, tmux socket access may fail")
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
if stdout == "MISSING":
|
|
73
|
+
should_warn, state = warn_if_missing, "missing"
|
|
74
|
+
elif stdout == "PROBE_FAILED":
|
|
75
|
+
should_warn, state = True, "probe failed"
|
|
76
|
+
else:
|
|
77
|
+
should_warn, state = True, "misconfigured"
|
|
78
|
+
|
|
79
|
+
if should_warn:
|
|
80
|
+
from agentworks import output
|
|
81
|
+
|
|
82
|
+
output.warn(f"Socket root {AGENT_SOCKET_ROOT} {state}, recreating")
|
|
83
|
+
|
|
84
|
+
admin = shlex.quote(admin_username)
|
|
85
|
+
result = target.run(f"getent group {grp} >/dev/null 2>&1", check=False)
|
|
86
|
+
if not result.ok:
|
|
87
|
+
target.run(f"/usr/sbin/groupadd {grp}", sudo=True)
|
|
88
|
+
target.run(f"usermod -aG {grp} {admin}", sudo=True)
|
|
89
|
+
target.run(f"mkdir -p {AGENT_SOCKET_ROOT}", sudo=True)
|
|
90
|
+
target.run(f"chown root:{grp} {AGENT_SOCKET_ROOT}", sudo=True)
|
|
91
|
+
target.run(f"chmod 2771 {AGENT_SOCKET_ROOT}", sudo=True)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def ensure_agent_socket_dir(
|
|
95
|
+
target: ExecTarget,
|
|
96
|
+
linux_user: str,
|
|
97
|
+
*,
|
|
98
|
+
warn_if_missing: bool = True,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Create a per-agent tmux socket directory (idempotent).
|
|
101
|
+
|
|
102
|
+
Fast-paths when the directory already exists with the correct owner/group
|
|
103
|
+
and permissions (single SSH round-trip).
|
|
104
|
+
"""
|
|
105
|
+
q_user = shlex.quote(linux_user)
|
|
106
|
+
grp = shlex.quote(AGENT_SOCKET_GROUP)
|
|
107
|
+
q_path = shlex.quote(f"{AGENT_SOCKET_ROOT}/{linux_user}")
|
|
108
|
+
|
|
109
|
+
probe = target.run(
|
|
110
|
+
f'if test -d {q_path}; then stat -c "%U %G %a" {q_path} 2>/dev/null || echo PROBE_FAILED; '
|
|
111
|
+
f"else echo MISSING; fi",
|
|
112
|
+
sudo=True,
|
|
113
|
+
check=False,
|
|
114
|
+
)
|
|
115
|
+
stdout = probe.stdout.strip()
|
|
116
|
+
if stdout == f"{linux_user} {AGENT_SOCKET_GROUP} 2770":
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
if stdout == "MISSING":
|
|
120
|
+
should_warn, state = warn_if_missing, "missing"
|
|
121
|
+
elif stdout == "PROBE_FAILED":
|
|
122
|
+
should_warn, state = True, "probe failed"
|
|
123
|
+
else:
|
|
124
|
+
should_warn, state = True, "misconfigured"
|
|
125
|
+
|
|
126
|
+
if should_warn:
|
|
127
|
+
from agentworks import output
|
|
128
|
+
|
|
129
|
+
output.warn(f"Socket directory for {linux_user} {state}, recreating")
|
|
130
|
+
|
|
131
|
+
target.run(f"mkdir -p {q_path}", sudo=True)
|
|
132
|
+
target.run(f"chown {q_user}:{grp} {q_path}", sudo=True)
|
|
133
|
+
target.run(f"chmod 2770 {q_path}", sudo=True)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def cleanup_stale_sockets(target: ExecTarget, linux_user: str) -> int:
|
|
137
|
+
"""Remove socket files whose tmux server is no longer running.
|
|
138
|
+
|
|
139
|
+
Uses sudo for both the tmux check and file removal -- this is an
|
|
140
|
+
infrastructure maintenance context (vm reinit / agent create).
|
|
141
|
+
|
|
142
|
+
Returns the number of stale sockets removed.
|
|
143
|
+
"""
|
|
144
|
+
q_dir = shlex.quote(f"{AGENT_SOCKET_ROOT}/{linux_user}")
|
|
145
|
+
result = target.run(f"find {q_dir} -name '*.sock' -type s 2>/dev/null", sudo=True, check=False)
|
|
146
|
+
if not result.stdout.strip():
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
removed = 0
|
|
150
|
+
for sock_path in result.stdout.strip().splitlines():
|
|
151
|
+
sock_path = sock_path.strip()
|
|
152
|
+
if not sock_path:
|
|
153
|
+
continue
|
|
154
|
+
q_sock = shlex.quote(sock_path)
|
|
155
|
+
check = target.run(f"tmux -S {q_sock} list-sessions 2>/dev/null", sudo=True, check=False)
|
|
156
|
+
if not check.ok:
|
|
157
|
+
target.run(f"rm -f {q_sock}", sudo=True, check=False)
|
|
158
|
+
removed += 1
|
|
159
|
+
return removed
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def generate_restricted_config(history_limit: int = DEFAULT_HISTORY_LIMIT) -> str:
|
|
163
|
+
"""Generate the locked-down tmux config for sessions.
|
|
164
|
+
|
|
165
|
+
Loads the user's tmux.conf first so that familiar keybindings (prefix key,
|
|
166
|
+
detach, copy mode, etc.) work for direct session attach. Then disables
|
|
167
|
+
window/pane/session management on top to enforce one session per tmux server.
|
|
168
|
+
When inside the console, the console's prefix eclipses the session's, so the
|
|
169
|
+
session-level bindings are effectively invisible.
|
|
170
|
+
"""
|
|
171
|
+
return f"""\
|
|
172
|
+
# Generated by agentworks. Do not edit.
|
|
173
|
+
# Locked-down config for agentworks sessions.
|
|
174
|
+
#
|
|
175
|
+
# Loads user tmux.conf for familiar keybindings (prefix, detach, copy mode),
|
|
176
|
+
# then disables window/pane/session creation to enforce one session per server.
|
|
177
|
+
|
|
178
|
+
# Load user config first
|
|
179
|
+
if-shell "test -f ~/.tmux.conf" "source-file ~/.tmux.conf"
|
|
180
|
+
|
|
181
|
+
# Large scrollback buffer (override user config)
|
|
182
|
+
set -g history-limit {history_limit}
|
|
183
|
+
|
|
184
|
+
# Size windows based on the most recently active client, not the smallest.
|
|
185
|
+
# Sessions are created detached (default geometry) then attached from
|
|
186
|
+
# within the console session. Without this, the inner session stays stuck
|
|
187
|
+
# at the small detached size.
|
|
188
|
+
set -g window-size latest
|
|
189
|
+
set -g aggressive-resize on
|
|
190
|
+
|
|
191
|
+
# Disable status bar -- the console provides this when nested;
|
|
192
|
+
# for direct attach, the session is the only thing on screen.
|
|
193
|
+
set -g status off
|
|
194
|
+
|
|
195
|
+
# Disable window/pane/session creation and management.
|
|
196
|
+
# The user's prefix key, detach, copy mode, and scroll bindings are preserved.
|
|
197
|
+
unbind c # new-window
|
|
198
|
+
unbind % # split-window -h
|
|
199
|
+
unbind '"' # split-window -v
|
|
200
|
+
unbind & # kill-window
|
|
201
|
+
unbind x # kill-pane
|
|
202
|
+
unbind n # next-window
|
|
203
|
+
unbind p # previous-window
|
|
204
|
+
unbind w # choose-window
|
|
205
|
+
unbind s # choose-session
|
|
206
|
+
unbind $ # rename-session
|
|
207
|
+
unbind , # rename-window
|
|
208
|
+
unbind . # move-window
|
|
209
|
+
unbind ! # break-pane
|
|
210
|
+
unbind : # command-prompt (prevents arbitrary tmux commands)
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def deploy_restricted_config(
|
|
215
|
+
run_command: RunCommand,
|
|
216
|
+
history_limit: int = DEFAULT_HISTORY_LIMIT,
|
|
217
|
+
) -> None:
|
|
218
|
+
"""Write the restricted tmux config to the VM."""
|
|
219
|
+
config = generate_restricted_config(history_limit)
|
|
220
|
+
# Ensure directory exists and write config
|
|
221
|
+
run_command(f"sudo mkdir -p $(dirname {RESTRICTED_CONFIG_PATH})")
|
|
222
|
+
run_command(f"sudo tee {RESTRICTED_CONFIG_PATH} > /dev/null << 'TMUX_CONF'\n{config}TMUX_CONF")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def tmux_cmd(base: str, socket_path: str | None = None, *, sudo: bool = False) -> str:
|
|
226
|
+
"""Build a tmux command string, optionally with ``-S`` and ``sudo``.
|
|
227
|
+
|
|
228
|
+
Session commands (has-session, kill-session, send-keys, capture-pane) do
|
|
229
|
+
NOT use sudo -- socket access goes through group permissions, and failures
|
|
230
|
+
surface as BROKEN status. ``sudo=True`` is only for infrastructure
|
|
231
|
+
operations (e.g. cleanup_stale_sockets probing sockets during setup).
|
|
232
|
+
"""
|
|
233
|
+
cmd = f"tmux -S {shlex.quote(socket_path)} {base}" if socket_path else f"tmux {base}"
|
|
234
|
+
return f"sudo -n {cmd}" if sudo else cmd
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _grant_server_access(
|
|
238
|
+
run_command: RunCommand,
|
|
239
|
+
linux_user: str,
|
|
240
|
+
socket_path: str,
|
|
241
|
+
) -> None:
|
|
242
|
+
"""Grant tmux server-access to every member of the socket group."""
|
|
243
|
+
q_user = shlex.quote(linux_user)
|
|
244
|
+
q_sock = shlex.quote(socket_path)
|
|
245
|
+
grp = shlex.quote(AGENT_SOCKET_GROUP)
|
|
246
|
+
run_command(
|
|
247
|
+
f"for u in $(getent group {grp} | cut -d: -f4 | tr ',' ' '); do "
|
|
248
|
+
f"sudo -u {q_user} tmux -S {q_sock} server-access -a \"$u\"; "
|
|
249
|
+
f"done",
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def create_session(
|
|
254
|
+
session_name: str,
|
|
255
|
+
workspace_path: str,
|
|
256
|
+
command: str,
|
|
257
|
+
linux_user: str | None,
|
|
258
|
+
*,
|
|
259
|
+
run_command: RunCommand,
|
|
260
|
+
target: ExecTarget | None = None,
|
|
261
|
+
run_as_root: RunCommand | None = None,
|
|
262
|
+
admin_username: str | None = None,
|
|
263
|
+
is_admin: bool = True,
|
|
264
|
+
) -> tuple[str | None, int | None]:
|
|
265
|
+
"""Create a locked-down tmux session.
|
|
266
|
+
|
|
267
|
+
For admin mode, the command runs directly on the admin's default tmux
|
|
268
|
+
server. For agent mode, the session is created as the agent Linux user
|
|
269
|
+
with a per-session socket so the agent's tmux server (and shell) run under
|
|
270
|
+
the agent's uid. The admin gains access via group permissions on the
|
|
271
|
+
socket and the tmux ``server-access`` ACL.
|
|
272
|
+
|
|
273
|
+
Returns (socket_path, tmux_server_pid). socket_path is None for admin-mode.
|
|
274
|
+
"""
|
|
275
|
+
q_session = shlex.quote(session_name)
|
|
276
|
+
q_path = shlex.quote(workspace_path)
|
|
277
|
+
|
|
278
|
+
if is_admin:
|
|
279
|
+
if command:
|
|
280
|
+
inner = shlex.quote(f"cd {q_path} && {command}")
|
|
281
|
+
shell_cmd = f"$SHELL -lic {inner}"
|
|
282
|
+
else:
|
|
283
|
+
shell_cmd = ""
|
|
284
|
+
|
|
285
|
+
cmd = f"tmux new-session -d -s {q_session} -c {q_path} -f {RESTRICTED_CONFIG_PATH}"
|
|
286
|
+
if shell_cmd:
|
|
287
|
+
cmd += f" {shlex.quote(shell_cmd)}"
|
|
288
|
+
run_command(cmd)
|
|
289
|
+
try:
|
|
290
|
+
pid_out = run_command("tmux display-message -p '#{pid}'", check=False)
|
|
291
|
+
pid: int | None = _parse_pid(getattr(pid_out, "stdout", ""), context="after session create")
|
|
292
|
+
except (RuntimeError, ValueError):
|
|
293
|
+
pid = None # best-effort; auto-repair will recover on next access
|
|
294
|
+
return (None, pid)
|
|
295
|
+
else:
|
|
296
|
+
assert linux_user is not None
|
|
297
|
+
assert run_as_root is not None, "run_as_root required for agent sessions"
|
|
298
|
+
assert admin_username is not None, "admin_username required for agent sessions"
|
|
299
|
+
q_user = shlex.quote(linux_user)
|
|
300
|
+
sock = agent_socket_path(linux_user, session_name)
|
|
301
|
+
q_sock = shlex.quote(sock)
|
|
302
|
+
|
|
303
|
+
# Ensure the tmpfs socket directories exist (wiped on VM reboot).
|
|
304
|
+
assert target is not None, "target required for agent sessions"
|
|
305
|
+
ensure_agent_socket_root(target, admin_username)
|
|
306
|
+
ensure_agent_socket_dir(target, linux_user)
|
|
307
|
+
|
|
308
|
+
# Check for an existing socket file before creating the session.
|
|
309
|
+
# A stale socket (no server) is removed to start clean. An active
|
|
310
|
+
# socket (server running) is an error -- something else is using it.
|
|
311
|
+
sock_exists = run_command(f"test -e {q_sock}", check=False)
|
|
312
|
+
if getattr(sock_exists, "ok", False):
|
|
313
|
+
server_alive = run_as_root(
|
|
314
|
+
f"tmux -S {q_sock} list-sessions 2>/dev/null",
|
|
315
|
+
check=False,
|
|
316
|
+
)
|
|
317
|
+
if getattr(server_alive, "ok", False):
|
|
318
|
+
raise RuntimeError(
|
|
319
|
+
f"Socket {sock} already has an active tmux server. "
|
|
320
|
+
f"Kill it first or choose a different session name."
|
|
321
|
+
)
|
|
322
|
+
# Stale socket -- remove it
|
|
323
|
+
from agentworks import output as _output
|
|
324
|
+
|
|
325
|
+
_output.detail(f"Removing stale socket: {sock}")
|
|
326
|
+
run_as_root(f"rm -f {q_sock}", check=False)
|
|
327
|
+
|
|
328
|
+
# Build the pane command. sudo --login gives the agent a proper
|
|
329
|
+
# login environment; tmux then starts the pane shell as that user.
|
|
330
|
+
if command:
|
|
331
|
+
inner = shlex.quote(f"cd {q_path} && {command}")
|
|
332
|
+
shell_cmd = f"$SHELL -lic {inner}"
|
|
333
|
+
else:
|
|
334
|
+
shell_cmd = ""
|
|
335
|
+
|
|
336
|
+
cmd = (
|
|
337
|
+
f"sudo --login -u {q_user} "
|
|
338
|
+
f"tmux -S {q_sock} new-session -d -s {q_session} "
|
|
339
|
+
f"-c {q_path} -f {RESTRICTED_CONFIG_PATH}"
|
|
340
|
+
)
|
|
341
|
+
if shell_cmd:
|
|
342
|
+
cmd += f" {shlex.quote(shell_cmd)}"
|
|
343
|
+
run_command(cmd)
|
|
344
|
+
|
|
345
|
+
# Fix socket permissions (tmux creates sockets mode 0700).
|
|
346
|
+
# Socket is owned by the agent user, so sudo is needed.
|
|
347
|
+
run_as_root(f"chmod g+rwx {q_sock}")
|
|
348
|
+
|
|
349
|
+
# Grant tmux server-access to all socket-group members
|
|
350
|
+
_grant_server_access(run_command, linux_user, sock)
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
pid_out = run_command(tmux_cmd("display-message -p '#{pid}'", sock), check=False)
|
|
354
|
+
pid = _parse_pid(getattr(pid_out, "stdout", ""), context="after session create")
|
|
355
|
+
except (RuntimeError, ValueError):
|
|
356
|
+
pid = None # best-effort; auto-repair will recover on next access
|
|
357
|
+
return (sock, pid)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def kill_session(
|
|
361
|
+
session_name: str,
|
|
362
|
+
*,
|
|
363
|
+
run_command: RunCommand,
|
|
364
|
+
socket_path: str | None = None,
|
|
365
|
+
) -> bool:
|
|
366
|
+
"""Kill a session's tmux session. Returns True if the session existed."""
|
|
367
|
+
q_session = shlex.quote(session_name)
|
|
368
|
+
result = run_command(
|
|
369
|
+
tmux_cmd(f"kill-session -t {q_session}", socket_path),
|
|
370
|
+
check=False,
|
|
371
|
+
)
|
|
372
|
+
return getattr(result, "ok", True)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def session_exists(
|
|
376
|
+
session_name: str,
|
|
377
|
+
*,
|
|
378
|
+
run_command: RunCommand,
|
|
379
|
+
socket_path: str | None = None,
|
|
380
|
+
) -> bool:
|
|
381
|
+
"""Check if a session's tmux session is alive."""
|
|
382
|
+
q_session = shlex.quote(session_name)
|
|
383
|
+
result = run_command(
|
|
384
|
+
tmux_cmd(f"has-session -t {q_session}", socket_path) + " 2>/dev/null",
|
|
385
|
+
check=False,
|
|
386
|
+
)
|
|
387
|
+
return getattr(result, "ok", False)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def send_keys(
|
|
391
|
+
session_name: str,
|
|
392
|
+
keys: str,
|
|
393
|
+
*,
|
|
394
|
+
run_command: RunCommand,
|
|
395
|
+
socket_path: str | None = None,
|
|
396
|
+
) -> None:
|
|
397
|
+
"""Send keys to a session's tmux session."""
|
|
398
|
+
q_session = shlex.quote(session_name)
|
|
399
|
+
run_command(
|
|
400
|
+
tmux_cmd(f"send-keys -t {q_session} {keys}", socket_path),
|
|
401
|
+
check=False,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def capture_output(
|
|
406
|
+
session_name: str,
|
|
407
|
+
*,
|
|
408
|
+
run_command: RunCommand,
|
|
409
|
+
lines: int = DEFAULT_HISTORY_LIMIT,
|
|
410
|
+
socket_path: str | None = None,
|
|
411
|
+
) -> str:
|
|
412
|
+
"""Capture the scrollback buffer from a session."""
|
|
413
|
+
q_session = shlex.quote(session_name)
|
|
414
|
+
result = run_command(
|
|
415
|
+
tmux_cmd(f"capture-pane -t {q_session} -p -S -{lines}", socket_path),
|
|
416
|
+
check=False,
|
|
417
|
+
)
|
|
418
|
+
return getattr(result, "stdout", "") or ""
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _parse_pid(raw: str, context: str) -> int:
|
|
422
|
+
"""Parse a PID from tmux display-message output. Raises RuntimeError on failure."""
|
|
423
|
+
pid_str = raw.strip()
|
|
424
|
+
if not pid_str:
|
|
425
|
+
raise RuntimeError(f"tmux returned empty PID output ({context})")
|
|
426
|
+
try:
|
|
427
|
+
pid = int(pid_str)
|
|
428
|
+
except ValueError:
|
|
429
|
+
raise RuntimeError(f"tmux returned non-numeric PID: {pid_str!r} ({context})") from None
|
|
430
|
+
if pid <= 0:
|
|
431
|
+
raise RuntimeError(f"tmux returned invalid PID: {pid} ({context})")
|
|
432
|
+
return pid
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
# -- PID-based liveness helpers --------------------------------------------
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def get_tmux_server_pid(
|
|
439
|
+
*,
|
|
440
|
+
target: ExecTarget,
|
|
441
|
+
socket_path: str | None = None,
|
|
442
|
+
) -> int | None:
|
|
443
|
+
"""Retrieve the PID of a running tmux server.
|
|
444
|
+
|
|
445
|
+
Returns None if the server is not running or unreachable.
|
|
446
|
+
"""
|
|
447
|
+
cmd = tmux_cmd("display-message -p '#{pid}'", socket_path) + " 2>/dev/null"
|
|
448
|
+
result = target.run(cmd, check=False)
|
|
449
|
+
if not result.ok:
|
|
450
|
+
return None
|
|
451
|
+
pid_str = result.stdout.strip()
|
|
452
|
+
if not pid_str:
|
|
453
|
+
return None
|
|
454
|
+
try:
|
|
455
|
+
pid = int(pid_str)
|
|
456
|
+
except ValueError:
|
|
457
|
+
return None
|
|
458
|
+
return pid if pid > 0 else None
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def force_kill_tmux_server(
|
|
462
|
+
pid: int,
|
|
463
|
+
*,
|
|
464
|
+
target: ExecTarget,
|
|
465
|
+
socket_path: str | None = None,
|
|
466
|
+
log: Callable[[str], None] | None = None,
|
|
467
|
+
) -> bool:
|
|
468
|
+
"""Kill a tmux server by PID with SIGTERM -> SIGKILL escalation.
|
|
469
|
+
|
|
470
|
+
Cleans up socket file if present. Returns True if the process is dead.
|
|
471
|
+
"""
|
|
472
|
+
if pid <= 1:
|
|
473
|
+
raise ValueError(f"refusing to kill PID {pid} (dangerous special value)")
|
|
474
|
+
import time
|
|
475
|
+
|
|
476
|
+
def _log(msg: str) -> None:
|
|
477
|
+
if log:
|
|
478
|
+
log(msg)
|
|
479
|
+
|
|
480
|
+
# SIGTERM
|
|
481
|
+
_log(f"Sending SIGTERM to PID {pid}")
|
|
482
|
+
target.run(f"kill {pid}", sudo=True, check=False)
|
|
483
|
+
time.sleep(2)
|
|
484
|
+
|
|
485
|
+
# Check if still alive
|
|
486
|
+
if target.run(f"test -d /proc/{pid}", check=False).ok:
|
|
487
|
+
_log(f"PID {pid} survived SIGTERM, escalating to SIGKILL")
|
|
488
|
+
target.run(f"kill -9 {pid}", sudo=True, check=False)
|
|
489
|
+
time.sleep(1)
|
|
490
|
+
|
|
491
|
+
# Final check
|
|
492
|
+
if target.run(f"test -d /proc/{pid}", check=False).ok:
|
|
493
|
+
_log(f"PID {pid} survived SIGKILL")
|
|
494
|
+
return False # process survived
|
|
495
|
+
|
|
496
|
+
_log(f"PID {pid} is dead")
|
|
497
|
+
|
|
498
|
+
# Clean up stale socket (validate path is under expected root)
|
|
499
|
+
if socket_path and socket_path.startswith(AGENT_SOCKET_ROOT + "/"):
|
|
500
|
+
_log(f"Removing stale socket {socket_path}")
|
|
501
|
+
target.run(f"rm -f {shlex.quote(socket_path)}", sudo=True, check=False)
|
|
502
|
+
|
|
503
|
+
return True
|