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,1095 @@
|
|
|
1
|
+
"""Agent lifecycle orchestration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from agentworks import output
|
|
8
|
+
from agentworks.config import validate_name
|
|
9
|
+
from agentworks.output import AgentError, VMError
|
|
10
|
+
from agentworks.ssh import admin_exec_target
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from agentworks.catalog import UserInstallCommandEntry
|
|
14
|
+
from agentworks.config import Config
|
|
15
|
+
from agentworks.db import Database, VMRow, WorkspaceRow
|
|
16
|
+
from agentworks.ssh import ExecTarget, SSHLogger, SSHResult
|
|
17
|
+
|
|
18
|
+
AGENT_PREFIX = "agt--"
|
|
19
|
+
WS_GROUP_PREFIX = "ws--"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _run_as_agent(
|
|
23
|
+
target: ExecTarget,
|
|
24
|
+
linux_user: str,
|
|
25
|
+
command: str,
|
|
26
|
+
*,
|
|
27
|
+
check: bool = True,
|
|
28
|
+
timeout: int | None = None,
|
|
29
|
+
) -> SSHResult:
|
|
30
|
+
"""Run a command as an agent user via su -.
|
|
31
|
+
|
|
32
|
+
Uses su - for a login shell so the agent's environment is set up.
|
|
33
|
+
Logging is sourced from ``target.logger``; pass a logger-equipped
|
|
34
|
+
target to capture output.
|
|
35
|
+
"""
|
|
36
|
+
import shlex
|
|
37
|
+
|
|
38
|
+
inner = shlex.quote(command)
|
|
39
|
+
return target.run(
|
|
40
|
+
f"su - {shlex.quote(linux_user)} -c {inner}",
|
|
41
|
+
sudo=True,
|
|
42
|
+
check=check,
|
|
43
|
+
timeout=timeout,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _write_agent_file(
|
|
48
|
+
target: ExecTarget,
|
|
49
|
+
linux_user: str,
|
|
50
|
+
dest: str,
|
|
51
|
+
content: str,
|
|
52
|
+
*,
|
|
53
|
+
mode: str | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Write a file into an agent user's home via tmp + mv.
|
|
56
|
+
|
|
57
|
+
scp runs as admin and can't write to the agent's home directly.
|
|
58
|
+
Logging is sourced from ``target.logger``.
|
|
59
|
+
"""
|
|
60
|
+
safe_name = linux_user.replace("/", "-")
|
|
61
|
+
tmp_path = f"/tmp/agentworks-{safe_name}-{dest.rsplit('/', 1)[-1]}"
|
|
62
|
+
target.write_file(tmp_path, content)
|
|
63
|
+
target.run(f"mv {tmp_path} {dest}", sudo=True)
|
|
64
|
+
target.run(f"chown {linux_user}:{linux_user} {dest}", sudo=True)
|
|
65
|
+
if mode:
|
|
66
|
+
target.run(f"chmod {mode} {dest}", sudo=True)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def derive_linux_user(agent_name: str) -> str:
|
|
70
|
+
"""Derive the Linux username for an agent: agt--<name>."""
|
|
71
|
+
return f"{AGENT_PREFIX}{agent_name}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def workspace_group(workspace_name: str) -> str:
|
|
75
|
+
"""Derive the Linux group name for a workspace: ws--<name>."""
|
|
76
|
+
return f"{WS_GROUP_PREFIX}{workspace_name}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def create_agent(
|
|
80
|
+
db: Database,
|
|
81
|
+
config: Config,
|
|
82
|
+
*,
|
|
83
|
+
name: str,
|
|
84
|
+
vm_name: str,
|
|
85
|
+
template: str | None = None,
|
|
86
|
+
grant_all_workspaces: bool = False,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Create an agent on a VM."""
|
|
89
|
+
from dataclasses import replace as _replace
|
|
90
|
+
|
|
91
|
+
from agentworks.agents.templates import resolve_template
|
|
92
|
+
|
|
93
|
+
agent_tmpl = resolve_template(config, template)
|
|
94
|
+
|
|
95
|
+
if template is not None:
|
|
96
|
+
config = _replace(config, agent=agent_tmpl)
|
|
97
|
+
|
|
98
|
+
validate_name(name)
|
|
99
|
+
|
|
100
|
+
if db.get_agent(name) is not None:
|
|
101
|
+
raise AgentError(f"agent '{name}' already exists")
|
|
102
|
+
|
|
103
|
+
vm = _require_vm(db, vm_name)
|
|
104
|
+
linux_user = derive_linux_user(name)
|
|
105
|
+
|
|
106
|
+
# Collect credentials up front before any SSH work
|
|
107
|
+
git_tokens = _collect_agent_credentials(config)
|
|
108
|
+
|
|
109
|
+
from agentworks.ssh import SSHLogger
|
|
110
|
+
|
|
111
|
+
ssh_logger = SSHLogger(vm.name, "agent-create")
|
|
112
|
+
try:
|
|
113
|
+
_create_agent_on_vm(vm, config, linux_user, git_tokens=git_tokens, logger=ssh_logger)
|
|
114
|
+
except Exception as e:
|
|
115
|
+
ssh_logger.close()
|
|
116
|
+
_delete_agent_on_vm(vm, config, linux_user, logger=ssh_logger)
|
|
117
|
+
raise AgentError(f"creating agent: {e}\nSSH log: {ssh_logger.path}") from None
|
|
118
|
+
ssh_logger.close()
|
|
119
|
+
|
|
120
|
+
agent = db.insert_agent(
|
|
121
|
+
name,
|
|
122
|
+
vm_name,
|
|
123
|
+
linux_user,
|
|
124
|
+
template=agent_tmpl.name,
|
|
125
|
+
grant_all=grant_all_workspaces,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# If grant_all, add to all existing workspace groups
|
|
129
|
+
if grant_all_workspaces:
|
|
130
|
+
workspaces = db.list_workspaces(vm_name=vm_name)
|
|
131
|
+
for ws in workspaces:
|
|
132
|
+
if ws.type == "vm":
|
|
133
|
+
_add_to_workspace_group(vm, config, linux_user, ws.name, logger=None)
|
|
134
|
+
db.insert_agent_grant(name, ws.name, "explicit")
|
|
135
|
+
|
|
136
|
+
output.info(f"Agent '{name}' created on VM '{vm_name}' (user: {agent.linux_user})")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def delete_agent(
|
|
140
|
+
db: Database,
|
|
141
|
+
config: Config,
|
|
142
|
+
*,
|
|
143
|
+
name: str,
|
|
144
|
+
force: bool = False,
|
|
145
|
+
yes: bool = False,
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Delete an agent from a VM."""
|
|
148
|
+
agent = db.get_agent(name)
|
|
149
|
+
if agent is None:
|
|
150
|
+
raise AgentError(f"agent '{name}' not found")
|
|
151
|
+
|
|
152
|
+
# Check for sessions using this agent
|
|
153
|
+
all_sessions = db.list_sessions()
|
|
154
|
+
agent_sessions = [s for s in all_sessions if s.agent_name == name]
|
|
155
|
+
if agent_sessions and not force:
|
|
156
|
+
for s in agent_sessions:
|
|
157
|
+
output.detail(f"{s.name}")
|
|
158
|
+
raise AgentError(
|
|
159
|
+
f"agent '{name}' has {len(agent_sessions)} session(s). Delete them first, or use --force."
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
if not yes:
|
|
163
|
+
msg = f"Delete agent '{name}'?"
|
|
164
|
+
if agent_sessions:
|
|
165
|
+
msg += f" ({len(agent_sessions)} session(s) will also be stopped)"
|
|
166
|
+
if not output.confirm(msg):
|
|
167
|
+
raise output.UserAbort("delete cancelled")
|
|
168
|
+
|
|
169
|
+
vm = _require_vm(db, agent.vm_name)
|
|
170
|
+
|
|
171
|
+
from agentworks.ssh import SSHLogger
|
|
172
|
+
|
|
173
|
+
ssh_logger = SSHLogger(vm.name, "agent-delete")
|
|
174
|
+
|
|
175
|
+
# Kill running sessions for this agent (status-aware)
|
|
176
|
+
if agent_sessions:
|
|
177
|
+
from agentworks.db import SessionStatus
|
|
178
|
+
from agentworks.sessions.manager import check_session_status, ensure_pids_batch
|
|
179
|
+
from agentworks.sessions.tmux import force_kill_tmux_server, kill_session
|
|
180
|
+
from agentworks.ssh import admin_exec_target
|
|
181
|
+
|
|
182
|
+
target = admin_exec_target(vm, config, logger=ssh_logger)
|
|
183
|
+
agent_sessions = ensure_pids_batch(agent_sessions, db=db, config=config)
|
|
184
|
+
unstoppable: list[str] = []
|
|
185
|
+
for session in agent_sessions:
|
|
186
|
+
status = check_session_status(session, target=target)
|
|
187
|
+
if status == SessionStatus.OK:
|
|
188
|
+
if not kill_session(session.name, run_command=target.run, socket_path=session.socket_path):
|
|
189
|
+
# Race: session may have exited between check and kill. Recheck.
|
|
190
|
+
recheck = check_session_status(session, target=target)
|
|
191
|
+
if recheck != SessionStatus.STOPPED:
|
|
192
|
+
unstoppable.append(session.name)
|
|
193
|
+
continue
|
|
194
|
+
elif status == SessionStatus.BROKEN:
|
|
195
|
+
if session.pid and session.pid > 0 and force_kill_tmux_server(
|
|
196
|
+
session.pid, target=target, socket_path=session.socket_path,
|
|
197
|
+
):
|
|
198
|
+
pass # killed successfully
|
|
199
|
+
else:
|
|
200
|
+
unstoppable.append(session.name)
|
|
201
|
+
elif status == SessionStatus.UNKNOWN:
|
|
202
|
+
unstoppable.append(session.name)
|
|
203
|
+
if unstoppable:
|
|
204
|
+
raise AgentError(
|
|
205
|
+
f"cannot delete agent '{name}': {len(unstoppable)} session(s) could not be stopped "
|
|
206
|
+
f"({', '.join(unstoppable)}). Resolve manually before retrying."
|
|
207
|
+
)
|
|
208
|
+
for session in agent_sessions:
|
|
209
|
+
db.delete_session(session.name)
|
|
210
|
+
output.detail(f"Deleted {len(agent_sessions)} session(s)")
|
|
211
|
+
|
|
212
|
+
# Remove from all workspace groups
|
|
213
|
+
granted_workspaces = db.list_granted_workspaces(name)
|
|
214
|
+
for ws_name in granted_workspaces:
|
|
215
|
+
_remove_from_workspace_group(vm, config, agent.linux_user, ws_name, logger=ssh_logger)
|
|
216
|
+
|
|
217
|
+
_delete_agent_on_vm(vm, config, agent.linux_user, logger=ssh_logger)
|
|
218
|
+
ssh_logger.close()
|
|
219
|
+
|
|
220
|
+
db.delete_agent(name)
|
|
221
|
+
|
|
222
|
+
output.info(f"Agent '{name}' deleted")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def reinit_agent(
|
|
226
|
+
db: Database,
|
|
227
|
+
config: Config,
|
|
228
|
+
*,
|
|
229
|
+
name: str,
|
|
230
|
+
) -> None:
|
|
231
|
+
"""Re-run agent setup using the stored template."""
|
|
232
|
+
from dataclasses import replace as _replace
|
|
233
|
+
|
|
234
|
+
from agentworks.agents.templates import resolve_template
|
|
235
|
+
|
|
236
|
+
agent = db.get_agent(name)
|
|
237
|
+
if agent is None:
|
|
238
|
+
raise AgentError(f"agent '{name}' not found")
|
|
239
|
+
|
|
240
|
+
agent_tmpl = resolve_template(config, agent.template)
|
|
241
|
+
if agent.template and agent.template != "default":
|
|
242
|
+
config = _replace(config, agent=agent_tmpl)
|
|
243
|
+
|
|
244
|
+
vm = _require_vm(db, agent.vm_name)
|
|
245
|
+
|
|
246
|
+
# Collect credentials up front before any SSH work
|
|
247
|
+
git_tokens = _collect_agent_credentials(config)
|
|
248
|
+
|
|
249
|
+
from agentworks.ssh import SSHLogger
|
|
250
|
+
|
|
251
|
+
ssh_logger = SSHLogger(vm.name, "agent-reinit")
|
|
252
|
+
try:
|
|
253
|
+
_create_agent_on_vm(vm, config, agent.linux_user, git_tokens=git_tokens, logger=ssh_logger)
|
|
254
|
+
except Exception as e:
|
|
255
|
+
ssh_logger.close()
|
|
256
|
+
raise AgentError(f"reinitializing agent: {e}\nSSH log: {ssh_logger.path}") from None
|
|
257
|
+
ssh_logger.close()
|
|
258
|
+
|
|
259
|
+
output.info(f"Agent '{name}' reinitialized")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def revoke_workspace_grants(
|
|
263
|
+
db: Database,
|
|
264
|
+
config: Config,
|
|
265
|
+
ws_name: str,
|
|
266
|
+
vm: VMRow,
|
|
267
|
+
) -> None:
|
|
268
|
+
"""Remove all agent grants for a workspace (called during workspace deletion).
|
|
269
|
+
|
|
270
|
+
Agents are VM-scoped and not deleted with workspaces. Only their grants
|
|
271
|
+
and group memberships for this workspace are removed.
|
|
272
|
+
"""
|
|
273
|
+
# Find agents that have grants for this workspace
|
|
274
|
+
# We need to remove group membership for each
|
|
275
|
+
from agentworks.ssh import SSHLogger
|
|
276
|
+
|
|
277
|
+
ssh_logger = SSHLogger(vm.name, "workspace-delete-grants")
|
|
278
|
+
agents = db.list_agents(vm_name=vm.name)
|
|
279
|
+
for agent in agents:
|
|
280
|
+
if db.has_any_grant(agent.name, ws_name):
|
|
281
|
+
_remove_from_workspace_group(vm, config, agent.linux_user, ws_name, logger=ssh_logger)
|
|
282
|
+
ssh_logger.close()
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
MAX_GRANTS_DISPLAY = 60
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _format_grants(db: Database, agent_name: str, grant_all: bool) -> str:
|
|
289
|
+
"""Format workspace grants for display in agent list."""
|
|
290
|
+
if grant_all:
|
|
291
|
+
return "--ALL--"
|
|
292
|
+
|
|
293
|
+
grants = db.list_granted_workspaces_with_types(agent_name)
|
|
294
|
+
if not grants:
|
|
295
|
+
return "(none)"
|
|
296
|
+
|
|
297
|
+
parts: list[str] = []
|
|
298
|
+
for ws_name, has_explicit, has_implicit in grants:
|
|
299
|
+
# Mark with * if implicit-only (no explicit grant)
|
|
300
|
+
suffix = "*" if has_implicit and not has_explicit else ""
|
|
301
|
+
parts.append(f"{ws_name}{suffix}")
|
|
302
|
+
|
|
303
|
+
result = ", ".join(parts)
|
|
304
|
+
if len(result) > MAX_GRANTS_DISPLAY:
|
|
305
|
+
result = result[: MAX_GRANTS_DISPLAY - 3] + "..."
|
|
306
|
+
return result
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def list_agents(
|
|
310
|
+
db: Database,
|
|
311
|
+
*,
|
|
312
|
+
vm_name: str | None = None,
|
|
313
|
+
) -> None:
|
|
314
|
+
"""List agents."""
|
|
315
|
+
agents = db.list_agents(vm_name=vm_name)
|
|
316
|
+
if not agents:
|
|
317
|
+
output.info("No agents found.")
|
|
318
|
+
return
|
|
319
|
+
|
|
320
|
+
output.info(f"{'NAME':<20} {'VM':<15} {'TEMPLATE':<12} {'WORKSPACE GRANTS'}")
|
|
321
|
+
output.info("-" * 80)
|
|
322
|
+
for agent in agents:
|
|
323
|
+
grants = _format_grants(db, agent.name, agent.grant_all)
|
|
324
|
+
output.info(f"{agent.name:<20} {agent.vm_name:<15} {agent.template or '-':<12} {grants}")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def describe_agent(
|
|
328
|
+
db: Database,
|
|
329
|
+
*,
|
|
330
|
+
name: str,
|
|
331
|
+
) -> None:
|
|
332
|
+
"""Show detailed information about an agent."""
|
|
333
|
+
agent = db.get_agent(name)
|
|
334
|
+
if agent is None:
|
|
335
|
+
raise AgentError(f"agent '{name}' not found")
|
|
336
|
+
|
|
337
|
+
output.info(f"Name: {agent.name}")
|
|
338
|
+
output.info(f"VM: {agent.vm_name}")
|
|
339
|
+
output.info(f"Linux user: {agent.linux_user}")
|
|
340
|
+
output.info(f"Template: {agent.template or '-'}")
|
|
341
|
+
output.info(f"Grant all: {'yes' if agent.grant_all else 'no'}")
|
|
342
|
+
output.info(f"Created: {agent.created_at}")
|
|
343
|
+
|
|
344
|
+
# Explicit grants
|
|
345
|
+
grants = db.list_granted_workspaces_with_types(name)
|
|
346
|
+
explicit = [ws for ws, has_explicit, _ in grants if has_explicit]
|
|
347
|
+
output.info(f"\nExplicit grants ({len(explicit)}):")
|
|
348
|
+
if explicit:
|
|
349
|
+
for ws in explicit:
|
|
350
|
+
output.detail(ws)
|
|
351
|
+
else:
|
|
352
|
+
output.detail("(none)")
|
|
353
|
+
|
|
354
|
+
# Sessions (which also show implicit grants)
|
|
355
|
+
all_sessions = db.list_sessions()
|
|
356
|
+
agent_sessions = [s for s in all_sessions if s.agent_name == name]
|
|
357
|
+
output.info(f"\nSessions ({len(agent_sessions)}):")
|
|
358
|
+
if agent_sessions:
|
|
359
|
+
for s in agent_sessions:
|
|
360
|
+
output.detail(f"{s.name} [{s.template}] workspace: {s.workspace_name}")
|
|
361
|
+
else:
|
|
362
|
+
output.detail("(none)")
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def shell_agent(
|
|
366
|
+
db: Database,
|
|
367
|
+
config: Config,
|
|
368
|
+
*,
|
|
369
|
+
name: str,
|
|
370
|
+
workspace_name: str | None = None,
|
|
371
|
+
) -> None:
|
|
372
|
+
"""Open a shell as an agent user on a VM."""
|
|
373
|
+
agent = db.get_agent(name)
|
|
374
|
+
if agent is None:
|
|
375
|
+
raise AgentError(f"agent '{name}' not found")
|
|
376
|
+
|
|
377
|
+
vm = _require_vm(db, agent.vm_name)
|
|
378
|
+
|
|
379
|
+
from agentworks.workspaces.manager import _ensure_vm_running
|
|
380
|
+
|
|
381
|
+
_ensure_vm_running(db, config, vm)
|
|
382
|
+
|
|
383
|
+
import sys
|
|
384
|
+
|
|
385
|
+
from agentworks.ssh import interactive
|
|
386
|
+
|
|
387
|
+
target = admin_exec_target(vm, config)
|
|
388
|
+
|
|
389
|
+
if workspace_name:
|
|
390
|
+
ws = db.get_workspace(workspace_name)
|
|
391
|
+
if ws is None:
|
|
392
|
+
raise AgentError(f"workspace '{workspace_name}' not found")
|
|
393
|
+
if not db.has_any_grant(name, workspace_name):
|
|
394
|
+
raise AgentError(f"agent '{name}' does not have access to workspace '{workspace_name}'")
|
|
395
|
+
import shlex
|
|
396
|
+
|
|
397
|
+
q_path = shlex.quote(ws.workspace_path)
|
|
398
|
+
sys.exit(interactive(target, f"exec sudo su --login {agent.linux_user} -c 'cd {q_path} && exec $SHELL -li'"))
|
|
399
|
+
else:
|
|
400
|
+
sys.exit(interactive(target, f"exec sudo su --login {agent.linux_user}"))
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def exec_agent(
|
|
404
|
+
db: Database,
|
|
405
|
+
config: Config,
|
|
406
|
+
*,
|
|
407
|
+
name: str,
|
|
408
|
+
command: list[str],
|
|
409
|
+
) -> int:
|
|
410
|
+
"""Execute a command as an agent user on a VM via direct SSH subprocess.
|
|
411
|
+
|
|
412
|
+
Returns the remote exit code.
|
|
413
|
+
"""
|
|
414
|
+
import shlex
|
|
415
|
+
import subprocess
|
|
416
|
+
|
|
417
|
+
agent = db.get_agent(name)
|
|
418
|
+
if agent is None:
|
|
419
|
+
raise AgentError(f"agent '{name}' not found")
|
|
420
|
+
|
|
421
|
+
vm = _require_vm(db, agent.vm_name)
|
|
422
|
+
if vm.tailscale_host is None:
|
|
423
|
+
raise AgentError(f"VM '{vm.name}' has no Tailscale IP (init may not be complete)")
|
|
424
|
+
|
|
425
|
+
remote_cmd = command[0] if len(command) == 1 else shlex.join(command)
|
|
426
|
+
su_cmd = f"sudo -n su --login {agent.linux_user} -c {shlex.quote(remote_cmd)}"
|
|
427
|
+
|
|
428
|
+
ssh_cmd = ["ssh", "-T", "-o", "StrictHostKeyChecking=accept-new", "-o", "BatchMode=yes"]
|
|
429
|
+
if config.operator.ssh_private_key:
|
|
430
|
+
ssh_cmd.extend(["-i", str(config.operator.ssh_private_key)])
|
|
431
|
+
ssh_cmd.append(f"{vm.admin_username}@{vm.tailscale_host}")
|
|
432
|
+
ssh_cmd.append(su_cmd)
|
|
433
|
+
|
|
434
|
+
return subprocess.call(ssh_cmd)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# -- VM operations ---------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def grant_workspaces(
|
|
441
|
+
db: Database,
|
|
442
|
+
config: Config,
|
|
443
|
+
*,
|
|
444
|
+
agent_name: str,
|
|
445
|
+
workspace_names: list[str],
|
|
446
|
+
grant_all: bool = False,
|
|
447
|
+
) -> None:
|
|
448
|
+
"""Grant an agent explicit access to workspaces."""
|
|
449
|
+
agent = db.get_agent(agent_name)
|
|
450
|
+
if agent is None:
|
|
451
|
+
raise AgentError(f"agent '{agent_name}' not found")
|
|
452
|
+
|
|
453
|
+
vm = _require_vm(db, agent.vm_name)
|
|
454
|
+
|
|
455
|
+
if grant_all:
|
|
456
|
+
db.update_agent_grant_all(agent_name, True)
|
|
457
|
+
# Add to all existing VM workspace groups
|
|
458
|
+
workspaces = db.list_workspaces(vm_name=vm.name)
|
|
459
|
+
for ws in workspaces:
|
|
460
|
+
if ws.type == "vm":
|
|
461
|
+
_add_to_workspace_group(vm, config, agent.linux_user, ws.name, logger=None)
|
|
462
|
+
db.insert_agent_grant(agent_name, ws.name, "explicit")
|
|
463
|
+
output.info(f"Agent '{agent_name}' granted access to all workspaces")
|
|
464
|
+
return
|
|
465
|
+
|
|
466
|
+
for ws_name in workspace_names:
|
|
467
|
+
found_ws = db.get_workspace(ws_name)
|
|
468
|
+
if found_ws is None:
|
|
469
|
+
output.warn(f"workspace '{ws_name}' not found, skipping")
|
|
470
|
+
continue
|
|
471
|
+
_add_to_workspace_group(vm, config, agent.linux_user, ws_name, logger=None)
|
|
472
|
+
db.insert_agent_grant(agent_name, ws_name, "explicit")
|
|
473
|
+
output.detail(f"Granted: {ws_name}")
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def deny_workspaces(
|
|
477
|
+
db: Database,
|
|
478
|
+
config: Config,
|
|
479
|
+
*,
|
|
480
|
+
agent_name: str,
|
|
481
|
+
workspace_names: list[str],
|
|
482
|
+
deny_all: bool = False,
|
|
483
|
+
) -> None:
|
|
484
|
+
"""Remove explicit workspace grants from an agent."""
|
|
485
|
+
agent = db.get_agent(agent_name)
|
|
486
|
+
if agent is None:
|
|
487
|
+
raise AgentError(f"agent '{agent_name}' not found")
|
|
488
|
+
|
|
489
|
+
vm = _require_vm(db, agent.vm_name)
|
|
490
|
+
|
|
491
|
+
if deny_all:
|
|
492
|
+
db.update_agent_grant_all(agent_name, False)
|
|
493
|
+
db.delete_explicit_grants(agent_name)
|
|
494
|
+
# Remove from groups where no implicit grants remain
|
|
495
|
+
remaining_implicit: list[str] = []
|
|
496
|
+
granted = db.list_granted_workspaces(agent_name)
|
|
497
|
+
for ws_name in granted:
|
|
498
|
+
if db.has_any_grant(agent_name, ws_name):
|
|
499
|
+
remaining_implicit.append(ws_name)
|
|
500
|
+
else:
|
|
501
|
+
_remove_from_workspace_group(vm, config, agent.linux_user, ws_name, logger=None)
|
|
502
|
+
output.info(f"All explicit grants removed for agent '{agent_name}'")
|
|
503
|
+
if remaining_implicit:
|
|
504
|
+
output.warn(
|
|
505
|
+
f"agent still has implicit access via sessions to: {', '.join(remaining_implicit)}"
|
|
506
|
+
)
|
|
507
|
+
return
|
|
508
|
+
|
|
509
|
+
for ws_name in workspace_names:
|
|
510
|
+
db.delete_agent_grant(agent_name, ws_name, "explicit")
|
|
511
|
+
if not db.has_any_grant(agent_name, ws_name):
|
|
512
|
+
_remove_from_workspace_group(vm, config, agent.linux_user, ws_name, logger=None)
|
|
513
|
+
output.detail(f"Denied: {ws_name}")
|
|
514
|
+
else:
|
|
515
|
+
output.detail(f"Denied: {ws_name} (still has implicit access via sessions)")
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def list_grants(
|
|
519
|
+
db: Database,
|
|
520
|
+
*,
|
|
521
|
+
agent_name: str,
|
|
522
|
+
) -> None:
|
|
523
|
+
"""List workspace grants for an agent."""
|
|
524
|
+
agent = db.get_agent(agent_name)
|
|
525
|
+
if agent is None:
|
|
526
|
+
raise AgentError(f"agent '{agent_name}' not found")
|
|
527
|
+
|
|
528
|
+
if agent.grant_all:
|
|
529
|
+
output.info(f"Agent '{agent_name}' has grant-all enabled (access to all workspaces)")
|
|
530
|
+
|
|
531
|
+
grants = db.list_granted_workspaces_with_types(agent_name)
|
|
532
|
+
if not grants:
|
|
533
|
+
output.info("No workspace grants.")
|
|
534
|
+
return
|
|
535
|
+
|
|
536
|
+
output.info(f"{'WORKSPACE':<25} {'TYPE'}")
|
|
537
|
+
output.info("-" * 45)
|
|
538
|
+
for ws_name, has_explicit, has_implicit in grants:
|
|
539
|
+
if has_explicit and has_implicit:
|
|
540
|
+
grant_type = "explicit + implicit"
|
|
541
|
+
elif has_explicit:
|
|
542
|
+
grant_type = "explicit"
|
|
543
|
+
else:
|
|
544
|
+
grant_type = "implicit (via sessions)"
|
|
545
|
+
output.info(f"{ws_name:<25} {grant_type}")
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
# -- VM operations ---------------------------------------------------------
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _add_to_workspace_group(
|
|
552
|
+
vm: VMRow,
|
|
553
|
+
config: Config,
|
|
554
|
+
linux_user: str,
|
|
555
|
+
workspace_name: str,
|
|
556
|
+
*,
|
|
557
|
+
logger: SSHLogger | None = None,
|
|
558
|
+
) -> None:
|
|
559
|
+
"""Add an agent user to a workspace's Linux group."""
|
|
560
|
+
target = admin_exec_target(vm, config, logger=logger)
|
|
561
|
+
ws_grp = workspace_group(workspace_name)
|
|
562
|
+
# Ensure group exists (idempotent)
|
|
563
|
+
target.run(f"sh -c 'getent group {ws_grp} >/dev/null 2>&1 || /usr/sbin/groupadd {ws_grp}'", sudo=True)
|
|
564
|
+
target.run(f"usermod -aG {ws_grp} {linux_user}", sudo=True)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _remove_from_workspace_group(
|
|
568
|
+
vm: VMRow,
|
|
569
|
+
config: Config,
|
|
570
|
+
linux_user: str,
|
|
571
|
+
workspace_name: str,
|
|
572
|
+
*,
|
|
573
|
+
logger: SSHLogger | None = None,
|
|
574
|
+
) -> None:
|
|
575
|
+
"""Remove an agent user from a workspace's Linux group."""
|
|
576
|
+
target = admin_exec_target(vm, config, logger=logger)
|
|
577
|
+
ws_grp = workspace_group(workspace_name)
|
|
578
|
+
target.run(f"gpasswd -d {linux_user} {ws_grp}", sudo=True, check=False)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _collect_agent_credentials(config: Config) -> dict[str, str]:
|
|
582
|
+
"""Collect git credentials up front before any SSH work begins."""
|
|
583
|
+
agent_cfg = config.agent
|
|
584
|
+
git_tokens: dict[str, str] = {}
|
|
585
|
+
if agent_cfg.git_credentials:
|
|
586
|
+
from agentworks.vms.initializer import resolve_git_credential_providers
|
|
587
|
+
|
|
588
|
+
providers = resolve_git_credential_providers(config, agent_cfg.git_credentials)
|
|
589
|
+
for cred_name, provider in providers.items():
|
|
590
|
+
git_tokens[cred_name] = provider.obtain_token("agent")
|
|
591
|
+
return git_tokens
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _create_agent_on_vm(
|
|
595
|
+
vm: VMRow,
|
|
596
|
+
config: Config,
|
|
597
|
+
linux_user: str,
|
|
598
|
+
*,
|
|
599
|
+
git_tokens: dict[str, str] | None = None,
|
|
600
|
+
logger: SSHLogger | None = None,
|
|
601
|
+
) -> None:
|
|
602
|
+
"""Create an agent Linux user on a VM and set up their environment.
|
|
603
|
+
|
|
604
|
+
Workspace group membership is NOT set here - it is managed by the grant
|
|
605
|
+
system. This function only creates the user and configures their tools.
|
|
606
|
+
"""
|
|
607
|
+
target = admin_exec_target(vm, config, logger=logger)
|
|
608
|
+
|
|
609
|
+
output.detail(f"Creating user '{linux_user}' on VM '{vm.name}'...")
|
|
610
|
+
home = f"/home/{linux_user}"
|
|
611
|
+
|
|
612
|
+
agent_cfg = config.agent
|
|
613
|
+
agent_shell = agent_cfg.shell
|
|
614
|
+
|
|
615
|
+
# Create user with the template's shell (idempotent: skip if exists)
|
|
616
|
+
shell_path = f"/bin/{agent_shell}" if "/" not in agent_shell else agent_shell
|
|
617
|
+
user_exists = target.run(f"id {linux_user}", sudo=True, check=False)
|
|
618
|
+
if not user_exists.ok:
|
|
619
|
+
target.run(f"useradd -m -s {shell_path} {linux_user}", sudo=True)
|
|
620
|
+
else:
|
|
621
|
+
target.run(f"usermod -s {shell_path} {linux_user}", sudo=True)
|
|
622
|
+
|
|
623
|
+
# Ensure the agent tmux socket infrastructure exists. Call
|
|
624
|
+
# ensure_agent_socket_root first so this works on VMs that haven't
|
|
625
|
+
# been reinited since the socket feature was added.
|
|
626
|
+
from agentworks.sessions.tmux import cleanup_stale_sockets, ensure_agent_socket_dir, ensure_agent_socket_root
|
|
627
|
+
|
|
628
|
+
ensure_agent_socket_root(target, vm.admin_username)
|
|
629
|
+
# The per-agent dir won't exist for a brand-new agent -- suppress the
|
|
630
|
+
# "missing" warning. Misconfiguration of an existing dir still warns.
|
|
631
|
+
ensure_agent_socket_dir(target, linux_user, warn_if_missing=False)
|
|
632
|
+
removed = cleanup_stale_sockets(target, linux_user)
|
|
633
|
+
if removed:
|
|
634
|
+
output.detail(f"Cleaned up {removed} stale socket(s)")
|
|
635
|
+
|
|
636
|
+
# Write a minimal rc file with a clear agent prompt
|
|
637
|
+
if agent_shell == "zsh":
|
|
638
|
+
rc_content = f"export PS1='[agent:{linux_user}] %~%# '\n"
|
|
639
|
+
rc_file = f"{home}/.zshrc"
|
|
640
|
+
elif agent_shell == "bash":
|
|
641
|
+
rc_content = f"export PS1='[agent:{linux_user}] \\w\\$ '\n"
|
|
642
|
+
rc_file = f"{home}/.bashrc"
|
|
643
|
+
else:
|
|
644
|
+
output.warn(f"unsupported shell '{agent_shell}', skipping prompt configuration")
|
|
645
|
+
rc_content = None
|
|
646
|
+
rc_file = None
|
|
647
|
+
|
|
648
|
+
if rc_content and rc_file:
|
|
649
|
+
_write_agent_file(target, linux_user, rc_file, rc_content)
|
|
650
|
+
|
|
651
|
+
# Git safe.directory wildcard (agents access repos owned by admin)
|
|
652
|
+
if config.admin.git_force_safe_directory:
|
|
653
|
+
try:
|
|
654
|
+
_run_as_agent(target, linux_user, "git config --global --add safe.directory '*'")
|
|
655
|
+
output.detail("Git safe.directory configured for agent")
|
|
656
|
+
except Exception as e:
|
|
657
|
+
output.warn(f"agent git safe.directory setup failed: {e}")
|
|
658
|
+
|
|
659
|
+
# Git credentials for the agent (tokens collected up front)
|
|
660
|
+
if agent_cfg.git_credentials and git_tokens:
|
|
661
|
+
from agentworks.vms.initializer import resolve_git_credential_providers
|
|
662
|
+
|
|
663
|
+
output.detail("Configuring git credentials for agent...")
|
|
664
|
+
try:
|
|
665
|
+
providers = resolve_git_credential_providers(config, agent_cfg.git_credentials)
|
|
666
|
+
cred_lines: list[str] = []
|
|
667
|
+
for cred_name, provider in providers.items():
|
|
668
|
+
token = git_tokens.get(cred_name)
|
|
669
|
+
if token:
|
|
670
|
+
cred_lines.extend(provider.credential_lines(token))
|
|
671
|
+
if cred_lines:
|
|
672
|
+
cred_content = "\n".join(cred_lines) + "\n"
|
|
673
|
+
_write_agent_file(target, linux_user, f"{home}/.git-credentials", cred_content, mode="600")
|
|
674
|
+
_run_as_agent(target, linux_user, "git config --global credential.helper store")
|
|
675
|
+
except Exception as e:
|
|
676
|
+
output.warn(f"agent git credential setup failed: {e}")
|
|
677
|
+
|
|
678
|
+
# User install commands for the agent
|
|
679
|
+
_run_agent_install_commands(vm, config, linux_user, home, logger=logger)
|
|
680
|
+
|
|
681
|
+
# Dotfiles for the agent
|
|
682
|
+
if agent_cfg.dotfiles_source:
|
|
683
|
+
output.detail(f"Syncing agent dotfiles from {agent_cfg.dotfiles_source}...")
|
|
684
|
+
try:
|
|
685
|
+
from agentworks.sources import SourceRefError, fetch_dir, parse_source_ref
|
|
686
|
+
|
|
687
|
+
ref = parse_source_ref(agent_cfg.dotfiles_source)
|
|
688
|
+
dest = agent_cfg.dotfiles_destination.replace("~", home)
|
|
689
|
+
|
|
690
|
+
# Clone/pull as the agent user (git credentials are already configured)
|
|
691
|
+
if ref.kind == "git":
|
|
692
|
+
import shlex as _shlex
|
|
693
|
+
|
|
694
|
+
# If already cloned from the same repo, pull instead of clone
|
|
695
|
+
is_git = _run_as_agent(
|
|
696
|
+
target, linux_user, f"test -d {_shlex.quote(dest)}/.git",
|
|
697
|
+
check=False,
|
|
698
|
+
)
|
|
699
|
+
if is_git.ok:
|
|
700
|
+
remote = _run_as_agent(
|
|
701
|
+
target, linux_user,
|
|
702
|
+
f"git -C {_shlex.quote(dest)} remote get-url origin",
|
|
703
|
+
check=False,
|
|
704
|
+
)
|
|
705
|
+
if remote.ok and remote.stdout.strip() == ref.path:
|
|
706
|
+
output.detail("Dotfiles already cloned, pulling latest...")
|
|
707
|
+
if ref.ref:
|
|
708
|
+
_run_as_agent(
|
|
709
|
+
target, linux_user,
|
|
710
|
+
f"git -C {_shlex.quote(dest)} fetch",
|
|
711
|
+
check=False, timeout=120,
|
|
712
|
+
)
|
|
713
|
+
checkout = _run_as_agent(
|
|
714
|
+
target, linux_user,
|
|
715
|
+
f"git -C {_shlex.quote(dest)} checkout {_shlex.quote(ref.ref)}",
|
|
716
|
+
check=False,
|
|
717
|
+
)
|
|
718
|
+
if not checkout.ok:
|
|
719
|
+
output.warn(
|
|
720
|
+
f"dotfiles checkout of '{ref.ref}' failed, skipping"
|
|
721
|
+
)
|
|
722
|
+
else:
|
|
723
|
+
pull = _run_as_agent(
|
|
724
|
+
target, linux_user,
|
|
725
|
+
f"git -C {_shlex.quote(dest)} pull",
|
|
726
|
+
check=False, timeout=120,
|
|
727
|
+
)
|
|
728
|
+
if not pull.ok:
|
|
729
|
+
output.warn(
|
|
730
|
+
"dotfiles pull failed (local changes?), skipping"
|
|
731
|
+
)
|
|
732
|
+
else:
|
|
733
|
+
raise SourceRefError(
|
|
734
|
+
f"dotfiles destination {dest} exists but is a different repo"
|
|
735
|
+
)
|
|
736
|
+
else:
|
|
737
|
+
clone_cmd = f"git clone {_shlex.quote(ref.path)} {_shlex.quote(dest)}"
|
|
738
|
+
if ref.ref:
|
|
739
|
+
clone_cmd = (
|
|
740
|
+
f"git clone --branch {_shlex.quote(ref.ref)}"
|
|
741
|
+
f" {_shlex.quote(ref.path)} {_shlex.quote(dest)}"
|
|
742
|
+
)
|
|
743
|
+
_run_as_agent(target, linux_user, clone_cmd, timeout=120)
|
|
744
|
+
else:
|
|
745
|
+
# Local source: copy as admin then chown
|
|
746
|
+
tmp_dotfiles = f"/tmp/agentworks-{linux_user}-dotfiles"
|
|
747
|
+
target.run(f"rm -rf {tmp_dotfiles}", check=False)
|
|
748
|
+
from agentworks.sources import fetch_dir
|
|
749
|
+
|
|
750
|
+
fetch_dir(ref, target, tmp_dotfiles)
|
|
751
|
+
target.run(f"mv {tmp_dotfiles} {dest}", sudo=True)
|
|
752
|
+
target.run(f"chown -R {linux_user}:{linux_user} {dest}", sudo=True)
|
|
753
|
+
|
|
754
|
+
output.detail(f"Running agent dotfiles install: {agent_cfg.dotfiles_install_cmd}")
|
|
755
|
+
_run_as_agent(
|
|
756
|
+
target,
|
|
757
|
+
linux_user,
|
|
758
|
+
f"cd {dest} && {agent_cfg.dotfiles_install_cmd}",
|
|
759
|
+
timeout=120,
|
|
760
|
+
)
|
|
761
|
+
except (SourceRefError, Exception) as e:
|
|
762
|
+
output.warn(f"agent dotfiles failed: {e}")
|
|
763
|
+
|
|
764
|
+
# Mise for the agent
|
|
765
|
+
_run_agent_mise_setup(vm, config, linux_user, home, logger=logger)
|
|
766
|
+
|
|
767
|
+
# Install nerf Claude plugin for the agent
|
|
768
|
+
if config.agent.nerf_install_claude_plugin:
|
|
769
|
+
_install_nerf_claude_plugin_for_agent(target, linux_user, agent_shell)
|
|
770
|
+
|
|
771
|
+
# Claude Code marketplaces and plugins for the agent
|
|
772
|
+
from agentworks.vms.initializer import install_claude_plugins
|
|
773
|
+
|
|
774
|
+
install_claude_plugins(
|
|
775
|
+
lambda cmd, timeout: _run_as_agent(target, linux_user, cmd, timeout=timeout),
|
|
776
|
+
config.agent.claude_marketplaces,
|
|
777
|
+
config.agent.claude_plugins,
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _install_nerf_claude_plugin_for_agent(
|
|
782
|
+
target: ExecTarget,
|
|
783
|
+
linux_user: str,
|
|
784
|
+
shell: str,
|
|
785
|
+
) -> None:
|
|
786
|
+
"""Install the nerf Claude Code plugin for an agent user. Non-fatal."""
|
|
787
|
+
from agentworks.ssh import SSHError
|
|
788
|
+
|
|
789
|
+
try:
|
|
790
|
+
check = _run_as_agent(
|
|
791
|
+
target,
|
|
792
|
+
linux_user,
|
|
793
|
+
f"{shell} -lc 'test -x $AGENTWORKS_NERF_HOME/claude-plugin/scripts/install-plugin'",
|
|
794
|
+
check=False,
|
|
795
|
+
)
|
|
796
|
+
if not check.ok:
|
|
797
|
+
output.warn(
|
|
798
|
+
"nerf Claude plugin not found on this VM. "
|
|
799
|
+
"Set nerf_build_claude_plugin = true in your VM template and reinit."
|
|
800
|
+
)
|
|
801
|
+
return
|
|
802
|
+
|
|
803
|
+
output.detail("Installing nerf Claude plugin for agent...")
|
|
804
|
+
_run_as_agent(
|
|
805
|
+
target,
|
|
806
|
+
linux_user,
|
|
807
|
+
f"{shell} -lc '$AGENTWORKS_NERF_HOME/claude-plugin/scripts/install-plugin'",
|
|
808
|
+
timeout=30,
|
|
809
|
+
)
|
|
810
|
+
output.detail("Nerf Claude plugin installed for agent")
|
|
811
|
+
except SSHError as e:
|
|
812
|
+
output.warn(f"agent nerf plugin install failed: {e}")
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _delete_agent_on_vm(
|
|
816
|
+
vm: VMRow,
|
|
817
|
+
config: Config,
|
|
818
|
+
linux_user: str,
|
|
819
|
+
*,
|
|
820
|
+
logger: SSHLogger | None = None,
|
|
821
|
+
) -> None:
|
|
822
|
+
"""Remove an agent Linux user from a VM."""
|
|
823
|
+
from agentworks.ssh import SSHError
|
|
824
|
+
|
|
825
|
+
target = admin_exec_target(vm, config, logger=logger)
|
|
826
|
+
|
|
827
|
+
try:
|
|
828
|
+
# Kill any running processes for the user
|
|
829
|
+
target.run(f"pkill -u {linux_user}", sudo=True, check=False)
|
|
830
|
+
# Remove the user and their home directory
|
|
831
|
+
target.run(f"userdel -r {linux_user}", sudo=True)
|
|
832
|
+
except SSHError as e:
|
|
833
|
+
output.warn(f"remote cleanup for '{linux_user}' failed: {e}")
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _run_agent_install_commands(
|
|
837
|
+
vm: VMRow,
|
|
838
|
+
config: Config,
|
|
839
|
+
linux_user: str,
|
|
840
|
+
home: str,
|
|
841
|
+
*,
|
|
842
|
+
logger: SSHLogger | None = None,
|
|
843
|
+
) -> None:
|
|
844
|
+
"""Run user install commands for an agent. Failures warn but do not abort."""
|
|
845
|
+
command_names = config.agent.user_install_commands
|
|
846
|
+
if not command_names:
|
|
847
|
+
return
|
|
848
|
+
|
|
849
|
+
import shlex
|
|
850
|
+
|
|
851
|
+
from agentworks.catalog import load_catalog
|
|
852
|
+
from agentworks.ssh import SSHError
|
|
853
|
+
|
|
854
|
+
catalog = load_catalog(config)
|
|
855
|
+
target = admin_exec_target(vm, config, logger=logger)
|
|
856
|
+
shell = config.agent.shell
|
|
857
|
+
path_additions: list[str] = []
|
|
858
|
+
total = len(command_names)
|
|
859
|
+
|
|
860
|
+
for i, name in enumerate(command_names, 1):
|
|
861
|
+
entry = catalog.user_install_commands.get(name)
|
|
862
|
+
if entry is None:
|
|
863
|
+
output.warn(f"install command '{name}' not found in catalog")
|
|
864
|
+
continue
|
|
865
|
+
# Skip if already installed for this user (short timeout)
|
|
866
|
+
test_cmd = _build_agent_test_command(entry, linux_user, home)
|
|
867
|
+
if test_cmd:
|
|
868
|
+
try:
|
|
869
|
+
check = target.run(test_cmd, sudo=True, check=False, timeout=10)
|
|
870
|
+
if check.returncode == 0:
|
|
871
|
+
output.detail(f"Agent install command {i}/{total} ({name}): already installed, skipping")
|
|
872
|
+
path_additions.extend(entry.path)
|
|
873
|
+
continue
|
|
874
|
+
except SSHError as e:
|
|
875
|
+
output.warn(f"install check for '{name}' failed ({e}), assuming not installed")
|
|
876
|
+
|
|
877
|
+
truncated = entry.command[:60]
|
|
878
|
+
output.detail(f"Agent install command {i}/{total} ({name}): {truncated}...")
|
|
879
|
+
try:
|
|
880
|
+
# Run as the agent user via su, in their login shell
|
|
881
|
+
target.run(
|
|
882
|
+
f"su - {shlex.quote(linux_user)} -c {shlex.quote(f'{shell} -lc {shlex.quote(entry.command)}')}",
|
|
883
|
+
sudo=True,
|
|
884
|
+
timeout=120,
|
|
885
|
+
)
|
|
886
|
+
except SSHError as e:
|
|
887
|
+
output.warn(f"agent install command '{name}' failed: {e}")
|
|
888
|
+
path_additions.extend(entry.path)
|
|
889
|
+
|
|
890
|
+
# Write PATH additions for the agent
|
|
891
|
+
if path_additions:
|
|
892
|
+
from agentworks.vms.initializer import AGENTWORKS_PROFILE
|
|
893
|
+
|
|
894
|
+
output.detail(f"Adding {len(path_additions)} PATH entries for agent...")
|
|
895
|
+
lines = ["# Managed by agentworks -- do not edit"]
|
|
896
|
+
for p in path_additions:
|
|
897
|
+
expanded = p.replace("~", "$HOME", 1) if p.startswith("~") else p
|
|
898
|
+
lines.append(f'export PATH="{expanded}:$PATH"')
|
|
899
|
+
content = "\n".join(lines) + "\n"
|
|
900
|
+
try:
|
|
901
|
+
profile_path = f"{home}/{AGENTWORKS_PROFILE}"
|
|
902
|
+
_write_agent_file(target, linux_user, profile_path, content)
|
|
903
|
+
# Source from shell profiles (run as agent so appends work)
|
|
904
|
+
source_line = f". {profile_path}"
|
|
905
|
+
rc_files = [f"{home}/.profile", f"{home}/.bashrc"]
|
|
906
|
+
if shell == "zsh":
|
|
907
|
+
rc_files.append(f"{home}/.zprofile")
|
|
908
|
+
for rc in rc_files:
|
|
909
|
+
_run_as_agent(
|
|
910
|
+
target,
|
|
911
|
+
linux_user,
|
|
912
|
+
f"grep -q {AGENTWORKS_PROFILE} {rc} 2>/dev/null || printf '%s\\n' '{source_line}' >> {rc}",
|
|
913
|
+
)
|
|
914
|
+
except SSHError as e:
|
|
915
|
+
output.warn(f"agent PATH configuration failed: {e}")
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _run_agent_mise_setup(
|
|
919
|
+
vm: VMRow,
|
|
920
|
+
config: Config,
|
|
921
|
+
linux_user: str,
|
|
922
|
+
home: str,
|
|
923
|
+
*,
|
|
924
|
+
logger: SSHLogger | None = None,
|
|
925
|
+
) -> None:
|
|
926
|
+
"""Set up mise for an agent: shims PATH, config, lockfile, install."""
|
|
927
|
+
from agentworks.ssh import SSHError
|
|
928
|
+
|
|
929
|
+
target = admin_exec_target(vm, config, logger=logger)
|
|
930
|
+
agent_cfg = config.agent
|
|
931
|
+
has_packages = bool(agent_cfg.mise_packages)
|
|
932
|
+
has_lockfile = bool(agent_cfg.mise_lockfile)
|
|
933
|
+
|
|
934
|
+
if not has_packages and not has_lockfile:
|
|
935
|
+
return
|
|
936
|
+
|
|
937
|
+
from agentworks.vms.initializer import AGENTWORKS_PROFILE, AGENTWORKS_RC, MISE_ACTIVATE_LINES
|
|
938
|
+
|
|
939
|
+
# Append mise shims PATH to agent's agentworks profile
|
|
940
|
+
shims_path = f"{home}/.local/share/mise/shims"
|
|
941
|
+
try:
|
|
942
|
+
profile_path = f"{home}/{AGENTWORKS_PROFILE}"
|
|
943
|
+
_run_as_agent(
|
|
944
|
+
target,
|
|
945
|
+
linux_user,
|
|
946
|
+
f"printf '%s' 'export PATH=\"{shims_path}:$PATH\"\n' >> {profile_path}",
|
|
947
|
+
)
|
|
948
|
+
source_line = f". {profile_path}"
|
|
949
|
+
for rc in [f"{home}/.profile", f"{home}/.zprofile"]:
|
|
950
|
+
_run_as_agent(
|
|
951
|
+
target,
|
|
952
|
+
linux_user,
|
|
953
|
+
f"grep -q {AGENTWORKS_PROFILE} {rc} 2>/dev/null || printf '%s\\n' '{source_line}' >> {rc}",
|
|
954
|
+
)
|
|
955
|
+
except SSHError as e:
|
|
956
|
+
output.warn(f"agent profile configuration failed: {e}")
|
|
957
|
+
|
|
958
|
+
# Write mise activation to agent's rc (interactive shell hooks)
|
|
959
|
+
if agent_cfg.mise_activate:
|
|
960
|
+
try:
|
|
961
|
+
rc_path = f"{home}/{AGENTWORKS_RC}"
|
|
962
|
+
rc_content = f"# Managed by agentworks -- do not edit\n{MISE_ACTIVATE_LINES}\n"
|
|
963
|
+
_write_agent_file(target, linux_user, rc_path, rc_content)
|
|
964
|
+
source_line = f". {rc_path}"
|
|
965
|
+
for rc in [f"{home}/.bashrc", f"{home}/.zshrc"]:
|
|
966
|
+
_run_as_agent(
|
|
967
|
+
target,
|
|
968
|
+
linux_user,
|
|
969
|
+
f"grep -q {AGENTWORKS_RC} {rc} 2>/dev/null || printf '%s\\n' '{source_line}' >> {rc}",
|
|
970
|
+
)
|
|
971
|
+
except SSHError as e:
|
|
972
|
+
output.warn(f"agent rc configuration failed: {e}")
|
|
973
|
+
|
|
974
|
+
mise_config_dir = f"{home}/.config/mise"
|
|
975
|
+
|
|
976
|
+
# Write mise config if packages declared
|
|
977
|
+
if has_packages:
|
|
978
|
+
output.detail(f"Writing mise config for agent ({len(agent_cfg.mise_packages)} packages)...")
|
|
979
|
+
settings_lines = ["[settings]", f'install_before = "{agent_cfg.mise_install_before}"', ""]
|
|
980
|
+
tools_lines = ["[tools]"]
|
|
981
|
+
for pkg in agent_cfg.mise_packages:
|
|
982
|
+
if "@" in pkg:
|
|
983
|
+
name, version = pkg.rsplit("@", 1)
|
|
984
|
+
tools_lines.append(f'"{name}" = "{version}"')
|
|
985
|
+
else:
|
|
986
|
+
tools_lines.append(f'"{pkg}" = "latest"')
|
|
987
|
+
mise_config = "\n".join(settings_lines + tools_lines) + "\n"
|
|
988
|
+
try:
|
|
989
|
+
_run_as_agent(target, linux_user, f"mkdir -p {mise_config_dir}")
|
|
990
|
+
_write_agent_file(target, linux_user, f"{mise_config_dir}/config.toml", mise_config)
|
|
991
|
+
except SSHError as e:
|
|
992
|
+
output.warn(f"agent mise config write failed: {e}")
|
|
993
|
+
return
|
|
994
|
+
|
|
995
|
+
# Copy lockfile if configured
|
|
996
|
+
if has_lockfile and agent_cfg.mise_lockfile:
|
|
997
|
+
output.detail(f"Fetching agent mise lockfile from {agent_cfg.mise_lockfile}...")
|
|
998
|
+
try:
|
|
999
|
+
from agentworks.sources import SourceRefError, fetch_file, parse_source_ref
|
|
1000
|
+
|
|
1001
|
+
ref = parse_source_ref(agent_cfg.mise_lockfile, default_filename="mise.lock")
|
|
1002
|
+
_run_as_agent(target, linux_user, f"mkdir -p {mise_config_dir}")
|
|
1003
|
+
# Fetch to tmp (as admin, needs network), then move to agent home
|
|
1004
|
+
tmp_lock = f"/tmp/agentworks-{linux_user}-mise-lock"
|
|
1005
|
+
fetch_file(ref, target, tmp_lock)
|
|
1006
|
+
target.run(f"mv {tmp_lock} {mise_config_dir}/mise.lock", sudo=True)
|
|
1007
|
+
target.run(f"chown {linux_user}:{linux_user} {mise_config_dir}/mise.lock", sudo=True)
|
|
1008
|
+
except (SourceRefError, SSHError) as e:
|
|
1009
|
+
output.warn(f"agent mise lockfile fetch failed: {e}")
|
|
1010
|
+
|
|
1011
|
+
# Run mise install as the agent user
|
|
1012
|
+
lockfile_exists = False
|
|
1013
|
+
try:
|
|
1014
|
+
result = _run_as_agent(target, linux_user, f"test -f {mise_config_dir}/mise.lock", check=False)
|
|
1015
|
+
lockfile_exists = result.ok
|
|
1016
|
+
except SSHError:
|
|
1017
|
+
pass
|
|
1018
|
+
|
|
1019
|
+
installed = False
|
|
1020
|
+
install_flags = "-y --locked" if lockfile_exists else "-y"
|
|
1021
|
+
try:
|
|
1022
|
+
_run_as_agent(target, linux_user, f"mise install {install_flags}", timeout=300)
|
|
1023
|
+
output.detail("Agent mise packages installed")
|
|
1024
|
+
installed = True
|
|
1025
|
+
except SSHError as e:
|
|
1026
|
+
if lockfile_exists and agent_cfg.mise_allow_unlocked:
|
|
1027
|
+
output.warn("some agent packages not in lockfile, installing unlocked...")
|
|
1028
|
+
try:
|
|
1029
|
+
_run_as_agent(target, linux_user, "mise install -y", timeout=300)
|
|
1030
|
+
output.detail("Agent mise packages installed (unlocked)")
|
|
1031
|
+
installed = True
|
|
1032
|
+
except SSHError as e2:
|
|
1033
|
+
output.warn(f"agent mise install failed: {e2}")
|
|
1034
|
+
else:
|
|
1035
|
+
output.warn(f"agent mise install failed: {e}")
|
|
1036
|
+
if lockfile_exists:
|
|
1037
|
+
output.warn("set mise_allow_unlocked = true to install unlocked packages")
|
|
1038
|
+
|
|
1039
|
+
# Prune stale tool versions not in the current config
|
|
1040
|
+
if installed and agent_cfg.mise_prune_on_reinit:
|
|
1041
|
+
import contextlib
|
|
1042
|
+
|
|
1043
|
+
from agentworks.ssh import SSHError as _SSHError
|
|
1044
|
+
|
|
1045
|
+
with contextlib.suppress(_SSHError):
|
|
1046
|
+
_run_as_agent(target, linux_user, "mise prune -y", timeout=60)
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def _build_agent_test_command(
|
|
1050
|
+
entry: UserInstallCommandEntry,
|
|
1051
|
+
linux_user: str,
|
|
1052
|
+
home: str,
|
|
1053
|
+
) -> str | None:
|
|
1054
|
+
"""Build a test command that runs as the agent user."""
|
|
1055
|
+
import shlex as _shlex
|
|
1056
|
+
|
|
1057
|
+
test_exec: str | None = getattr(entry, "test_exec", None)
|
|
1058
|
+
test_file: str | None = getattr(entry, "test_file", None)
|
|
1059
|
+
test_dir: str | None = getattr(entry, "test_dir", None)
|
|
1060
|
+
if test_exec:
|
|
1061
|
+
# Run command -v as the agent user via su
|
|
1062
|
+
inner = f"command -v {_shlex.quote(test_exec)}"
|
|
1063
|
+
return f"su - {_shlex.quote(linux_user)} -c {_shlex.quote(inner)} > /dev/null 2>&1"
|
|
1064
|
+
if test_file:
|
|
1065
|
+
path = test_file.replace("~", home, 1) if test_file.startswith("~") else test_file
|
|
1066
|
+
return f"test -f {_shlex.quote(path)}"
|
|
1067
|
+
if test_dir:
|
|
1068
|
+
path = test_dir.replace("~", home, 1) if test_dir.startswith("~") else test_dir
|
|
1069
|
+
return f"test -d {_shlex.quote(path)}"
|
|
1070
|
+
return None
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
# -- Helpers ---------------------------------------------------------------
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def _require_vm(db: Database, vm_name: str) -> VMRow:
|
|
1077
|
+
vm = db.get_vm(vm_name)
|
|
1078
|
+
if vm is None:
|
|
1079
|
+
raise VMError(f"VM '{vm_name}' not found")
|
|
1080
|
+
return vm
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def _require_workspace(db: Database, workspace_name: str) -> WorkspaceRow:
|
|
1084
|
+
ws = db.get_workspace(workspace_name)
|
|
1085
|
+
if ws is None:
|
|
1086
|
+
raise AgentError(f"workspace '{workspace_name}' not found")
|
|
1087
|
+
return ws
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def _require_vm_for_workspace(db: Database, ws: WorkspaceRow) -> VMRow:
|
|
1091
|
+
assert ws.vm_name is not None
|
|
1092
|
+
vm = db.get_vm(ws.vm_name)
|
|
1093
|
+
if vm is None:
|
|
1094
|
+
raise VMError(f"VM '{ws.vm_name}' not found")
|
|
1095
|
+
return vm
|