plato-sdk-v2 2.6.1__py3-none-any.whl → 2.7.0__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 (32) hide show
  1. plato/_generated/__init__.py +1 -1
  2. plato/_generated/api/v2/__init__.py +2 -1
  3. plato/_generated/api/v2/networks/__init__.py +23 -0
  4. plato/_generated/api/v2/networks/add_member.py +75 -0
  5. plato/_generated/api/v2/networks/create_network.py +70 -0
  6. plato/_generated/api/v2/networks/delete_network.py +68 -0
  7. plato/_generated/api/v2/networks/get_network.py +69 -0
  8. plato/_generated/api/v2/networks/list_members.py +69 -0
  9. plato/_generated/api/v2/networks/list_networks.py +74 -0
  10. plato/_generated/api/v2/networks/remove_member.py +73 -0
  11. plato/_generated/api/v2/networks/update_member.py +80 -0
  12. plato/_generated/api/v2/sessions/__init__.py +4 -0
  13. plato/_generated/api/v2/sessions/add_ssh_key.py +81 -0
  14. plato/_generated/api/v2/sessions/connect_network.py +89 -0
  15. plato/_generated/models/__init__.py +150 -24
  16. plato/v1/cli/agent.py +45 -52
  17. plato/v1/cli/chronos.py +46 -58
  18. plato/v1/cli/main.py +14 -25
  19. plato/v1/cli/pm.py +37 -92
  20. plato/v1/cli/proxy.py +343 -0
  21. plato/v1/cli/sandbox.py +305 -385
  22. plato/v1/cli/ssh.py +12 -167
  23. plato/v1/cli/verify.py +79 -55
  24. plato/v1/cli/world.py +13 -12
  25. plato/v2/async_/client.py +24 -2
  26. plato/v2/async_/session.py +48 -0
  27. plato/v2/sync/client.py +24 -2
  28. plato/v2/sync/session.py +48 -0
  29. {plato_sdk_v2-2.6.1.dist-info → plato_sdk_v2-2.7.0.dist-info}/METADATA +1 -1
  30. {plato_sdk_v2-2.6.1.dist-info → plato_sdk_v2-2.7.0.dist-info}/RECORD +32 -20
  31. {plato_sdk_v2-2.6.1.dist-info → plato_sdk_v2-2.7.0.dist-info}/WHEEL +0 -0
  32. {plato_sdk_v2-2.6.1.dist-info → plato_sdk_v2-2.7.0.dist-info}/entry_points.txt +0 -0
plato/v1/cli/sandbox.py CHANGED
@@ -25,12 +25,18 @@ from plato._generated.api.v1.gitea import (
25
25
  get_gitea_credentials,
26
26
  get_simulator_repository,
27
27
  )
28
- from plato._generated.api.v1.sandbox import setup_root_access, setup_sandbox, start_worker
28
+ from plato._generated.api.v1.sandbox import start_worker
29
29
  from plato._generated.api.v2.jobs import get_flows as jobs_get_flows
30
30
  from plato._generated.api.v2.jobs import state as jobs_state
31
+ from plato._generated.api.v2.sessions import (
32
+ add_ssh_key as sessions_add_ssh_key,
33
+ )
31
34
  from plato._generated.api.v2.sessions import (
32
35
  close as sessions_close,
33
36
  )
37
+ from plato._generated.api.v2.sessions import (
38
+ connect_network as sessions_connect_network,
39
+ )
34
40
  from plato._generated.api.v2.sessions import (
35
41
  execute as sessions_execute,
36
42
  )
@@ -47,17 +53,19 @@ from plato._generated.api.v2.sessions import (
47
53
  state as sessions_state,
48
54
  )
49
55
  from plato._generated.models import (
50
- AppSchemasBuildModelsSetupSandboxRequest,
56
+ AddSSHKeyRequest,
51
57
  AppSchemasBuildModelsSimConfigCompute,
52
58
  AppSchemasBuildModelsSimConfigDataset,
53
59
  AppSchemasBuildModelsSimConfigMetadata,
60
+ ConnectNetworkRequest,
54
61
  CreateCheckpointRequest,
55
62
  ExecuteCommandRequest,
56
63
  Flow,
57
- SetupRootPasswordRequest,
58
64
  VMManagementRequest,
59
65
  )
