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.
Files changed (59) hide show
  1. agentworks/__init__.py +1 -0
  2. agentworks/agents/__init__.py +0 -0
  3. agentworks/agents/manager.py +1095 -0
  4. agentworks/agents/templates.py +145 -0
  5. agentworks/catalog.py +264 -0
  6. agentworks/catalog.toml +131 -0
  7. agentworks/cli.py +1462 -0
  8. agentworks/completions/__init__.py +33 -0
  9. agentworks/completions/bash.py +179 -0
  10. agentworks/completions/install.py +122 -0
  11. agentworks/completions/powershell.py +270 -0
  12. agentworks/completions/spec.py +216 -0
  13. agentworks/completions/zsh.py +256 -0
  14. agentworks/config.py +894 -0
  15. agentworks/db.py +1083 -0
  16. agentworks/doctor.py +430 -0
  17. agentworks/git_credentials/__init__.py +0 -0
  18. agentworks/git_credentials/azdo.py +29 -0
  19. agentworks/git_credentials/base.py +71 -0
  20. agentworks/git_credentials/github.py +22 -0
  21. agentworks/nerf-config.yaml +16 -0
  22. agentworks/output.py +296 -0
  23. agentworks/remote_exec.py +286 -0
  24. agentworks/sample-config.toml +289 -0
  25. agentworks/sessions/__init__.py +0 -0
  26. agentworks/sessions/console.py +164 -0
  27. agentworks/sessions/manager.py +1297 -0
  28. agentworks/sessions/templates.py +101 -0
  29. agentworks/sessions/tmux.py +503 -0
  30. agentworks/sources.py +303 -0
  31. agentworks/ssh.py +759 -0
  32. agentworks/ssh_config.py +255 -0
  33. agentworks/vm_hosts/__init__.py +0 -0
  34. agentworks/vm_hosts/manager.py +86 -0
  35. agentworks/vms/__init__.py +0 -0
  36. agentworks/vms/backup.py +409 -0
  37. agentworks/vms/base.py +56 -0
  38. agentworks/vms/bootstrap_script.py +185 -0
  39. agentworks/vms/cloud_init.py +55 -0
  40. agentworks/vms/initializer.py +1523 -0
  41. agentworks/vms/manager.py +1122 -0
  42. agentworks/vms/provisioners/__init__.py +0 -0
  43. agentworks/vms/provisioners/azure.py +602 -0
  44. agentworks/vms/provisioners/lima.py +295 -0
  45. agentworks/vms/provisioners/proxmox.py +279 -0
  46. agentworks/vms/provisioners/proxmox_api.py +261 -0
  47. agentworks/vms/provisioners/wsl2.py +340 -0
  48. agentworks/vms/templates.py +152 -0
  49. agentworks/workspaces/__init__.py +0 -0
  50. agentworks/workspaces/backends/__init__.py +0 -0
  51. agentworks/workspaces/backends/local.py +119 -0
  52. agentworks/workspaces/backends/vm.py +175 -0
  53. agentworks/workspaces/manager.py +1080 -0
  54. agentworks/workspaces/templates.py +76 -0
  55. agentworks/workspaces/tmuxinator.py +80 -0
  56. agentworks_cli-0.2.1.dist-info/METADATA +635 -0
  57. agentworks_cli-0.2.1.dist-info/RECORD +59 -0
  58. agentworks_cli-0.2.1.dist-info/WHEEL +4 -0
  59. 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