60
- from plato.v1.cli.ssh import setup_ssh_for_sandbox
66
+ from plato.v1.cli.proxy import ssh as gateway_ssh_command
67
+ from plato.v1.cli.proxy import tunnel as gateway_tunnel_command
68
+ from plato.v1.cli.ssh import generate_ssh_key_pair
61
69
  from plato.v1.cli.utils import (
62
70
  SANDBOX_FILE,
63
71
  console,
@@ -81,6 +89,10 @@ UUID_PATTERN = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-
81
89
  sandbox_app = typer.Typer(help="Manage sandboxes for simulator development")
82
90
  sandbox_app.add_typer(sandbox_verify_app, name="verify")
83
91
 
92
+ # Register gateway SSH/tunnel commands
93
+ sandbox_app.command(name="ssh")(gateway_ssh_command)
94
+ sandbox_app.command(name="tunnel")(gateway_tunnel_command)
95
+
84
96
 
85
97
  def format_public_url_with_router_target(public_url: str | None, service_name: str | None) -> str | None:
86
98
  """Format public URL with _plato_router_target parameter for browser access.
@@ -130,41 +142,45 @@ def sandbox_start(
130
142
  # Common options
131
143
  timeout: int = typer.Option(1800, "--timeout", help="VM lifetime in seconds (default: 30 minutes)"),
132
144
  no_reset: bool = typer.Option(False, "--no-reset", help="Skip initial reset after ready"),
145
+ connect_network: bool = typer.Option(
146
+ True, "--network/--no-network", help="Connect VMs to WireGuard network for SSH access (default: enabled)"
147
+ ),
133
148
  json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
134
149
  working_dir: Path = typer.Option(
135
150
  None, "--working-dir", "-w", help="Working directory for .sandbox.yaml and .plato/"
136
151
  ),
137
152
  ):
138
- """
139
- Start a sandbox environment.
140
-
141
- THREE MODES (pick one):
142
-
143
- 1. FROM CONFIG (-c): Use plato-config.yml in current directory
144
-
145
- plato sandbox start -c
146
- plato sandbox start -c -d base
147
-
148
- 2. FROM SIMULATOR/ARTIFACT (-s or -a): Start from existing artifact
149
-
150
- -s <simulator> Latest tag
151
- -s <simulator>:<tag> Specific tag
152
- -s <simulator>:<artifact-uuid> Specific artifact (UUID detected)
153
- -s <simulator> -a <artifact-uuid> Explicit artifact
154
- -a <artifact-uuid> Artifact only (no simulator name)
155
-
156
- 3. BLANK VM (-b): Create fresh VM with custom specs
157
-
158
- plato sandbox start -b --service myapp
159
- plato sandbox start -b --service myapp --cpus 4 --memory 2048
160
-
161
- EXAMPLES:
162
-
163
- plato sandbox start -c # From config
164
- plato sandbox start -s espocrm # Latest artifact
165
- plato sandbox start -s espocrm:staging # Staging tag
166
- plato sandbox start -s espocrm:e9c25ca5-1234-5678-... # Specific artifact
167
- plato sandbox start -a e9c25ca5-1234-5678-9abc-... # Artifact only
153
+ """Start a sandbox environment for simulator development.
154
+
155
+ Creates a VM that can be used to develop and test simulators. You must pick exactly
156
+ one mode to specify how the sandbox should be created.
157
+
158
+ Mode Options (pick exactly one):
159
+ -c, --from-config: Create VM using settings from plato-config.yml in the current
160
+ directory. Uses the compute specs (cpus, memory, disk) from the config file.
161
+ -s, --simulator: Start from an existing simulator. Supports formats:
162
+ '-s name' (latest tag), '-s name:tag' (specific tag), '-s name:uuid' (specific artifact)
163
+ -a, --artifact-id: Start directly from a specific artifact UUID
164
+ -b, --blank: Create a blank VM with custom specs (requires --service)
165
+
166
+ Config Mode Options:
167
+ -d, --dataset: Which dataset from the config to use (default: "base")
168
+
169
+ Simulator Mode Options:
170
+ -t, --tag: Artifact tag to use (default: "latest")
171
+
172
+ Blank VM Options:
173
+ --service: Service name for the blank VM (required with -b)
174
+ --cpus: Number of CPUs (default: 2)
175
+ --memory: Memory in MB (default: 1024)
176
+ --disk: Disk size in MB (default: 10240)
177
+
178
+ Common Options:
179
+ --timeout: VM lifetime in seconds before auto-shutdown (default: 1800 = 30 min)
180
+ --no-reset: Skip the initial environment reset after the VM is ready
181
+ --no-network: Disable WireGuard network connection (enabled by default for SSH access)
182
+ -j, --json: Output results as JSON instead of formatted text
183
+ -w, --working-dir: Directory to store .sandbox.yaml and .plato/ files
168
184
  """
169
185
  api_key = require_api_key()
170
186
 
@@ -328,12 +344,17 @@ def sandbox_start(
328
344
  # Create session using v2 SDK
329
345
  if not json_output:
330
346
  console.print("[cyan]Creating sandbox...[/cyan]")
347
+ if connect_network:
348
+ console.print("[cyan]Network connection will be established after VM is ready...[/cyan]")
349
+ console.print(
350
+ "[yellow]Note: First connection on older VMs may take a few minutes to install WireGuard[/yellow]"
351
+ )
331
352
 
332
353
  try:
333
354
  plato = PlatoV2(api_key=api_key)
334
355
  if not env_config:
335
356
  raise ValueError("No environment configuration provided")
336
- session = plato.sessions.create(envs=[env_config], timeout=timeout)
357
+ session = plato.sessions.create(envs=[env_config], timeout=timeout, connect_network=connect_network)
337
358
 
338
359
  # Get session info
339
360
  session_id = session.session_id
@@ -363,102 +384,64 @@ def sandbox_start(
363
384
  session.reset()
364
385
 
365
386
  # Setup SSH for ALL modes (so you can SSH into any sandbox)
366
- ssh_host = None
367
- ssh_config_path = None
368
387
  ssh_private_key_path = None
369
388
 
370
- if job_id:
389
+ if session_id and connect_network:
371
390
  if not json_output:
372
391
  console.print("[cyan]Setting up SSH access...[/cyan]")
373
392
  try:
374
- # Step 1: Generate SSH key pair and create SSH config (like Go hub does)
375
- # For config mode: use "plato" user (setup_sandbox configures this)
376
- # For artifact/simulator modes: use "root" user (setup_root_access configures this)
377
- ssh_username = "plato" if (mode == "config" and full_dataset_config_dict) else "root"
378
-
393
+ # Step 1: Generate SSH key pair
379
394
  if not json_output:
380
395
  console.print("[cyan] Generating SSH key pair...[/cyan]")
381
396
 
382
- base_url = os.getenv("PLATO_BASE_URL", "https://plato.so")
383
- ssh_info = setup_ssh_for_sandbox(base_url, job_id, username=ssh_username, working_dir=working_dir)
384
- ssh_host = ssh_info["ssh_host"]
385
- ssh_config_path = ssh_info["config_path"]
386
- ssh_private_key_path = ssh_info["private_key_path"]
387
- ssh_public_key = ssh_info["public_key"]
397
+ public_key, private_key_path = generate_ssh_key_pair(session_id[:8], working_dir)
398
+ ssh_private_key_path = private_key_path
388
399
 
400
+ # Step 2: Add SSH key to all VMs in the session via API
389
401
  if not json_output:
390
- console.print(f"[cyan] SSH config: {ssh_config_path}[/cyan]")
402
+ console.print("[cyan] Adding SSH key to VMs...[/cyan]")
391
403
 
392
- # Step 2: Upload SSH key to sandbox
393
- # For --from-config mode: use setup_sandbox with full config
394
- # For --simulator/--artifact-id modes: use setup_root_access (just SSH key, no config changes)
395
- if not json_output:
396
- console.print("[cyan] Uploading SSH key to sandbox...[/cyan]")
397
-
398
- if mode == "config" and full_dataset_config_dict:
399
- # Full config from plato-config.yml - use setup_sandbox API
400
- compute_dict = full_dataset_config_dict.get("compute", {})
401
- metadata_dict = full_dataset_config_dict.get("metadata", {})
402
- services_dict = full_dataset_config_dict.get("services")
403
- listeners_dict = full_dataset_config_dict.get("listeners")
404
-
405
- compute_obj = AppSchemasBuildModelsSimConfigCompute(
406
- cpus=compute_dict.get("cpus", 2),
407
- memory=compute_dict.get("memory", 2048),
408
- disk=compute_dict.get("disk", 10240),
409
- app_port=compute_dict.get("app_port", 80),
410
- plato_messaging_port=compute_dict.get("plato_messaging_port", 7000),
411
- )
412
-
413
- metadata_obj = AppSchemasBuildModelsSimConfigMetadata(
414
- name=metadata_dict.get("name", sim_name or "sandbox"),
415
- description=metadata_dict.get("description", ""),
416
- source_code_url=metadata_dict.get("source_code_url"),
417
- start_url=metadata_dict.get("start_url", "blank"),
418
- license=metadata_dict.get("license"),
419
- variables=metadata_dict.get("variables"),
420
- flows_path=metadata_dict.get("flows_path"),
421
- )
404
+ ssh_username = "root"
405
+ add_key_request = AddSSHKeyRequest(
406
+ public_key=public_key,
407
+ username=ssh_username,
408
+ )
422
409
 
423
- dataset_config_obj = AppSchemasBuildModelsSimConfigDataset(
424
- compute=compute_obj,
425
- metadata=metadata_obj,
426
- services=services_dict,
427
- listeners=listeners_dict,
410
+ with get_http_client() as client:
411
+ add_key_response = sessions_add_ssh_key.sync(
412
+ client=client,
413
+ session_id=session_id,
414
+ body=add_key_request,
415
+ x_api_key=api_key,
428
416
  )
429
417
 
430
- dataset_value = dataset_name or state_extras.get("dataset", "base")
431
- setup_request = AppSchemasBuildModelsSetupSandboxRequest(
432
- service=sim_name or "",
433
- dataset=str(dataset_value) if dataset_value else "",
434
- plato_dataset_config=dataset_config_obj,
435
- ssh_public_key=ssh_public_key,
436
- )
418
+ if not json_output:
419
+ # Debug: show full response
420
+ console.print("[yellow]DEBUG add_ssh_key response:[/yellow]")
421
+ console.print(f" success: {add_key_response.success}")
422
+
423
+ # Show results for each job
424
+ for jid, result in add_key_response.results.items():
425
+ console.print(f" [cyan]Job {jid}:[/cyan]")
426
+ console.print(f" success: {result.success}")
427
+ console.print(f" error: {result.error}")
428
+ console.print(" output:")
429
+ if result.output:
430
+ console.print(result.output)
431
+ else:
432
+ console.print(" (none)")
433
+ if result.success:
434
+ console.print(f" [green]✓[/green] {jid}: SSH key added")
435
+ else:
436
+ console.print(f" [red]✗[/red] {jid}: {result.error}")
437
437
 
438
- with get_http_client() as client:
439
- _setup_response = setup_sandbox.sync(
440
- client=client,
441
- public_id=job_id,
442
- body=setup_request,
443
- x_api_key=api_key,
444
- )
438
+ if add_key_response.success:
439
+ if not json_output:
440
+ console.print("[green]SSH setup complete![/green]")
441
+ console.print(" [cyan]SSH:[/cyan] plato sandbox ssh")
445
442
  else:
446
- # Artifact/simulator modes - use setup_root_access API (just SSH key, preserves existing config)
447
- setup_root_request = SetupRootPasswordRequest(
448
- ssh_public_key=ssh_public_key,
449
- )
450
-
451
- with get_http_client() as client:
452
- _setup_response = setup_root_access.sync(
453
- client=client,
454
- public_id=job_id,
455
- body=setup_root_request,
456
- x_api_key=api_key,
457
- )
458
-
459
- if not json_output:
460
- console.print("[green]SSH setup complete![/green]")
461
- console.print(f" [cyan]SSH:[/cyan] ssh -F {ssh_config_path} {ssh_host}")
443
+ if not json_output:
444
+ console.print("[red]SSH key setup failed - SSH may not work[/red]")
462
445
 
463
446
  except Exception as e:
464
447
  if not json_output:
@@ -482,16 +465,15 @@ def sandbox_start(
482
465
  "created_at": datetime.now(timezone.utc).isoformat(),
483
466
  **state_extras,
484
467
  }
485
- # Add SSH info if available from setup_sandbox
486
- if ssh_host:
487
- state["ssh_host"] = ssh_host
488
- if ssh_config_path:
489
- state["ssh_config_path"] = ssh_config_path
468
+ # Add SSH private key path if available
490
469
  if ssh_private_key_path:
491
470
  state["ssh_private_key_path"] = ssh_private_key_path
492
471
  # Add heartbeat PID
493
472
  if heartbeat_pid:
494
473
  state["heartbeat_pid"] = heartbeat_pid
474
+ # Add network connection status
475
+ if connect_network:
476
+ state["network_connected"] = True
495
477
  save_sandbox_state(state, working_dir)
496
478
 
497
479
  # Close the plato client (heartbeat process keeps session alive)
@@ -504,11 +486,9 @@ def sandbox_start(
504
486
  "job_id": job_id,
505
487
  "public_url": display_url, # Full URL with _plato_router_target
506
488
  }
507
- if ssh_host:
508
- output["ssh_host"] = ssh_host
509
- if ssh_config_path:
510
- output["ssh_config_path"] = ssh_config_path
511
- output["ssh_command"] = f"ssh -F {ssh_config_path} {ssh_host}"
489
+ if ssh_private_key_path:
490
+ output["ssh_private_key_path"] = ssh_private_key_path
491
+ output["ssh_command"] = "plato sandbox ssh"
512
492
  console.print(json.dumps(output))
513
493
  else:
514
494
  console.print("\n[green]Sandbox started successfully![/green]")
@@ -517,15 +497,36 @@ def sandbox_start(
517
497
  if public_url:
518
498
  display_url = format_public_url_with_router_target(public_url, sim_name)
519
499
  console.print(f" [cyan]Public URL:[/cyan] {display_url}")
520
- if ssh_host and ssh_config_path:
521
- console.print(f" [cyan]SSH:[/cyan] ssh -F {ssh_config_path} {ssh_host}")
500
+ if ssh_private_key_path:
501
+ console.print(" [cyan]SSH:[/cyan] plato sandbox ssh")
502
+ # Warn if using host-only routing (no VM-to-VM mesh)
503
+ if connect_network and hasattr(session, "network_host_only") and session.network_host_only:
504
+ console.print("\n[yellow]Warning: WireGuard not available in VM - using host-only routing[/yellow]")
505
+ console.print("[yellow] SSH from outside works, but VM-to-VM networking is disabled[/yellow]")
522
506
  console.print(f"\n[dim]State saved to {SANDBOX_FILE}[/dim]")
523
507
 
524
508
  except Exception as e:
525
509
  if json_output:
526
510
  console.print(json.dumps({"error": str(e)}))
527
511
  else:
528
- console.print(f"[red]Failed to start sandbox: {e}[/red]")
512
+ error_msg = str(e)
513
+ # Check if it's a network connection error with VM details
514
+ if "Network connection failed" in error_msg or "WireGuard" in error_msg:
515
+ console.print("[red]Failed to start sandbox - network setup failed[/red]")
516
+ console.print("[yellow]VM error:[/yellow]")
517
+ # Clean up error message - remove SSH warnings and format nicely
518
+ clean_lines = []
519
+ for line in error_msg.split("\n"):
520
+ line = line.strip()
521
+ # Skip SSH warnings
522
+ if line.startswith("Warning:") or "known hosts" in line:
523
+ continue
524
+ if line:
525
+ clean_lines.append(line)
526
+ for line in clean_lines:
527
+ console.print(f" {line}")
528
+ else:
529
+ console.print(f"[red]Failed to start sandbox: {e}[/red]")
529
530
  raise typer.Exit(1) from e
530
531
 
531
532
 
@@ -542,30 +543,23 @@ def sandbox_snapshot(
542
543
  messaging_port: int = typer.Option(None, "--messaging-port", help="Override messaging port"),
543
544
  target: str = typer.Option(None, "--target", help="Override target domain (e.g., myapp.web.plato.so)"),
544
545
  ):
545
- """
546
- Create a snapshot of the current sandbox state.
547
-
548
- Saves the artifact ID to .sandbox.yaml so it can be used by
549
- 'plato pm submit base' without needing to specify it manually.
550
-
551
- CONFIG BEHAVIOR:
552
-
553
- - Sandboxes started from config (-c): Automatically includes plato-config.yml,
554
- flows.yml, app_port, and messaging_port in the snapshot.
555
- - Sandboxes started from artifact: Inherits config from parent artifact.
556
- Use --include-config to override with local config files.
557
-
558
- USAGE:
559
-
560
- plato sandbox snapshot # Creates snapshot, saves artifact_id
561
- plato sandbox snapshot --json # JSON output
562
- plato sandbox snapshot -c # Force include local config files
563
- plato sandbox snapshot --app-port 8080 # Override app port
564
-
565
- NEXT STEPS:
566
-
567
- After snapshot, you can submit for review:
568
- plato pm submit base # Reads artifact_id from .sandbox.yaml
546
+ """Create a snapshot of the current sandbox state.
547
+
548
+ Captures the current VM state as an artifact that can be submitted for review or
549
+ used as a starting point for future sandboxes. The artifact ID is saved to
550
+ .sandbox.yaml so it can be used by 'plato pm submit base'.
551
+
552
+ For sandboxes started from config (-c), automatically includes plato-config.yml and
553
+ flows.yml in the snapshot. For sandboxes started from an artifact, config is inherited
554
+ from the parent.
555
+
556
+ Options:
557
+ -j, --json: Output results as JSON instead of formatted text
558
+ -c, --include-config: Force including local plato-config.yml and flows.yml in the
559
+ snapshot. Auto-enabled for sandboxes started from config.
560
+ --app-port: Override the internal application port stored in the artifact
561
+ --messaging-port: Override the Plato messaging port stored in the artifact
562
+ --target: Override the target domain (e.g., myapp.web.plato.so)
569
563
  """
570
564
  api_key = require_api_key()
571
565
  state = require_sandbox_state()
@@ -660,26 +654,11 @@ def sandbox_snapshot(
660
654
 
661
655
  @sandbox_app.command(name="stop")
662
656
  def sandbox_stop():
663
- """
664
- Stop and destroy the current sandbox.
657
+ """Stop and destroy the current sandbox.
665
658
 
666
- Closes the session, cleans up SSH keys, and removes .sandbox.yaml.
659
+ Terminates the remote VM session, stops the heartbeat background process,
660
+ cleans up local SSH keys created for this sandbox, and removes .sandbox.yaml.
667
661
  Run this when you're done with the sandbox or want to start fresh.
668
-
669
- REQUIRES:
670
-
671
- .sandbox.yaml in current directory (created by 'plato sandbox start')
672
-
673
- USAGE:
674
-
675
- plato sandbox stop
676
-
677
- WHAT IT DOES:
678
-
679
- 1. Stops the heartbeat process
680
- 2. Closes the remote session
681
- 3. Removes SSH config and keys
682
- 4. Deletes .sandbox.yaml
683
662
  """
684
663
  api_key = require_api_key()
685
664
  state = require_sandbox_state()
@@ -703,16 +682,9 @@ def sandbox_stop():
703
682
  x_api_key=api_key,
704
683
  )
705
684
 
706
- # Clean up SSH config and key files (like Go hub does)
707
- ssh_config_path = state.get("ssh_config_path")
685
+ # Clean up SSH key files
708
686
  ssh_private_key_path = state.get("ssh_private_key_path")
709
687
 
710
- if ssh_config_path:
711
- config_file = Path(ssh_config_path)
712
- if config_file.exists():
713
- config_file.unlink()
714
- console.print(f"[dim]Removed {ssh_config_path}[/dim]")
715
-
716
688
  if ssh_private_key_path:
717
689
  private_key_file = Path(ssh_private_key_path)
718
690
  public_key_file = Path(ssh_private_key_path + ".pub")
@@ -731,32 +703,78 @@ def sandbox_stop():
731
703
  raise typer.Exit(1) from e
732
704
 
733
705
 
734
- @sandbox_app.command(name="status")
735
- def sandbox_status(
706
+ @sandbox_app.command(name="connect-network")
707
+ def sandbox_connect_network(
708
+ session_id: str = typer.Option(None, "--session", "-s", help="Session ID (uses .sandbox.yaml if not provided)"),
736
709
  json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
737
710
  ):
711
+ """Connect all jobs in a session to a WireGuard network.
712
+
713
+ Establishes encrypted peer-to-peer networking between VMs in the session,
714
+ allowing SSH access from outside and VM-to-VM communication. Pre-generates
715
+ WireGuard keys, allocates IPs from the session network subnet, and configures
716
+ full mesh networking.
717
+
718
+ Options:
719
+ -s, --session: Session ID to connect. If not provided, reads from .sandbox.yaml
720
+ -j, --json: Output results as JSON instead of formatted text
738
721
  """
739
- Show current sandbox status and connection info.
722
+ api_key = require_api_key()
723
+
724
+ # Get session ID from argument or .sandbox.yaml
725
+ if session_id is None:
726
+ state = require_sandbox_state()
727
+ session_id = require_sandbox_field(state, "session_id")
728
+
729
+ console.print(f"[cyan]Connecting session {session_id} to network...[/cyan]")
730
+
731
+ try:
732
+ with get_http_client() as client:
733
+ result = sessions_connect_network.sync(
734
+ client=client,
735
+ session_id=session_id,
736
+ body=ConnectNetworkRequest(),
737
+ x_api_key=api_key,
738
+ )
740
739
 
741
- Displays the public URL, SSH config, VM status, and other details
742
- from .sandbox.yaml plus live status from the API.
740
+ if json_output:
741
+ console.print_json(data=result)
742
+ else:
743
+ # Display results
744
+ statuses = result.get("statuses", {})
745
+ success_count = sum(1 for s in statuses.values() if s.get("success"))
746
+ total_count = len(statuses)
743
747
 
744
- REQUIRES:
748
+ if success_count == total_count:
749
+ console.print(f"[green]All {total_count} jobs connected to network[/green]")
750
+ else:
751
+ console.print(f"[yellow]{success_count}/{total_count} jobs connected[/yellow]")
752
+
753
+ for job_id, status in statuses.items():
754
+ if status.get("success"):
755
+ wg_ip = status.get("wireguard_ip", "unknown")
756
+ console.print(f" [green]✓[/green] {job_id}: {wg_ip}")
757
+ else:
758
+ error = status.get("error", "unknown error")
759
+ console.print(f" [red]✗[/red] {job_id}: {error}")
745
760
 
746
- .sandbox.yaml in current directory (created by 'plato sandbox start')
761
+ except Exception as e:
762
+ console.print(f"[red]Failed to connect network: {e}[/red]")
763
+ raise typer.Exit(1) from e
747
764
 
748
- USAGE:
749
765
 
750
- plato sandbox status # Human-readable output
751
- plato sandbox status --json # JSON output for scripts
766
+ @sandbox_app.command(name="status")
767
+ def sandbox_status(
768
+ json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
769
+ ):
770
+ """Show current sandbox status and connection info.
752
771
 
753
- OUTPUT INCLUDES:
772
+ Displays information from .sandbox.yaml combined with live status from the API.
773
+ Shows session ID, job ID, VM status (running/stopped/etc.), public URL for browser
774
+ access, SSH connection details, network connection status, and heartbeat status.
754
775
 
755
- - Public URL (for browser access)
756
- - SSH config path (for 'ssh -F <config> sandbox-<id>')
757
- - VM status (running/stopped/etc.)
758
- - Session ID, Job ID
759
- - Service name, dataset
776
+ Options:
777
+ -j, --json: Output all status info as JSON instead of formatted text
760
778
  """
761
779
  state = require_sandbox_state()
762
780
 
@@ -876,10 +894,14 @@ def sandbox_status(
876
894
  console.print(f" [cyan]Created:[/cyan] {state.get('created_at')}")
877
895
 
878
896
  # Display SSH command if available
879
- ssh_host = state.get("ssh_host")
880
- ssh_config_path = state.get("ssh_config_path")
881
- if ssh_host and ssh_config_path:
882
- console.print(f" [cyan]SSH:[/cyan] ssh -F {ssh_config_path} {ssh_host}")
897
+ ssh_private_key_path = state.get("ssh_private_key_path")
898
+ job_id = state.get("job_id")
899
+ if ssh_private_key_path and job_id:
900
+ console.print(" [cyan]SSH:[/cyan] plato sandbox ssh")
901
+
902
+ # Display network connection status
903
+ if state.get("network_connected"):
904
+ console.print(" [cyan]Network:[/cyan] [green]connected[/green] (WireGuard)")
883
905
 
884
906
  # Display heartbeat process status
885
907
  heartbeat_pid = state.get("heartbeat_pid")
@@ -930,38 +952,24 @@ def sandbox_start_worker(
930
952
  wait_timeout: int = typer.Option(240, "--wait-timeout", help="Timeout in seconds for --wait (default: 240)"),
931
953
  json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
932
954
  ):
933
- """
934
- Start the Plato worker in the sandbox.
935
-
936
- The worker handles flow execution and state tracking. Only start it
937
- AFTER verifying login works (via 'plato sandbox flow' or browser testing).
938
-
939
- REQUIRES:
940
-
941
- .sandbox.yaml in current directory (created by 'plato sandbox start')
942
- plato-config.yml in current directory (or specify with --config-path)
943
-
944
- USAGE:
945
-
946
- plato sandbox start-worker # Uses plato-config.yml in cwd
947
- plato sandbox start-worker --wait # Wait for worker to be ready
948
- plato sandbox start-worker -d base # Specify dataset
949
- plato sandbox start-worker --config-path ./plato-config.yml
950
-
951
- WORKFLOW POSITION:
952
-
953
- 1. plato sandbox start -c
954
- 2. plato sandbox start-services
955
- 3. plato sandbox flow ← verify login works first!
956
- 4. plato sandbox start-worker --wait ← you are here (wait ~2-3 min)
957
- 5. plato sandbox flow ← run login again to verify with worker
958
- 6. plato sandbox state --verify-no-mutations ← verify no mutations
959
- 7. plato sandbox snapshot
960
-
961
- WARNING:
962
-
963
- Starting the worker with broken login causes infinite error loops.
964
- Always verify login works before starting the worker.
955
+ """Start the Plato worker in the sandbox.
956
+
957
+ The worker is the Plato component that handles flow execution, database audit
958
+ tracking, and state management. It should be started AFTER verifying the login
959
+ flow works manually, since a broken login with an active worker causes error loops.
960
+
961
+ Reads the dataset configuration from plato-config.yml to configure the worker
962
+ with the correct services, listeners, and compute settings.
963
+
964
+ Options:
965
+ -s, --service: Service name to configure the worker for. Defaults to value in
966
+ .sandbox.yaml if not provided.
967
+ -d, --dataset: Dataset name from plato-config.yml (default: "base")
968
+ --config-path: Path to plato-config.yml. Defaults to current directory.
969
+ -w, --wait: After starting, poll the state API until the worker is ready.
970
+ Useful in scripts to ensure the worker is fully initialized.
971
+ --wait-timeout: Timeout in seconds for --wait (default: 240 seconds)
972
+ -j, --json: Output results as JSON instead of formatted text
965
973
  """
966
974
  api_key = require_api_key()
967
975
  state = require_sandbox_state()
@@ -1122,36 +1130,20 @@ def sandbox_sync(
1122
1130
  timeout: int = typer.Option(120, "--timeout", "-t", help="Command timeout in seconds"),
1123
1131
  json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
1124
1132
  ):
1125
- """
1126
- Sync local files to the sandbox VM.
1127
-
1128
- Uploads local files to the remote sandbox. Useful for updating
1129
- docker-compose.yml, flows.yml, or other config without restarting.
1130
-
1131
- REQUIRES:
1132
-
1133
- .sandbox.yaml in current directory (created by 'plato sandbox start')
1134
-
1135
- USAGE:
1136
-
1137
- plato sandbox sync # Sync current directory
1138
- plato sandbox sync ./base # Sync specific directory
1139
- plato sandbox sync -r /custom/path # Custom remote path
1140
-
1141
- DEFAULT REMOTE PATH:
1142
-
1143
- /home/plato/worktree/<service>/
1144
-
1145
- WHAT IT SYNCS:
1133
+ """Sync local files to the sandbox VM.
1146
1134
 
1147
- - Respects .gitignore patterns
1148
- - Excludes .git, __pycache__, node_modules, etc.
1149
- - Creates tar archive and extracts on remote
1135
+ Creates a tar archive of local files and uploads it to the remote VM via the
1136
+ execute API. Excludes common build artifacts (.git, __pycache__, node_modules,
1137
+ .venv, etc.) to reduce transfer size.
1150
1138
 
1151
- NOTE:
1139
+ Arguments:
1140
+ path: Local path to sync (default: current directory)
1152
1141
 
1153
- For most workflows, use 'plato sandbox start-services' instead,
1154
- which syncs files AND restarts containers.
1142
+ Options:
1143
+ -r, --remote-path: Destination path on the VM. Defaults to
1144
+ /home/plato/worktree/<service> based on the service in .sandbox.yaml
1145
+ -t, --timeout: Command timeout in seconds for the extract operation (default: 120)
1146
+ -j, --json: Output results as JSON instead of formatted text
1155
1147
  """
1156
1148
  api_key = require_api_key()
1157
1149
  state = require_sandbox_state()
@@ -1367,41 +1359,25 @@ def sandbox_flow(
1367
1359
  local: bool = typer.Option(False, "--local", "-l", help="Force using local flows.yml only"),
1368
1360
  api: bool = typer.Option(False, "--api", "-a", help="Force fetching flows from API only"),
1369
1361
  ):
1370
- """
1371
- Execute a test flow against the running sandbox.
1372
-
1373
- Runs a flow (like login) to verify it works before starting the worker.
1374
- Opens a browser and executes the flow steps automatically.
1375
-
1376
- REQUIRES:
1377
-
1378
- .sandbox.yaml in current directory (created by 'plato sandbox start')
1379
- Either:
1380
- - Local plato-config.yml with flows_path pointing to flows.yml
1381
- - Or sandbox started from artifact (flows fetched from API)
1382
-
1383
- USAGE:
1362
+ """Execute a test flow against the running sandbox.
1384
1363
 
1385
- plato sandbox flow # Run "login" flow (default)
1386
- plato sandbox flow -f login # Explicit flow name
1387
- plato sandbox flow -f incorrect_login # Test failed login flow
1388
- plato sandbox flow --local # Force local flows.yml
1389
- plato sandbox flow --api # Force API flows (from artifact)
1364
+ Runs a named flow (like "login") using Playwright to verify it works correctly.
1365
+ Opens a visible browser window, navigates to the sandbox public URL, and executes
1366
+ the flow steps automatically. Useful for testing login flows before starting
1367
+ the worker.
1390
1368
 
1391
- WORKFLOW POSITION:
1369
+ By default, looks for flows in local flows.yml (path from plato-config.yml
1370
+ metadata.flows_path), then falls back to fetching from the API if the sandbox
1371
+ was started from an artifact.
1392
1372
 
1393
- 1. plato sandbox start -c
1394
- 2. plato sandbox start-services
1395
- 3. plato sandbox flow ← you are here (verify login)
1396
- 4. plato sandbox start-worker
1397
- 5. plato sandbox snapshot
1373
+ Options:
1374
+ -f, --flow-name: Name of the flow to execute from flows.yml (default: "login")
1375
+ -l, --local: Only use local flows.yml file. Errors if not found instead of
1376
+ falling back to API.
1377
+ -a, --api: Only fetch flows from the API (from the artifact). Ignores any
1378
+ local flows.yml file.
1398
1379
 
1399
- FLOW SOURCE (default priority):
1400
-
1401
- 1. Local flows.yml (from plato-config.yml metadata.flows_path)
1402
- 2. API (fetched from artifact if started from simulator)
1403
-
1404
- Use --local or --api to override this behavior.
1380
+ Note: --local and --api are mutually exclusive.
1405
1381
  """
1406
1382
  # Validate mutually exclusive flags
1407
1383
  if local and api:
@@ -1581,30 +1557,19 @@ def sandbox_state_cmd(
1581
1557
  ),
1582
1558
  json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
1583
1559
  ):
1584
- """
1585
- Get the database state/mutations from the simulator.
1586
-
1587
- Shows what database changes have been detected since the last reset.
1588
- Useful during review to verify:
1589
- - No mutations after login (state should be empty)
1590
- - Mutations appear after making changes (audit is working)
1591
-
1592
- REQUIRES:
1593
-
1594
- .sandbox.yaml in current directory (created by 'plato sandbox start')
1595
-
1596
- USAGE:
1597
-
1598
- plato sandbox state # Show current state
1599
- plato sandbox state --verify-no-mutations # Exit 1 if mutations found
1600
- plato sandbox state -v # Short form
1601
-
1602
- USED DURING REVIEW:
1603
-
1604
- 1. Run login flow
1605
- 2. plato sandbox state -v ← should pass (no mutations)
1606
- 3. Make a change in the app
1607
- 4. plato sandbox state ← should show mutations
1560
+ """Get the database state/mutations from the simulator.
1561
+
1562
+ Queries the worker to show what database changes have been detected since the last
1563
+ reset. Displays mutations grouped by table and operation type (INSERT/UPDATE/DELETE).
1564
+ Useful for verifying that login flows don't cause unwanted database mutations and
1565
+ that the audit system is properly tracking changes.
1566
+
1567
+ Options:
1568
+ -v, --verify-no-mutations: Exit with code 1 if any mutations are detected.
1569
+ Useful for CI/automation to verify login doesn't cause database changes.
1570
+ If mutations are found, the exit code indicates failure.
1571
+ -j, --json: Output the full state response as JSON instead of formatted text.
1572
+ Includes has_mutations and has_error fields for scripting.
1608
1573
  """
1609
1574
  sandbox_state = require_sandbox_state()
1610
1575
  api_key = require_api_key()
@@ -1790,33 +1755,19 @@ def sandbox_clear_audit(
1790
1755
  dataset: str = typer.Option("base", "--dataset", "-d", help="Dataset name"),
1791
1756
  json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
1792
1757
  ):
1793
- """
1794
- Clear the audit_log table(s) in the sandbox database.
1795
-
1796
- Truncates all audit_log tables to reset mutation tracking. Use this after
1797
- initial setup/login to clear any mutations before running a clean login flow.
1798
-
1799
- REQUIRES:
1758
+ """Clear the audit_log table(s) in the sandbox database.
1800
1759
 
1801
- .sandbox.yaml in current directory (created by 'plato sandbox start')
1802
- plato-config.yml with database listener config
1760
+ Truncates all audit_log tables to reset mutation tracking. Use this after initial
1761
+ setup or login has generated expected mutations, so you can verify that subsequent
1762
+ login flows don't create new mutations.
1803
1763
 
1804
- USAGE:
1764
+ Reads database connection info from plato-config.yml listeners and executes the
1765
+ appropriate SQL (PostgreSQL TRUNCATE or MySQL DELETE) via SSH to the sandbox VM.
1805
1766
 
1806
- plato sandbox clear-audit # Uses plato-config.yml in cwd
1807
- plato sandbox clear-audit -d base # Specify dataset
1808
- plato sandbox clear-audit --json # JSON output
1809
-
1810
- WORKFLOW POSITION:
1811
-
1812
- 1. plato sandbox start -c
1813
- 2. plato sandbox start-services
1814
- 3. plato sandbox start-worker --wait
1815
- 4. (agent does initial login/setup, generating mutations)
1816
- 5. plato sandbox clear-audit ← you are here
1817
- 6. plato sandbox flow ← clean login flow
1818
- 7. plato sandbox state --verify-no-mutations ← should pass now
1819
- 8. plato sandbox snapshot
1767
+ Options:
1768
+ --config-path: Path to plato-config.yml file (default: looks in current directory)
1769
+ -d, --dataset: Dataset name to read listener configuration from (default: "base")
1770
+ -j, --json: Output results as JSON instead of formatted text
1820
1771
  """
1821
1772
  state = require_sandbox_state()
1822
1773
 
@@ -1932,26 +1883,15 @@ def sandbox_clear_audit(
1932
1883
 
1933
1884
  @sandbox_app.command(name="audit-ui")
1934
1885
  def sandbox_audit_ui():
1935
- """
1936
- Launch Streamlit UI for configuring database audit rules.
1937
-
1938
- Opens a visual interface to help configure audit_ignore_tables
1939
- in plato-config.yml. Useful when you see unwanted mutations
1940
- during review (like session tables, timestamps, etc.).
1941
-
1942
- REQUIRES:
1886
+ """Launch Streamlit UI for configuring database audit rules.
1943
1887
 
1944
- streamlit installed: pip install streamlit psycopg2-binary pymysql
1888
+ Opens a visual web interface to help configure audit_ignore_tables in plato-config.yml.
1889
+ The UI shows database tables and their recent mutations, making it easy to identify
1890
+ which tables or columns should be ignored (like session tables, last_login timestamps,
1891
+ etc. that change on every login).
1945
1892
 
1946
- USAGE:
1947
-
1948
- plato sandbox audit-ui
1949
-
1950
- WHEN TO USE:
1951
-
1952
- - Review shows mutations after login (sessions, timestamps)
1953
- - Need to figure out which tables/columns to ignore
1954
- - Want visual help building audit_ignore_tables config
1893
+ Requires streamlit and database drivers to be installed:
1894
+ pip install streamlit psycopg2-binary pymysql
1955
1895
  """
1956
1896
  # Check if streamlit is installed
1957
1897
  if not shutil.which("streamlit"):
@@ -2117,40 +2057,20 @@ def _stop_heartbeat_process(pid: int) -> bool:
2117
2057
  def sandbox_start_services(
2118
2058
  json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
2119
2059
  ):
2120
- """
2121
- Deploy and start docker compose services on the sandbox.
2122
-
2123
- Syncs your local code to the VM and starts the containers defined
2124
- in plato-config.yml. This is the main command for deploying your app.
2125
-
2126
- REQUIRES:
2127
-
2128
- .sandbox.yaml in current directory (created by 'plato sandbox start -c')
2129
- plato-config.yml with services defined
2130
-
2131
- USAGE:
2132
-
2133
- plato sandbox start-services # Deploy and start containers
2134
- plato sandbox start-services --json # JSON output
2135
-
2136
- WHAT IT DOES:
2137
-
2138
- 1. Pushes local code to Plato Hub (Gitea)
2139
- 2. Clones code on VM via SSH
2140
- 3. Runs 'docker compose up -d' on VM
2141
- 4. Waits for containers to be healthy
2142
-
2143
- WORKFLOW POSITION:
2060
+ """Deploy and start docker compose services on the sandbox.
2144
2061
 
2145
- 1. plato sandbox start -c ← creates VM
2146
- 2. plato sandbox start-services ← you are here (deploy app)
2147
- 3. plato sandbox flow ← verify login
2148
- 4. plato sandbox start-worker
2149
- 5. plato sandbox snapshot
2062
+ Syncs your local code to the sandbox VM and starts containers. The process:
2063
+ 1. Gets Gitea credentials and pushes local code to a new branch on Plato Hub
2064
+ 2. Clones the code on the VM via SSH
2065
+ 3. Runs 'docker compose up -d' for each docker-compose service defined in
2066
+ the plato-config.yml services section
2150
2067
 
2151
- AFTER MAKING CHANGES:
2068
+ Run this command again after making local changes to re-sync and restart containers.
2069
+ Requires SSH to be configured (network is enabled by default).
2152
2070
 
2153
- Run this command again to re-sync and restart containers.
2071
+ Options:
2072
+ -j, --json: Output results as JSON instead of formatted text. Includes
2073
+ branch name, repo URL, VM path, and list of services started.
2154
2074
  """
2155
2075
  api_key = require_api_key()
2156
2076
  state = require_sandbox_state()