This is page 18 of 21. Use http://codebase.md/trycua/cua?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .all-contributorsrc ├── .cursorignore ├── .devcontainer │ ├── devcontainer.json │ ├── post-install.sh │ └── README.md ├── .dockerignore ├── .gitattributes ├── .github │ ├── FUNDING.yml │ ├── scripts │ │ ├── get_pyproject_version.py │ │ └── tests │ │ ├── __init__.py │ │ ├── README.md │ │ └── test_get_pyproject_version.py │ └── workflows │ ├── ci-lume.yml │ ├── docker-publish-kasm.yml │ ├── docker-publish-xfce.yml │ ├── docker-reusable-publish.yml │ ├── npm-publish-computer.yml │ ├── npm-publish-core.yml │ ├── publish-lume.yml │ ├── pypi-publish-agent.yml │ ├── pypi-publish-computer-server.yml │ ├── pypi-publish-computer.yml │ ├── pypi-publish-core.yml │ ├── pypi-publish-mcp-server.yml │ ├── pypi-publish-pylume.yml │ ├── pypi-publish-som.yml │ ├── pypi-reusable-publish.yml │ └── test-validation-script.yml ├── .gitignore ├── .vscode │ ├── docs.code-workspace │ ├── launch.json │ ├── libs-ts.code-workspace │ ├── lume.code-workspace │ ├── lumier.code-workspace │ ├── py.code-workspace │ └── settings.json ├── blog │ ├── app-use.md │ ├── assets │ │ ├── composite-agents.png │ │ ├── docker-ubuntu-support.png │ │ ├── hack-booth.png │ │ ├── hack-closing-ceremony.jpg │ │ ├── hack-cua-ollama-hud.jpeg │ │ ├── hack-leaderboard.png │ │ ├── hack-the-north.png │ │ ├── hack-winners.jpeg │ │ ├── hack-workshop.jpeg │ │ ├── hud-agent-evals.png │ │ └── trajectory-viewer.jpeg │ ├── bringing-computer-use-to-the-web.md │ ├── build-your-own-operator-on-macos-1.md │ ├── build-your-own-operator-on-macos-2.md │ ├── composite-agents.md │ ├── cua-hackathon.md │ ├── hack-the-north.md │ ├── hud-agent-evals.md │ ├── human-in-the-loop.md │ ├── introducing-cua-cloud-containers.md │ ├── lume-to-containerization.md │ ├── sandboxed-python-execution.md │ ├── training-computer-use-models-trajectories-1.md │ ├── trajectory-viewer.md │ ├── ubuntu-docker-support.md │ └── windows-sandbox.md ├── CONTRIBUTING.md ├── Development.md ├── Dockerfile ├── docs │ ├── .gitignore │ ├── .prettierrc │ ├── content │ │ └── docs │ │ ├── agent-sdk │ │ │ ├── agent-loops.mdx │ │ │ ├── benchmarks │ │ │ │ ├── index.mdx │ │ │ │ ├── interactive.mdx │ │ │ │ ├── introduction.mdx │ │ │ │ ├── meta.json │ │ │ │ ├── osworld-verified.mdx │ │ │ │ ├── screenspot-pro.mdx │ │ │ │ └── screenspot-v2.mdx │ │ │ ├── callbacks │ │ │ │ ├── agent-lifecycle.mdx │ │ │ │ ├── cost-saving.mdx │ │ │ │ ├── index.mdx │ │ │ │ ├── logging.mdx │ │ │ │ ├── meta.json │ │ │ │ ├── pii-anonymization.mdx │ │ │ │ └── trajectories.mdx │ │ │ ├── chat-history.mdx │ │ │ ├── custom-computer-handlers.mdx │ │ │ ├── custom-tools.mdx │ │ │ ├── customizing-computeragent.mdx │ │ │ ├── integrations │ │ │ │ ├── hud.mdx │ │ │ │ └── meta.json │ │ │ ├── message-format.mdx │ │ │ ├── meta.json │ │ │ ├── migration-guide.mdx │ │ │ ├── prompt-caching.mdx │ │ │ ├── supported-agents │ │ │ │ ├── composed-agents.mdx │ │ │ │ ├── computer-use-agents.mdx │ │ │ │ ├── grounding-models.mdx │ │ │ │ ├── human-in-the-loop.mdx │ │ │ │ └── meta.json │ │ │ ├── supported-model-providers │ │ │ │ ├── index.mdx │ │ │ │ └── local-models.mdx │ │ │ └── usage-tracking.mdx │ │ ├── computer-sdk │ │ │ ├── cloud-vm-management.mdx │ │ │ ├── commands.mdx │ │ │ ├── computer-ui.mdx │ │ │ ├── computers.mdx │ │ │ ├── meta.json │ │ │ └── sandboxed-python.mdx │ │ ├── index.mdx │ │ ├── libraries │ │ │ ├── agent │ │ │ │ └── index.mdx │ │ │ ├── computer │ │ │ │ └── index.mdx │ │ │ ├── computer-server │ │ │ │ ├── Commands.mdx │ │ │ │ ├── index.mdx │ │ │ │ ├── REST-API.mdx │ │ │ │ └── WebSocket-API.mdx │ │ │ ├── core │ │ │ │ └── index.mdx │ │ │ ├── lume │ │ │ │ ├── cli-reference.mdx │ │ │ │ ├── faq.md │ │ │ │ ├── http-api.mdx │ │ │ │ ├── index.mdx │ │ │ │ ├── installation.mdx │ │ │ │ ├── meta.json │ │ │ │ └── prebuilt-images.mdx │ │ │ ├── lumier │ │ │ │ ├── building-lumier.mdx │ │ │ │ ├── docker-compose.mdx │ │ │ │ ├── docker.mdx │ │ │ │ ├── index.mdx │ │ │ │ ├── installation.mdx │ │ │ │ └── meta.json │ │ │ ├── mcp-server │ │ │ │ ├── client-integrations.mdx │ │ │ │ ├── configuration.mdx │ │ │ │ ├── index.mdx │ │ │ │ ├── installation.mdx │ │ │ │ ├── llm-integrations.mdx │ │ │ │ ├── meta.json │ │ │ │ ├── tools.mdx │ │ │ │ └── usage.mdx │ │ │ └── som │ │ │ ├── configuration.mdx │ │ │ └── index.mdx │ │ ├── meta.json │ │ ├── quickstart-cli.mdx │ │ ├── quickstart-devs.mdx │ │ └── telemetry.mdx │ ├── next.config.mjs │ ├── package-lock.json │ ├── package.json │ ├── pnpm-lock.yaml │ ├── postcss.config.mjs │ ├── public │ │ └── img │ │ ├── agent_gradio_ui.png │ │ ├── agent.png │ │ ├── cli.png │ │ ├── computer.png │ │ ├── som_box_threshold.png │ │ └── som_iou_threshold.png │ ├── README.md │ ├── source.config.ts │ ├── src │ │ ├── app │ │ │ ├── (home) │ │ │ │ ├── [[...slug]] │ │ │ │ │ └── page.tsx │ │ │ │ └── layout.tsx │ │ │ ├── api │ │ │ │ └── search │ │ │ │ └── route.ts │ │ │ ├── favicon.ico │ │ │ ├── global.css │ │ │ ├── layout.config.tsx │ │ │ ├── layout.tsx │ │ │ ├── llms.mdx │ │ │ │ └── [[...slug]] │ │ │ │ └── route.ts │ │ │ └── llms.txt │ │ │ └── route.ts │ │ ├── assets │ │ │ ├── discord-black.svg │ │ │ ├── discord-white.svg │ │ │ ├── logo-black.svg │ │ │ └── logo-white.svg │ │ ├── components │ │ │ ├── iou.tsx │ │ │ └── mermaid.tsx │ │ ├── lib │ │ │ ├── llms.ts │ │ │ └── source.ts │ │ └── mdx-components.tsx │ └── tsconfig.json ├── examples │ ├── agent_examples.py │ ├── agent_ui_examples.py │ ├── cloud_api_examples.py │ ├── computer_examples_windows.py │ ├── computer_examples.py │ ├── computer_ui_examples.py │ ├── computer-example-ts │ │ ├── .env.example │ │ ├── .gitignore │ │ ├── .prettierrc │ │ ├── package-lock.json │ │ ├── package.json │ │ ├── pnpm-lock.yaml │ │ ├── README.md │ │ ├── src │ │ │ ├── helpers.ts │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── docker_examples.py │ ├── evals │ │ ├── hud_eval_examples.py │ │ └── wikipedia_most_linked.txt │ ├── pylume_examples.py │ ├── sandboxed_functions_examples.py │ ├── som_examples.py │ ├── utils.py │ └── winsandbox_example.py ├── img │ ├── agent_gradio_ui.png │ ├── agent.png │ ├── cli.png │ ├── computer.png │ ├── logo_black.png │ └── logo_white.png ├── libs │ ├── kasm │ │ ├── Dockerfile │ │ ├── LICENSE │ │ ├── README.md │ │ └── src │ │ └── ubuntu │ │ └── install │ │ └── firefox │ │ ├── custom_startup.sh │ │ ├── firefox.desktop │ │ └── install_firefox.sh │ ├── lume │ │ ├── .cursorignore │ │ ├── CONTRIBUTING.md │ │ ├── Development.md │ │ ├── img │ │ │ └── cli.png │ │ ├── Package.resolved │ │ ├── Package.swift │ │ ├── README.md │ │ ├── resources │ │ │ └── lume.entitlements │ │ ├── scripts │ │ │ ├── build │ │ │ │ ├── build-debug.sh │ │ │ │ ├── build-release-notarized.sh │ │ │ │ └── build-release.sh │ │ │ └── install.sh │ │ ├── src │ │ │ ├── Commands │ │ │ │ ├── Clone.swift │ │ │ │ ├── Config.swift │ │ │ │ ├── Create.swift │ │ │ │ ├── Delete.swift │ │ │ │ ├── Get.swift │ │ │ │ ├── Images.swift │ │ │ │ ├── IPSW.swift │ │ │ │ ├── List.swift │ │ │ │ ├── Logs.swift │ │ │ │ ├── Options │ │ │ │ │ └── FormatOption.swift │ │ │ │ ├── Prune.swift │ │ │ │ ├── Pull.swift │ │ │ │ ├── Push.swift │ │ │ │ ├── Run.swift │ │ │ │ ├── Serve.swift │ │ │ │ ├── Set.swift │ │ │ │ └── Stop.swift │ │ │ ├── ContainerRegistry │ │ │ │ ├── ImageContainerRegistry.swift │ │ │ │ ├── ImageList.swift │ │ │ │ └── ImagesPrinter.swift │ │ │ ├── Errors │ │ │ │ └── Errors.swift │ │ │ ├── FileSystem │ │ │ │ ├── Home.swift │ │ │ │ ├── Settings.swift │ │ │ │ ├── VMConfig.swift │ │ │ │ ├── VMDirectory.swift │ │ │ │ └── VMLocation.swift │ │ │ ├── LumeController.swift │ │ │ ├── Main.swift │ │ │ ├── Server │ │ │ │ ├── Handlers.swift │ │ │ │ ├── HTTP.swift │ │ │ │ ├── Requests.swift │ │ │ │ ├── Responses.swift │ │ │ │ └── Server.swift │ │ │ ├── Utils │ │ │ │ ├── CommandRegistry.swift │ │ │ │ ├── CommandUtils.swift │ │ │ │ ├── Logger.swift │ │ │ │ ├── NetworkUtils.swift │ │ │ │ ├── Path.swift │ │ │ │ ├── ProcessRunner.swift │ │ │ │ ├── ProgressLogger.swift │ │ │ │ ├── String.swift │ │ │ │ └── Utils.swift │ │ │ ├── Virtualization │ │ │ │ ├── DarwinImageLoader.swift │ │ │ │ ├── DHCPLeaseParser.swift │ │ │ │ ├── ImageLoaderFactory.swift │ │ │ │ └── VMVirtualizationService.swift │ │ │ ├── VM │ │ │ │ ├── DarwinVM.swift │ │ │ │ ├── LinuxVM.swift │ │ │ │ ├── VM.swift │ │ │ │ ├── VMDetails.swift │ │ │ │ ├── VMDetailsPrinter.swift │ │ │ │ ├── VMDisplayResolution.swift │ │ │ │ └── VMFactory.swift │ │ │ └── VNC │ │ │ ├── PassphraseGenerator.swift │ │ │ └── VNCService.swift │ │ └── tests │ │ ├── Mocks │ │ │ ├── MockVM.swift │ │ │ ├── MockVMVirtualizationService.swift │ │ │ └── MockVNCService.swift │ │ ├── VM │ │ │ └── VMDetailsPrinterTests.swift │ │ ├── VMTests.swift │ │ ├── VMVirtualizationServiceTests.swift │ │ └── VNCServiceTests.swift │ ├── lumier │ │ ├── .dockerignore │ │ ├── Dockerfile │ │ ├── README.md │ │ └── src │ │ ├── bin │ │ │ └── entry.sh │ │ ├── config │ │ │ └── constants.sh │ │ ├── hooks │ │ │ └── on-logon.sh │ │ └── lib │ │ ├── utils.sh │ │ └── vm.sh │ ├── python │ │ ├── agent │ │ │ ├── .bumpversion.cfg │ │ │ ├── agent │ │ │ │ ├── __init__.py │ │ │ │ ├── __main__.py │ │ │ │ ├── adapters │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── huggingfacelocal_adapter.py │ │ │ │ │ ├── human_adapter.py │ │ │ │ │ ├── mlxvlm_adapter.py │ │ │ │ │ └── models │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── generic.py │ │ │ │ │ ├── internvl.py │ │ │ │ │ ├── opencua.py │ │ │ │ │ └── qwen2_5_vl.py │ │ │ │ ├── agent.py │ │ │ │ ├── callbacks │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── base.py │ │ │ │ │ ├── budget_manager.py │ │ │ │ │ ├── image_retention.py │ │ │ │ │ ├── logging.py │ │ │ │ │ ├── operator_validator.py │ │ │ │ │ ├── pii_anonymization.py │ │ │ │ │ ├── prompt_instructions.py │ │ │ │ │ ├── telemetry.py │ │ │ │ │ └── trajectory_saver.py │ │ │ │ ├── cli.py │ │ │ │ ├── computers │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── base.py │ │ │ │ │ ├── cua.py │ │ │ │ │ └── custom.py │ │ │ │ ├── decorators.py │ │ │ │ ├── human_tool │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── __main__.py │ │ │ │ │ ├── server.py │ │ │ │ │ └── ui.py │ │ │ │ ├── integrations │ │ │ │ │ └── hud │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── agent.py │ │ │ │ │ └── proxy.py │ │ │ │ ├── loops │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── anthropic.py │ │ │ │ │ ├── base.py │ │ │ │ │ ├── composed_grounded.py │ │ │ │ │ ├── gemini.py │ │ │ │ │ ├── glm45v.py │ │ │ │ │ ├── gta1.py │ │ │ │ │ ├── holo.py │ │ │ │ │ ├── internvl.py │ │ │ │ │ ├── model_types.csv │ │ │ │ │ ├── moondream3.py │ │ │ │ │ ├── omniparser.py │ │ │ │ │ ├── openai.py │ │ │ │ │ ├── opencua.py │ │ │ │ │ └── uitars.py │ │ │ │ ├── proxy │ │ │ │ │ ├── examples.py │ │ │ │ │ └── handlers.py │ │ │ │ ├── responses.py │ │ │ │ ├── types.py │ │ │ │ └── ui │ │ │ │ ├── __init__.py │ │ │ │ ├── __main__.py │ │ │ │ └── gradio │ │ │ │ ├── __init__.py │ │ │ │ ├── app.py │ │ │ │ └── ui_components.py │ │ │ ├── benchmarks │ │ │ │ ├── .gitignore │ │ │ │ ├── contrib.md │ │ │ │ ├── interactive.py │ │ │ │ ├── models │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── base.py │ │ │ │ │ └── gta1.py │ │ │ │ ├── README.md │ │ │ │ ├── ss-pro.py │ │ │ │ ├── ss-v2.py │ │ │ │ └── utils.py │ │ │ ├── example.py │ │ │ ├── pyproject.toml │ │ │ └── README.md │ │ ├── computer │ │ │ ├── .bumpversion.cfg │ │ │ ├── computer │ │ │ │ ├── __init__.py │ │ │ │ ├── computer.py │ │ │ │ ├── diorama_computer.py │ │ │ │ ├── helpers.py │ │ │ │ ├── interface │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── base.py │ │ │ │ │ ├── factory.py │ │ │ │ │ ├── generic.py │ │ │ │ │ ├── linux.py │ │ │ │ │ ├── macos.py │ │ │ │ │ ├── models.py │ │ │ │ │ └── windows.py │ │ │ │ ├── logger.py │ │ │ │ ├── models.py │ │ │ │ ├── providers │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── base.py │ │ │ │ │ ├── cloud │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ └── provider.py │ │ │ │ │ ├── docker │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ └── provider.py │ │ │ │ │ ├── factory.py │ │ │ │ │ ├── lume │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ └── provider.py │ │ │ │ │ ├── lume_api.py │ │ │ │ │ ├── lumier │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ └── provider.py │ │ │ │ │ ├── types.py │ │ │ │ │ └── winsandbox │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── provider.py │ │ │ │ │ └── setup_script.ps1 │ │ │ │ ├── ui │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── __main__.py │ │ │ │ │ └── gradio │ │ │ │ │ ├── __init__.py │ │ │ │ │ └── app.py │ │ │ │ └── utils.py │ │ │ ├── poetry.toml │ │ │ ├── pyproject.toml │ │ │ └── README.md │ │ ├── computer-server │ │ │ ├── .bumpversion.cfg │ │ │ ├── computer_server │ │ │ │ ├── __init__.py │ │ │ │ ├── __main__.py │ │ │ │ ├── cli.py │ │ │ │ ├── diorama │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── base.py │ │ │ │ │ ├── diorama_computer.py │ │ │ │ │ ├── diorama.py │ │ │ │ │ ├── draw.py │ │ │ │ │ ├── macos.py │ │ │ │ │ └── safezone.py │ │ │ │ ├── handlers │ │ │ │ │ ├── base.py │ │ │ │ │ ├── factory.py │ │ │ │ │ ├── generic.py │ │ │ │ │ ├── linux.py │ │ │ │ │ ├── macos.py │ │ │ │ │ └── windows.py │ │ │ │ ├── main.py │ │ │ │ ├── server.py │ │ │ │ └── watchdog.py │ │ │ ├── examples │ │ │ │ ├── __init__.py │ │ │ │ └── usage_example.py │ │ │ ├── pyproject.toml │ │ │ ├── README.md │ │ │ ├── run_server.py │ │ │ └── test_connection.py │ │ ├── core │ │ │ ├── .bumpversion.cfg │ │ │ ├── core │ │ │ │ ├── __init__.py │ │ │ │ └── telemetry │ │ │ │ ├── __init__.py │ │ │ │ └── posthog.py │ │ │ ├── poetry.toml │ │ │ ├── pyproject.toml │ │ │ └── README.md │ │ ├── mcp-server │ │ │ ├── .bumpversion.cfg │ │ │ ├── CONCURRENT_SESSIONS.md │ │ │ ├── mcp_server │ │ │ │ ├── __init__.py │ │ │ │ ├── __main__.py │ │ │ │ ├── server.py │ │ │ │ └── session_manager.py │ │ │ ├── pdm.lock │ │ │ ├── pyproject.toml │ │ │ ├── README.md │ │ │ └── scripts │ │ │ ├── install_mcp_server.sh │ │ │ └── start_mcp_server.sh │ │ ├── pylume │ │ │ ├── __init__.py │ │ │ ├── .bumpversion.cfg │ │ │ ├── pylume │ │ │ │ ├── __init__.py │ │ │ │ ├── client.py │ │ │ │ ├── exceptions.py │ │ │ │ ├── lume │ │ │ │ ├── models.py │ │ │ │ ├── pylume.py │ │ │ │ └── server.py │ │ │ ├── pyproject.toml │ │ │ └── README.md │ │ └── som │ │ ├── .bumpversion.cfg │ │ ├── LICENSE │ │ ├── poetry.toml │ │ ├── pyproject.toml │ │ ├── README.md │ │ ├── som │ │ │ ├── __init__.py │ │ │ ├── detect.py │ │ │ ├── detection.py │ │ │ ├── models.py │ │ │ ├── ocr.py │ │ │ ├── util │ │ │ │ └── utils.py │ │ │ └── visualization.py │ │ └── tests │ │ └── test_omniparser.py │ ├── typescript │ │ ├── .gitignore │ │ ├── .nvmrc │ │ ├── agent │ │ │ ├── examples │ │ │ │ ├── playground-example.html │ │ │ │ └── README.md │ │ │ ├── package.json │ │ │ ├── README.md │ │ │ ├── src │ │ │ │ ├── client.ts │ │ │ │ ├── index.ts │ │ │ │ └── types.ts │ │ │ ├── tests │ │ │ │ └── client.test.ts │ │ │ ├── tsconfig.json │ │ │ ├── tsdown.config.ts │ │ │ └── vitest.config.ts │ │ ├── biome.json │ │ ├── computer │ │ │ ├── .editorconfig │ │ │ ├── .gitattributes │ │ │ ├── .gitignore │ │ │ ├── LICENSE │ │ │ ├── package.json │ │ │ ├── README.md │ │ │ ├── src │ │ │ │ ├── computer │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── providers │ │ │ │ │ │ ├── base.ts │ │ │ │ │ │ ├── cloud.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── index.ts │ │ │ │ ├── interface │ │ │ │ │ ├── base.ts │ │ │ │ │ ├── factory.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── linux.ts │ │ │ │ │ ├── macos.ts │ │ │ │ │ └── windows.ts │ │ │ │ └── types.ts │ │ │ ├── tests │ │ │ │ ├── computer │ │ │ │ │ └── cloud.test.ts │ │ │ │ ├── interface │ │ │ │ │ ├── factory.test.ts │ │ │ │ │ ├── index.test.ts │ │ │ │ │ ├── linux.test.ts │ │ │ │ │ ├── macos.test.ts │ │ │ │ │ └── windows.test.ts │ │ │ │ └── setup.ts │ │ │ ├── tsconfig.json │ │ │ ├── tsdown.config.ts │ │ │ └── vitest.config.ts │ │ ├── core │ │ │ ├── .editorconfig │ │ │ ├── .gitattributes │ │ │ ├── .gitignore │ │ │ ├── LICENSE │ │ │ ├── package.json │ │ │ ├── README.md │ │ │ ├── src │ │ │ │ ├── index.ts │ │ │ │ └── telemetry │ │ │ │ ├── clients │ │ │ │ │ ├── index.ts │ │ │ │ │ └── posthog.ts │ │ │ │ └── index.ts │ │ │ ├── tests │ │ │ │ └── telemetry.test.ts │ │ │ ├── tsconfig.json │ │ │ ├── tsdown.config.ts │ │ │ └── vitest.config.ts │ │ ├── package.json │ │ ├── pnpm-lock.yaml │ │ ├── pnpm-workspace.yaml │ │ └── README.md │ └── xfce │ ├── .dockerignore │ ├── .gitignore │ ├── Dockerfile │ ├── README.md │ └── src │ ├── scripts │ │ ├── resize-display.sh │ │ ├── start-computer-server.sh │ │ ├── start-novnc.sh │ │ ├── start-vnc.sh │ │ └── xstartup.sh │ ├── supervisor │ │ └── supervisord.conf │ └── xfce-config │ ├── helpers.rc │ ├── xfce4-power-manager.xml │ └── xfce4-session.xml ├── LICENSE.md ├── Makefile ├── notebooks │ ├── agent_nb.ipynb │ ├── blog │ │ ├── build-your-own-operator-on-macos-1.ipynb │ │ └── build-your-own-operator-on-macos-2.ipynb │ ├── composite_agents_docker_nb.ipynb │ ├── computer_nb.ipynb │ ├── computer_server_nb.ipynb │ ├── customizing_computeragent.ipynb │ ├── eval_osworld.ipynb │ ├── ollama_nb.ipynb │ ├── pylume_nb.ipynb │ ├── README.md │ ├── sota_hackathon_cloud.ipynb │ └── sota_hackathon.ipynb ├── pdm.lock ├── pyproject.toml ├── pyrightconfig.json ├── README.md ├── samples │ └── community │ ├── global-online │ │ └── README.md │ └── hack-the-north │ └── README.md ├── scripts │ ├── build-uv.sh │ ├── build.ps1 │ ├── build.sh │ ├── cleanup.sh │ ├── playground-docker.sh │ ├── playground.sh │ └── run-docker-dev.sh └── tests ├── pytest.ini ├── shell_cmd.py ├── test_files.py ├── test_mcp_server_session_management.py ├── test_mcp_server_streaming.py ├── test_shell_bash.py ├── test_telemetry.py ├── test_venv.py └── test_watchdog.py ``` # Files -------------------------------------------------------------------------------- /libs/python/computer/computer/interface/generic.py: -------------------------------------------------------------------------------- ```python 1 | import asyncio 2 | import json 3 | import time 4 | from typing import Any, Dict, List, Optional, Tuple 5 | from PIL import Image 6 | 7 | import websockets 8 | import aiohttp 9 | 10 | from ..logger import Logger, LogLevel 11 | from .base import BaseComputerInterface 12 | from ..utils import decode_base64_image, encode_base64_image, bytes_to_image, draw_box, resize_image 13 | from .models import Key, KeyType, MouseButton, CommandResult 14 | 15 | 16 | class GenericComputerInterface(BaseComputerInterface): 17 | """Generic interface with common functionality for all supported platforms (Windows, Linux, macOS).""" 18 | 19 | def __init__(self, ip_address: str, username: str = "lume", password: str = "lume", api_key: Optional[str] = None, vm_name: Optional[str] = None, logger_name: str = "computer.interface.generic"): 20 | super().__init__(ip_address, username, password, api_key, vm_name) 21 | self._ws = None 22 | self._reconnect_task = None 23 | self._closed = False 24 | self._last_ping = 0 25 | self._ping_interval = 5 # Send ping every 5 seconds 26 | self._ping_timeout = 120 # Wait 120 seconds for pong response 27 | self._reconnect_delay = 1 # Start with 1 second delay 28 | self._max_reconnect_delay = 30 # Maximum delay between reconnection attempts 29 | self._log_connection_attempts = True # Flag to control connection attempt logging 30 | self._authenticated = False # Track authentication status 31 | self._recv_lock = asyncio.Lock() # Lock to ensure only one recv at a time 32 | 33 | # Set logger name for the interface 34 | self.logger = Logger(logger_name, LogLevel.NORMAL) 35 | 36 | # Optional default delay time between commands (in seconds) 37 | self.delay = 0.0 38 | 39 | async def _handle_delay(self, delay: Optional[float] = None): 40 | """Handle delay between commands using async sleep. 41 | 42 | Args: 43 | delay: Optional delay in seconds. If None, uses self.delay. 44 | """ 45 | if delay is not None: 46 | if isinstance(delay, float) or isinstance(delay, int) and delay > 0: 47 | await asyncio.sleep(delay) 48 | elif isinstance(self.delay, float) or isinstance(self.delay, int) and self.delay > 0: 49 | await asyncio.sleep(self.delay) 50 | 51 | @property 52 | def ws_uri(self) -> str: 53 | """Get the WebSocket URI using the current IP address. 54 | 55 | Returns: 56 | WebSocket URI for the Computer API Server 57 | """ 58 | protocol = "wss" if self.api_key else "ws" 59 | port = "8443" if self.api_key else "8000" 60 | return f"{protocol}://{self.ip_address}:{port}/ws" 61 | 62 | @property 63 | def rest_uri(self) -> str: 64 | """Get the REST URI using the current IP address. 65 | 66 | Returns: 67 | REST URI for the Computer API Server 68 | """ 69 | protocol = "https" if self.api_key else "http" 70 | port = "8443" if self.api_key else "8000" 71 | return f"{protocol}://{self.ip_address}:{port}/cmd" 72 | 73 | # Mouse actions 74 | async def mouse_down(self, x: Optional[int] = None, y: Optional[int] = None, button: str = "left", delay: Optional[float] = None) -> None: 75 | await self._send_command("mouse_down", {"x": x, "y": y, "button": button}) 76 | await self._handle_delay(delay) 77 | 78 | async def mouse_up(self, x: Optional[int] = None, y: Optional[int] = None, button: str = "left", delay: Optional[float] = None) -> None: 79 | await self._send_command("mouse_up", {"x": x, "y": y, "button": button}) 80 | await self._handle_delay(delay) 81 | 82 | async def left_click(self, x: Optional[int] = None, y: Optional[int] = None, delay: Optional[float] = None) -> None: 83 | await self._send_command("left_click", {"x": x, "y": y}) 84 | await self._handle_delay(delay) 85 | 86 | async def right_click(self, x: Optional[int] = None, y: Optional[int] = None, delay: Optional[float] = None) -> None: 87 | await self._send_command("right_click", {"x": x, "y": y}) 88 | await self._handle_delay(delay) 89 | 90 | async def double_click(self, x: Optional[int] = None, y: Optional[int] = None, delay: Optional[float] = None) -> None: 91 | await self._send_command("double_click", {"x": x, "y": y}) 92 | await self._handle_delay(delay) 93 | 94 | async def move_cursor(self, x: int, y: int, delay: Optional[float] = None) -> None: 95 | await self._send_command("move_cursor", {"x": x, "y": y}) 96 | await self._handle_delay(delay) 97 | 98 | async def drag_to(self, x: int, y: int, button: "MouseButton" = "left", duration: float = 0.5, delay: Optional[float] = None) -> None: 99 | await self._send_command( 100 | "drag_to", {"x": x, "y": y, "button": button, "duration": duration} 101 | ) 102 | await self._handle_delay(delay) 103 | 104 | async def drag(self, path: List[Tuple[int, int]], button: "MouseButton" = "left", duration: float = 0.5, delay: Optional[float] = None) -> None: 105 | await self._send_command( 106 | "drag", {"path": path, "button": button, "duration": duration} 107 | ) 108 | await self._handle_delay(delay) 109 | 110 | # Keyboard Actions 111 | async def key_down(self, key: "KeyType", delay: Optional[float] = None) -> None: 112 | await self._send_command("key_down", {"key": key}) 113 | await self._handle_delay(delay) 114 | 115 | async def key_up(self, key: "KeyType", delay: Optional[float] = None) -> None: 116 | await self._send_command("key_up", {"key": key}) 117 | await self._handle_delay(delay) 118 | 119 | async def type_text(self, text: str, delay: Optional[float] = None) -> None: 120 | await self._send_command("type_text", {"text": text}) 121 | await self._handle_delay(delay) 122 | 123 | async def press(self, key: "KeyType", delay: Optional[float] = None) -> None: 124 | """Press a single key. 125 | 126 | Args: 127 | key: The key to press. Can be any of: 128 | - A Key enum value (recommended), e.g. Key.PAGE_DOWN 129 | - A direct key value string, e.g. 'pagedown' 130 | - A single character string, e.g. 'a' 131 | 132 | Examples: 133 | ```python 134 | # Using enum (recommended) 135 | await interface.press(Key.PAGE_DOWN) 136 | await interface.press(Key.ENTER) 137 | 138 | # Using direct values 139 | await interface.press('pagedown') 140 | await interface.press('enter') 141 | 142 | # Using single characters 143 | await interface.press('a') 144 | ``` 145 | 146 | Raises: 147 | ValueError: If the key type is invalid or the key is not recognized 148 | """ 149 | if isinstance(key, Key): 150 | actual_key = key.value 151 | elif isinstance(key, str): 152 | # Try to convert to enum if it matches a known key 153 | key_or_enum = Key.from_string(key) 154 | actual_key = key_or_enum.value if isinstance(key_or_enum, Key) else key_or_enum 155 | else: 156 | raise ValueError(f"Invalid key type: {type(key)}. Must be Key enum or string.") 157 | 158 | await self._send_command("press_key", {"key": actual_key}) 159 | await self._handle_delay(delay) 160 | 161 | async def press_key(self, key: "KeyType", delay: Optional[float] = None) -> None: 162 | """DEPRECATED: Use press() instead. 163 | 164 | This method is kept for backward compatibility but will be removed in a future version. 165 | Please use the press() method instead. 166 | """ 167 | await self.press(key, delay) 168 | 169 | async def hotkey(self, *keys: "KeyType", delay: Optional[float] = None) -> None: 170 | """Press multiple keys simultaneously. 171 | 172 | Args: 173 | *keys: Multiple keys to press simultaneously. Each key can be any of: 174 | - A Key enum value (recommended), e.g. Key.COMMAND 175 | - A direct key value string, e.g. 'command' 176 | - A single character string, e.g. 'a' 177 | 178 | Examples: 179 | ```python 180 | # Using enums (recommended) 181 | await interface.hotkey(Key.COMMAND, Key.C) # Copy 182 | await interface.hotkey(Key.COMMAND, Key.V) # Paste 183 | 184 | # Using mixed formats 185 | await interface.hotkey(Key.COMMAND, 'a') # Select all 186 | ``` 187 | 188 | Raises: 189 | ValueError: If any key type is invalid or not recognized 190 | """ 191 | actual_keys = [] 192 | for key in keys: 193 | if isinstance(key, Key): 194 | actual_keys.append(key.value) 195 | elif isinstance(key, str): 196 | # Try to convert to enum if it matches a known key 197 | key_or_enum = Key.from_string(key) 198 | actual_keys.append(key_or_enum.value if isinstance(key_or_enum, Key) else key_or_enum) 199 | else: 200 | raise ValueError(f"Invalid key type: {type(key)}. Must be Key enum or string.") 201 | 202 | await self._send_command("hotkey", {"keys": actual_keys}) 203 | await self._handle_delay(delay) 204 | 205 | # Scrolling Actions 206 | async def scroll(self, x: int, y: int, delay: Optional[float] = None) -> None: 207 | await self._send_command("scroll", {"x": x, "y": y}) 208 | await self._handle_delay(delay) 209 | 210 | async def scroll_down(self, clicks: int = 1, delay: Optional[float] = None) -> None: 211 | await self._send_command("scroll_down", {"clicks": clicks}) 212 | await self._handle_delay(delay) 213 | 214 | async def scroll_up(self, clicks: int = 1, delay: Optional[float] = None) -> None: 215 | await self._send_command("scroll_up", {"clicks": clicks}) 216 | await self._handle_delay(delay) 217 | 218 | # Screen actions 219 | async def screenshot( 220 | self, 221 | boxes: Optional[List[Tuple[int, int, int, int]]] = None, 222 | box_color: str = "#FF0000", 223 | box_thickness: int = 2, 224 | scale_factor: float = 1.0, 225 | ) -> bytes: 226 | """Take a screenshot with optional box drawing and scaling. 227 | 228 | Args: 229 | boxes: Optional list of (x, y, width, height) tuples defining boxes to draw in screen coordinates 230 | box_color: Color of the boxes in hex format (default: "#FF0000" red) 231 | box_thickness: Thickness of the box borders in pixels (default: 2) 232 | scale_factor: Factor to scale the final image by (default: 1.0) 233 | Use > 1.0 to enlarge, < 1.0 to shrink (e.g., 0.5 for half size, 2.0 for double) 234 | 235 | Returns: 236 | bytes: The screenshot image data, optionally with boxes drawn on it and scaled 237 | """ 238 | result = await self._send_command("screenshot") 239 | if not result.get("image_data"): 240 | raise RuntimeError("Failed to take screenshot, no image data received from server") 241 | 242 | screenshot = decode_base64_image(result["image_data"]) 243 | 244 | if boxes: 245 | # Get the natural scaling between screen and screenshot 246 | screen_size = await self.get_screen_size() 247 | screenshot_width, screenshot_height = bytes_to_image(screenshot).size 248 | width_scale = screenshot_width / screen_size["width"] 249 | height_scale = screenshot_height / screen_size["height"] 250 | 251 | # Scale box coordinates from screen space to screenshot space 252 | for box in boxes: 253 | scaled_box = ( 254 | int(box[0] * width_scale), # x 255 | int(box[1] * height_scale), # y 256 | int(box[2] * width_scale), # width 257 | int(box[3] * height_scale), # height 258 | ) 259 | screenshot = draw_box( 260 | screenshot, 261 | x=scaled_box[0], 262 | y=scaled_box[1], 263 | width=scaled_box[2], 264 | height=scaled_box[3], 265 | color=box_color, 266 | thickness=box_thickness, 267 | ) 268 | 269 | if scale_factor != 1.0: 270 | screenshot = resize_image(screenshot, scale_factor) 271 | 272 | return screenshot 273 | 274 | async def get_screen_size(self) -> Dict[str, int]: 275 | result = await self._send_command("get_screen_size") 276 | if result["success"] and result["size"]: 277 | return result["size"] 278 | raise RuntimeError("Failed to get screen size") 279 | 280 | async def get_cursor_position(self) -> Dict[str, int]: 281 | result = await self._send_command("get_cursor_position") 282 | if result["success"] and result["position"]: 283 | return result["position"] 284 | raise RuntimeError("Failed to get cursor position") 285 | 286 | # Clipboard Actions 287 | async def copy_to_clipboard(self) -> str: 288 | result = await self._send_command("copy_to_clipboard") 289 | if result["success"] and result["content"]: 290 | return result["content"] 291 | raise RuntimeError("Failed to get clipboard content") 292 | 293 | async def set_clipboard(self, text: str) -> None: 294 | await self._send_command("set_clipboard", {"text": text}) 295 | 296 | # File Operations 297 | async def _write_bytes_chunked(self, path: str, content: bytes, append: bool = False, chunk_size: int = 1024 * 1024) -> None: 298 | """Write large files in chunks to avoid memory issues.""" 299 | total_size = len(content) 300 | current_offset = 0 301 | 302 | while current_offset < total_size: 303 | chunk_end = min(current_offset + chunk_size, total_size) 304 | chunk_data = content[current_offset:chunk_end] 305 | 306 | # First chunk uses the original append flag, subsequent chunks always append 307 | chunk_append = append if current_offset == 0 else True 308 | 309 | result = await self._send_command("write_bytes", { 310 | "path": path, 311 | "content_b64": encode_base64_image(chunk_data), 312 | "append": chunk_append 313 | }) 314 | 315 | if not result.get("success", False): 316 | raise RuntimeError(result.get("error", "Failed to write file chunk")) 317 | 318 | current_offset = chunk_end 319 | 320 | async def write_bytes(self, path: str, content: bytes, append: bool = False) -> None: 321 | # For large files, use chunked writing 322 | if len(content) > 5 * 1024 * 1024: # 5MB threshold 323 | await self._write_bytes_chunked(path, content, append) 324 | return 325 | 326 | result = await self._send_command("write_bytes", {"path": path, "content_b64": encode_base64_image(content), "append": append}) 327 | if not result.get("success", False): 328 | raise RuntimeError(result.get("error", "Failed to write file")) 329 | 330 | async def _read_bytes_chunked(self, path: str, offset: int, total_length: int, chunk_size: int = 1024 * 1024) -> bytes: 331 | """Read large files in chunks to avoid memory issues.""" 332 | chunks = [] 333 | current_offset = offset 334 | remaining = total_length 335 | 336 | while remaining > 0: 337 | read_size = min(chunk_size, remaining) 338 | result = await self._send_command("read_bytes", { 339 | "path": path, 340 | "offset": current_offset, 341 | "length": read_size 342 | }) 343 | 344 | if not result.get("success", False): 345 | raise RuntimeError(result.get("error", "Failed to read file chunk")) 346 | 347 | content_b64 = result.get("content_b64", "") 348 | chunk_data = decode_base64_image(content_b64) 349 | chunks.append(chunk_data) 350 | 351 | current_offset += read_size 352 | remaining -= read_size 353 | 354 | return b''.join(chunks) 355 | 356 | async def read_bytes(self, path: str, offset: int = 0, length: Optional[int] = None) -> bytes: 357 | # For large files, use chunked reading 358 | if length is None: 359 | # Get file size first to determine if we need chunking 360 | file_size = await self.get_file_size(path) 361 | # If file is larger than 5MB, read in chunks 362 | if file_size > 5 * 1024 * 1024: # 5MB threshold 363 | return await self._read_bytes_chunked(path, offset, file_size - offset if offset > 0 else file_size) 364 | 365 | result = await self._send_command("read_bytes", { 366 | "path": path, 367 | "offset": offset, 368 | "length": length 369 | }) 370 | if not result.get("success", False): 371 | raise RuntimeError(result.get("error", "Failed to read file")) 372 | content_b64 = result.get("content_b64", "") 373 | return decode_base64_image(content_b64) 374 | 375 | async def read_text(self, path: str, encoding: str = 'utf-8') -> str: 376 | """Read text from a file with specified encoding. 377 | 378 | Args: 379 | path: Path to the file to read 380 | encoding: Text encoding to use (default: 'utf-8') 381 | 382 | Returns: 383 | str: The decoded text content of the file 384 | """ 385 | content_bytes = await self.read_bytes(path) 386 | return content_bytes.decode(encoding) 387 | 388 | async def write_text(self, path: str, content: str, encoding: str = 'utf-8', append: bool = False) -> None: 389 | """Write text to a file with specified encoding. 390 | 391 | Args: 392 | path: Path to the file to write 393 | content: Text content to write 394 | encoding: Text encoding to use (default: 'utf-8') 395 | append: Whether to append to the file instead of overwriting 396 | """ 397 | content_bytes = content.encode(encoding) 398 | await self.write_bytes(path, content_bytes, append) 399 | 400 | async def get_file_size(self, path: str) -> int: 401 | result = await self._send_command("get_file_size", {"path": path}) 402 | if not result.get("success", False): 403 | raise RuntimeError(result.get("error", "Failed to get file size")) 404 | return result.get("size", 0) 405 | 406 | async def file_exists(self, path: str) -> bool: 407 | result = await self._send_command("file_exists", {"path": path}) 408 | return result.get("exists", False) 409 | 410 | async def directory_exists(self, path: str) -> bool: 411 | result = await self._send_command("directory_exists", {"path": path}) 412 | return result.get("exists", False) 413 | 414 | async def create_dir(self, path: str) -> None: 415 | result = await self._send_command("create_dir", {"path": path}) 416 | if not result.get("success", False): 417 | raise RuntimeError(result.get("error", "Failed to create directory")) 418 | 419 | async def delete_file(self, path: str) -> None: 420 | result = await self._send_command("delete_file", {"path": path}) 421 | if not result.get("success", False): 422 | raise RuntimeError(result.get("error", "Failed to delete file")) 423 | 424 | async def delete_dir(self, path: str) -> None: 425 | result = await self._send_command("delete_dir", {"path": path}) 426 | if not result.get("success", False): 427 | raise RuntimeError(result.get("error", "Failed to delete directory")) 428 | 429 | async def list_dir(self, path: str) -> list[str]: 430 | result = await self._send_command("list_dir", {"path": path}) 431 | if not result.get("success", False): 432 | raise RuntimeError(result.get("error", "Failed to list directory")) 433 | return result.get("files", []) 434 | 435 | # Command execution 436 | async def run_command(self, command: str) -> CommandResult: 437 | result = await self._send_command("run_command", {"command": command}) 438 | if not result.get("success", False): 439 | raise RuntimeError(result.get("error", "Failed to run command")) 440 | return CommandResult( 441 | stdout=result.get("stdout", ""), 442 | stderr=result.get("stderr", ""), 443 | returncode=result.get("return_code", 0) 444 | ) 445 | 446 | # Accessibility Actions 447 | async def get_accessibility_tree(self) -> Dict[str, Any]: 448 | """Get the accessibility tree of the current screen.""" 449 | result = await self._send_command("get_accessibility_tree") 450 | if not result.get("success", False): 451 | raise RuntimeError(result.get("error", "Failed to get accessibility tree")) 452 | return result 453 | 454 | async def get_active_window_bounds(self) -> Dict[str, int]: 455 | """Get the bounds of the currently active window.""" 456 | result = await self._send_command("get_active_window_bounds") 457 | if result["success"] and result["bounds"]: 458 | return result["bounds"] 459 | raise RuntimeError("Failed to get active window bounds") 460 | 461 | async def to_screen_coordinates(self, x: float, y: float) -> tuple[float, float]: 462 | """Convert screenshot coordinates to screen coordinates. 463 | 464 | Args: 465 | x: X coordinate in screenshot space 466 | y: Y coordinate in screenshot space 467 | 468 | Returns: 469 | tuple[float, float]: (x, y) coordinates in screen space 470 | """ 471 | screen_size = await self.get_screen_size() 472 | screenshot = await self.screenshot() 473 | screenshot_img = bytes_to_image(screenshot) 474 | screenshot_width, screenshot_height = screenshot_img.size 475 | 476 | # Calculate scaling factors 477 | width_scale = screen_size["width"] / screenshot_width 478 | height_scale = screen_size["height"] / screenshot_height 479 | 480 | # Convert coordinates 481 | screen_x = x * width_scale 482 | screen_y = y * height_scale 483 | 484 | return screen_x, screen_y 485 | 486 | async def to_screenshot_coordinates(self, x: float, y: float) -> tuple[float, float]: 487 | """Convert screen coordinates to screenshot coordinates. 488 | 489 | Args: 490 | x: X coordinate in screen space 491 | y: Y coordinate in screen space 492 | 493 | Returns: 494 | tuple[float, float]: (x, y) coordinates in screenshot space 495 | """ 496 | screen_size = await self.get_screen_size() 497 | screenshot = await self.screenshot() 498 | screenshot_img = bytes_to_image(screenshot) 499 | screenshot_width, screenshot_height = screenshot_img.size 500 | 501 | # Calculate scaling factors 502 | width_scale = screenshot_width / screen_size["width"] 503 | height_scale = screenshot_height / screen_size["height"] 504 | 505 | # Convert coordinates 506 | screenshot_x = x * width_scale 507 | screenshot_y = y * height_scale 508 | 509 | return screenshot_x, screenshot_y 510 | 511 | # Websocket Methods 512 | async def _keep_alive(self): 513 | """Keep the WebSocket connection alive with automatic reconnection.""" 514 | retry_count = 0 515 | max_log_attempts = 1 # Only log the first attempt at INFO level 516 | log_interval = 500 # Then log every 500th attempt (significantly increased from 30) 517 | last_warning_time = 0 518 | min_warning_interval = 30 # Minimum seconds between connection lost warnings 519 | min_retry_delay = 0.5 # Minimum delay between connection attempts (500ms) 520 | 521 | while not self._closed: 522 | try: 523 | if self._ws is None or ( 524 | self._ws and self._ws.state == websockets.protocol.State.CLOSED 525 | ): 526 | try: 527 | retry_count += 1 528 | 529 | # Add a minimum delay between connection attempts to avoid flooding 530 | if retry_count > 1: 531 | await asyncio.sleep(min_retry_delay) 532 | 533 | # Only log the first attempt at INFO level, then every Nth attempt 534 | if retry_count == 1: 535 | self.logger.info(f"Attempting WebSocket connection to {self.ws_uri}") 536 | elif retry_count % log_interval == 0: 537 | self.logger.info( 538 | f"Still attempting WebSocket connection (attempt {retry_count})..." 539 | ) 540 | else: 541 | # All other attempts are logged at DEBUG level 542 | self.logger.debug( 543 | f"Attempting WebSocket connection to {self.ws_uri} (attempt {retry_count})" 544 | ) 545 | 546 | self._ws = await asyncio.wait_for( 547 | websockets.connect( 548 | self.ws_uri, 549 | max_size=1024 * 1024 * 10, # 10MB limit 550 | max_queue=32, 551 | ping_interval=self._ping_interval, 552 | ping_timeout=self._ping_timeout, 553 | close_timeout=5, 554 | compression=None, # Disable compression to reduce overhead 555 | ), 556 | timeout=120, 557 | ) 558 | self.logger.info("WebSocket connection established") 559 | 560 | # If api_key and vm_name are provided, perform authentication handshake 561 | if self.api_key and self.vm_name: 562 | self.logger.info("Performing authentication handshake...") 563 | auth_message = { 564 | "command": "authenticate", 565 | "params": { 566 | "api_key": self.api_key, 567 | "container_name": self.vm_name 568 | } 569 | } 570 | await self._ws.send(json.dumps(auth_message)) 571 | 572 | # Wait for authentication response 573 | async with self._recv_lock: 574 | auth_response = await asyncio.wait_for(self._ws.recv(), timeout=10) 575 | auth_result = json.loads(auth_response) 576 | 577 | if not auth_result.get("success"): 578 | error_msg = auth_result.get("error", "Authentication failed") 579 | self.logger.error(f"Authentication failed: {error_msg}") 580 | await self._ws.close() 581 | self._ws = None 582 | raise ConnectionError(f"Authentication failed: {error_msg}") 583 | 584 | self.logger.info("Authentication successful") 585 | 586 | self._reconnect_delay = 1 # Reset reconnect delay on successful connection 587 | self._last_ping = time.time() 588 | retry_count = 0 # Reset retry count on successful connection 589 | except (asyncio.TimeoutError, websockets.exceptions.WebSocketException) as e: 590 | next_retry = self._reconnect_delay 591 | 592 | # Only log the first error at WARNING level, then every Nth attempt 593 | if retry_count == 1: 594 | self.logger.warning( 595 | f"Computer API Server not ready yet. Will retry automatically." 596 | ) 597 | elif retry_count % log_interval == 0: 598 | self.logger.warning( 599 | f"Still waiting for Computer API Server (attempt {retry_count})..." 600 | ) 601 | else: 602 | # All other errors are logged at DEBUG level 603 | self.logger.debug(f"Connection attempt {retry_count} failed: {e}") 604 | 605 | if self._ws: 606 | try: 607 | await self._ws.close() 608 | except: 609 | pass 610 | self._ws = None 611 | 612 | # Use exponential backoff for connection retries 613 | await asyncio.sleep(self._reconnect_delay) 614 | self._reconnect_delay = min( 615 | self._reconnect_delay * 2, self._max_reconnect_delay 616 | ) 617 | continue 618 | 619 | # Regular ping to check connection 620 | if self._ws and self._ws.state == websockets.protocol.State.OPEN: 621 | try: 622 | if time.time() - self._last_ping >= self._ping_interval: 623 | pong_waiter = await self._ws.ping() 624 | await asyncio.wait_for(pong_waiter, timeout=self._ping_timeout) 625 | self._last_ping = time.time() 626 | except Exception as e: 627 | self.logger.debug(f"Ping failed: {e}") 628 | if self._ws: 629 | try: 630 | await self._ws.close() 631 | except: 632 | pass 633 | self._ws = None 634 | continue 635 | 636 | await asyncio.sleep(1) 637 | 638 | except Exception as e: 639 | current_time = time.time() 640 | # Only log connection lost warnings at most once every min_warning_interval seconds 641 | if current_time - last_warning_time >= min_warning_interval: 642 | self.logger.warning( 643 | f"Computer API Server connection lost. Will retry automatically." 644 | ) 645 | last_warning_time = current_time 646 | else: 647 | # Log at debug level instead 648 | self.logger.debug(f"Connection lost: {e}") 649 | 650 | if self._ws: 651 | try: 652 | await self._ws.close() 653 | except: 654 | pass 655 | self._ws = None 656 | 657 | async def _ensure_connection(self): 658 | """Ensure WebSocket connection is established.""" 659 | if self._reconnect_task is None or self._reconnect_task.done(): 660 | self._reconnect_task = asyncio.create_task(self._keep_alive()) 661 | 662 | retry_count = 0 663 | max_retries = 5 664 | 665 | while retry_count < max_retries: 666 | try: 667 | if self._ws and self._ws.state == websockets.protocol.State.OPEN: 668 | return 669 | retry_count += 1 670 | await asyncio.sleep(1) 671 | except Exception as e: 672 | # Only log at ERROR level for the last retry attempt 673 | if retry_count == max_retries - 1: 674 | self.logger.error( 675 | f"Persistent connection check error after {retry_count} attempts: {e}" 676 | ) 677 | else: 678 | self.logger.debug(f"Connection check error (attempt {retry_count}): {e}") 679 | retry_count += 1 680 | await asyncio.sleep(1) 681 | continue 682 | 683 | raise ConnectionError("Failed to establish WebSocket connection after multiple retries") 684 | 685 | async def _send_command_ws(self, command: str, params: Optional[Dict] = None) -> Dict[str, Any]: 686 | """Send command through WebSocket.""" 687 | max_retries = 3 688 | retry_count = 0 689 | last_error = None 690 | 691 | # Acquire lock to ensure only one command is processed at a time 692 | self.logger.debug(f"Acquired lock for command: {command}") 693 | while retry_count < max_retries: 694 | try: 695 | await self._ensure_connection() 696 | if not self._ws: 697 | raise ConnectionError("WebSocket connection is not established") 698 | 699 | message = {"command": command, "params": params or {}} 700 | await self._ws.send(json.dumps(message)) 701 | async with self._recv_lock: 702 | response = await asyncio.wait_for(self._ws.recv(), timeout=120) 703 | self.logger.debug(f"Completed command: {command}") 704 | return json.loads(response) 705 | except Exception as e: 706 | last_error = e 707 | retry_count += 1 708 | if retry_count < max_retries: 709 | # Only log at debug level for intermediate retries 710 | self.logger.debug( 711 | f"Command '{command}' failed (attempt {retry_count}/{max_retries}): {e}" 712 | ) 713 | await asyncio.sleep(1) 714 | continue 715 | else: 716 | # Only log at error level for the final failure 717 | self.logger.error( 718 | f"Failed to send command '{command}' after {max_retries} retries" 719 | ) 720 | self.logger.debug(f"Command failure details: {e}") 721 | raise 722 | 723 | raise last_error if last_error else RuntimeError("Failed to send command") 724 | 725 | async def _send_command_rest(self, command: str, params: Optional[Dict] = None) -> Dict[str, Any]: 726 | """Send command through REST API without retries or connection management.""" 727 | try: 728 | # Prepare the request payload 729 | payload = {"command": command, "params": params or {}} 730 | 731 | # Prepare headers 732 | headers = {"Content-Type": "application/json"} 733 | if self.api_key: 734 | headers["X-API-Key"] = self.api_key 735 | if self.vm_name: 736 | headers["X-Container-Name"] = self.vm_name 737 | 738 | # Send the request 739 | async with aiohttp.ClientSession() as session: 740 | async with session.post( 741 | self.rest_uri, 742 | json=payload, 743 | headers=headers 744 | ) as response: 745 | # Get the response text 746 | response_text = await response.text() 747 | 748 | # Trim whitespace 749 | response_text = response_text.strip() 750 | 751 | # Check if it starts with "data: " 752 | if response_text.startswith("data: "): 753 | # Extract everything after "data: " 754 | json_str = response_text[6:] # Remove "data: " prefix 755 | try: 756 | return json.loads(json_str) 757 | except json.JSONDecodeError: 758 | return { 759 | "success": False, 760 | "error": "Server returned malformed response", 761 | "message": response_text 762 | } 763 | else: 764 | # Return error response 765 | return { 766 | "success": False, 767 | "error": "Server returned malformed response", 768 | "message": response_text 769 | } 770 | 771 | except Exception as e: 772 | return { 773 | "success": False, 774 | "error": "Request failed", 775 | "message": str(e) 776 | } 777 | 778 | async def _send_command(self, command: str, params: Optional[Dict] = None) -> Dict[str, Any]: 779 | """Send command using REST API with WebSocket fallback.""" 780 | # Try REST API first 781 | result = await self._send_command_rest(command, params) 782 | 783 | # If REST failed with "Request failed", try WebSocket as fallback 784 | if not result.get("success", True) and (result.get("error") == "Request failed" or result.get("error") == "Server returned malformed response"): 785 | self.logger.warning(f"REST API failed for command '{command}', trying WebSocket fallback") 786 | try: 787 | return await self._send_command_ws(command, params) 788 | except Exception as e: 789 | self.logger.error(f"WebSocket fallback also failed: {e}") 790 | # Return the original REST error 791 | return result 792 | 793 | return result 794 | 795 | async def wait_for_ready(self, timeout: int = 60, interval: float = 1.0): 796 | """Wait for Computer API Server to be ready by testing version command.""" 797 | 798 | # Check if REST API is available 799 | try: 800 | result = await self._send_command_rest("version", {}) 801 | assert result.get("success", True) 802 | except Exception as e: 803 | self.logger.debug(f"REST API failed for command 'version', trying WebSocket fallback: {e}") 804 | try: 805 | await self._wait_for_ready_ws(timeout, interval) 806 | return 807 | except Exception as e: 808 | self.logger.debug(f"WebSocket fallback also failed: {e}") 809 | raise e 810 | 811 | start_time = time.time() 812 | last_error = None 813 | attempt_count = 0 814 | progress_interval = 10 # Log progress every 10 seconds 815 | last_progress_time = start_time 816 | 817 | try: 818 | self.logger.info( 819 | f"Waiting for Computer API Server to be ready (timeout: {timeout}s)..." 820 | ) 821 | 822 | # Wait for the server to respond to get_screen_size command 823 | while time.time() - start_time < timeout: 824 | try: 825 | attempt_count += 1 826 | current_time = time.time() 827 | 828 | # Log progress periodically without flooding logs 829 | if current_time - last_progress_time >= progress_interval: 830 | elapsed = current_time - start_time 831 | self.logger.info( 832 | f"Still waiting for Computer API Server... (elapsed: {elapsed:.1f}s, attempts: {attempt_count})" 833 | ) 834 | last_progress_time = current_time 835 | 836 | # Test the server with a simple get_screen_size command 837 | result = await self._send_command("get_screen_size") 838 | if result.get("success", False): 839 | elapsed = time.time() - start_time 840 | self.logger.info( 841 | f"Computer API Server is ready (after {elapsed:.1f}s, {attempt_count} attempts)" 842 | ) 843 | return # Server is ready 844 | else: 845 | last_error = result.get("error", "Unknown error") 846 | self.logger.debug(f"Initial connection command failed: {last_error}") 847 | 848 | except Exception as e: 849 | last_error = e 850 | self.logger.debug(f"Connection attempt {attempt_count} failed: {e}") 851 | 852 | # Wait before trying again 853 | await asyncio.sleep(interval) 854 | 855 | # If we get here, we've timed out 856 | error_msg = f"Could not connect to {self.ip_address} after {timeout} seconds" 857 | if last_error: 858 | error_msg += f": {str(last_error)}" 859 | self.logger.error(error_msg) 860 | raise TimeoutError(error_msg) 861 | 862 | except Exception as e: 863 | if isinstance(e, TimeoutError): 864 | raise 865 | error_msg = f"Error while waiting for server: {str(e)}" 866 | self.logger.error(error_msg) 867 | raise RuntimeError(error_msg) 868 | 869 | async def _wait_for_ready_ws(self, timeout: int = 60, interval: float = 1.0): 870 | """Wait for WebSocket connection to become available.""" 871 | start_time = time.time() 872 | last_error = None 873 | attempt_count = 0 874 | progress_interval = 10 # Log progress every 10 seconds 875 | last_progress_time = start_time 876 | 877 | # Disable detailed logging for connection attempts 878 | self._log_connection_attempts = False 879 | 880 | try: 881 | self.logger.info( 882 | f"Waiting for Computer API Server to be ready (timeout: {timeout}s)..." 883 | ) 884 | 885 | # Start the keep-alive task if it's not already running 886 | if self._reconnect_task is None or self._reconnect_task.done(): 887 | self._reconnect_task = asyncio.create_task(self._keep_alive()) 888 | 889 | # Wait for the connection to be established 890 | while time.time() - start_time < timeout: 891 | try: 892 | attempt_count += 1 893 | current_time = time.time() 894 | 895 | # Log progress periodically without flooding logs 896 | if current_time - last_progress_time >= progress_interval: 897 | elapsed = current_time - start_time 898 | self.logger.info( 899 | f"Still waiting for Computer API Server... (elapsed: {elapsed:.1f}s, attempts: {attempt_count})" 900 | ) 901 | last_progress_time = current_time 902 | 903 | # Check if we have a connection 904 | if self._ws and self._ws.state == websockets.protocol.State.OPEN: 905 | # Test the connection with a simple command 906 | try: 907 | await self._send_command_ws("get_screen_size") 908 | elapsed = time.time() - start_time 909 | self.logger.info( 910 | f"Computer API Server is ready (after {elapsed:.1f}s, {attempt_count} attempts)" 911 | ) 912 | return # Connection is fully working 913 | except Exception as e: 914 | last_error = e 915 | self.logger.debug(f"Connection test failed: {e}") 916 | 917 | # Wait before trying again 918 | await asyncio.sleep(interval) 919 | 920 | except Exception as e: 921 | last_error = e 922 | self.logger.debug(f"Connection attempt {attempt_count} failed: {e}") 923 | await asyncio.sleep(interval) 924 | 925 | # If we get here, we've timed out 926 | error_msg = f"Could not connect to {self.ip_address} after {timeout} seconds" 927 | if last_error: 928 | error_msg += f": {str(last_error)}" 929 | self.logger.error(error_msg) 930 | raise TimeoutError(error_msg) 931 | finally: 932 | # Reset to default logging behavior 933 | self._log_connection_attempts = False 934 | 935 | def close(self): 936 | """Close WebSocket connection. 937 | 938 | Note: In host computer server mode, we leave the connection open 939 | to allow other clients to connect to the same server. The server 940 | will handle cleaning up idle connections. 941 | """ 942 | # Only cancel the reconnect task 943 | if self._reconnect_task: 944 | self._reconnect_task.cancel() 945 | 946 | # Don't set closed flag or close websocket by default 947 | # This allows the server to stay connected for other clients 948 | # self._closed = True 949 | # if self._ws: 950 | # asyncio.create_task(self._ws.close()) 951 | # self._ws = None 952 | 953 | def force_close(self): 954 | """Force close the WebSocket connection. 955 | 956 | This method should be called when you want to completely 957 | shut down the connection, not just for regular cleanup. 958 | """ 959 | self._closed = True 960 | if self._reconnect_task: 961 | self._reconnect_task.cancel() 962 | if self._ws: 963 | asyncio.create_task(self._ws.close()) 964 | self._ws = None 965 | 966 | ``` -------------------------------------------------------------------------------- /libs/python/computer/computer/providers/lumier/provider.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Lumier VM provider implementation. 3 | 4 | This provider uses Docker containers running the Lumier image to create 5 | macOS and Linux VMs. It handles VM lifecycle operations through Docker 6 | commands and container management. 7 | """ 8 | 9 | import logging 10 | import os 11 | import json 12 | import asyncio 13 | from typing import Dict, List, Optional, Any 14 | import subprocess 15 | import time 16 | import re 17 | 18 | from ..base import BaseVMProvider, VMProviderType 19 | from ..lume_api import ( 20 | lume_api_get, 21 | lume_api_run, 22 | lume_api_stop, 23 | lume_api_update 24 | ) 25 | 26 | # Setup logging 27 | logger = logging.getLogger(__name__) 28 | 29 | # Check if Docker is available 30 | try: 31 | subprocess.run(["docker", "--version"], capture_output=True, check=True) 32 | HAS_LUMIER = True 33 | except (subprocess.SubprocessError, FileNotFoundError): 34 | HAS_LUMIER = False 35 | 36 | 37 | class LumierProvider(BaseVMProvider): 38 | """ 39 | Lumier VM Provider implementation using Docker containers. 40 | 41 | This provider uses Docker to run Lumier containers that can create 42 | macOS and Linux VMs through containerization. 43 | """ 44 | 45 | def __init__( 46 | self, 47 | port: Optional[int] = 7777, 48 | host: str = "localhost", 49 | storage: Optional[str] = None, # Can be a path or 'ephemeral' 50 | shared_path: Optional[str] = None, 51 | image: str = "macos-sequoia-cua:latest", # VM image to use 52 | verbose: bool = False, 53 | ephemeral: bool = False, 54 | noVNC_port: Optional[int] = 8006, 55 | ): 56 | """Initialize the Lumier VM Provider. 57 | 58 | Args: 59 | port: Port for the API server (default: 7777) 60 | host: Hostname for the API server (default: localhost) 61 | storage: Path for persistent VM storage 62 | shared_path: Path for shared folder between host and VM 63 | image: VM image to use (e.g. "macos-sequoia-cua:latest") 64 | verbose: Enable verbose logging 65 | ephemeral: Use ephemeral (temporary) storage 66 | noVNC_port: Specific port for noVNC interface (default: 8006) 67 | """ 68 | self.host = host 69 | # Always ensure api_port has a valid value (7777 is the default) 70 | self.api_port = 7777 if port is None else port 71 | self.vnc_port = noVNC_port # User-specified noVNC port, will be set in run_vm if provided 72 | self.ephemeral = ephemeral 73 | 74 | # Handle ephemeral storage (temporary directory) 75 | if ephemeral: 76 | self.storage = "ephemeral" 77 | else: 78 | self.storage = storage 79 | 80 | self.shared_path = shared_path 81 | self.image = image # Store the VM image name to use 82 | # The container_name will be set in run_vm using the VM name 83 | self.verbose = verbose 84 | self._container_id = None 85 | self._api_url = None # Will be set after container starts 86 | 87 | @property 88 | def provider_type(self) -> VMProviderType: 89 | """Return the provider type.""" 90 | return VMProviderType.LUMIER 91 | 92 | def _parse_memory(self, memory_str: str) -> int: 93 | """Parse memory string to MB integer. 94 | 95 | Examples: 96 | "8GB" -> 8192 97 | "1024MB" -> 1024 98 | "512" -> 512 99 | """ 100 | if isinstance(memory_str, int): 101 | return memory_str 102 | 103 | if isinstance(memory_str, str): 104 | # Extract number and unit 105 | match = re.match(r"(\d+)([A-Za-z]*)", memory_str) 106 | if match: 107 | value, unit = match.groups() 108 | value = int(value) 109 | unit = unit.upper() 110 | 111 | if unit == "GB" or unit == "G": 112 | return value * 1024 113 | elif unit == "MB" or unit == "M" or unit == "": 114 | return value 115 | 116 | # Default fallback 117 | logger.warning(f"Could not parse memory string '{memory_str}', using 8GB default") 118 | return 8192 # Default to 8GB 119 | 120 | # Helper methods for interacting with the Lumier API through curl 121 | # These methods handle the various VM operations via API calls 122 | 123 | def _get_curl_error_message(self, return_code: int) -> str: 124 | """Get a descriptive error message for curl return codes. 125 | 126 | Args: 127 | return_code: The curl return code 128 | 129 | Returns: 130 | A descriptive error message 131 | """ 132 | # Map common curl error codes to helpful messages 133 | if return_code == 7: 134 | return "Failed to connect - API server is starting up" 135 | elif return_code == 22: 136 | return "HTTP error returned from API server" 137 | elif return_code == 28: 138 | return "Operation timeout - API server is slow to respond" 139 | elif return_code == 52: 140 | return "Empty reply from server - API is starting but not ready" 141 | elif return_code == 56: 142 | return "Network problem during data transfer" 143 | else: 144 | return f"Unknown curl error code: {return_code}" 145 | 146 | 147 | async def get_vm(self, name: str, storage: Optional[str] = None) -> Dict[str, Any]: 148 | """Get VM information by name. 149 | 150 | Args: 151 | name: Name of the VM to get information for 152 | storage: Optional storage path override. If provided, this will be used 153 | instead of the provider's default storage path. 154 | 155 | Returns: 156 | Dictionary with VM information including status, IP address, etc. 157 | """ 158 | if not HAS_LUMIER: 159 | logger.error("Docker is not available. Cannot get VM status.") 160 | return { 161 | "name": name, 162 | "status": "unavailable", 163 | "error": "Docker is not available" 164 | } 165 | 166 | # Store the current name for API requests 167 | self.container_name = name 168 | 169 | try: 170 | # Check if the container exists and is running 171 | check_cmd = ["docker", "ps", "-a", "--filter", f"name={name}", "--format", "{{.Status}}"] 172 | check_result = subprocess.run(check_cmd, capture_output=True, text=True) 173 | container_status = check_result.stdout.strip() 174 | 175 | if not container_status: 176 | logger.info(f"Container {name} does not exist. Will create when run_vm is called.") 177 | return { 178 | "name": name, 179 | "status": "not_found", 180 | "message": "Container doesn't exist yet" 181 | } 182 | 183 | # Container exists, check if it's running 184 | is_running = container_status.startswith("Up") 185 | 186 | if not is_running: 187 | logger.info(f"Container {name} exists but is not running. Status: {container_status}") 188 | return { 189 | "name": name, 190 | "status": "stopped", 191 | "container_status": container_status, 192 | } 193 | 194 | # Container is running, get the IP address and API status from Lumier API 195 | logger.info(f"Container {name} is running. Getting VM status from API.") 196 | 197 | # Use the shared lume_api_get function directly 198 | vm_info = lume_api_get( 199 | vm_name=name, 200 | host=self.host, 201 | port=self.api_port, 202 | storage=storage if storage is not None else self.storage, 203 | debug=self.verbose, 204 | verbose=self.verbose 205 | ) 206 | 207 | # Check for API errors 208 | if "error" in vm_info: 209 | # Use debug level instead of warning to reduce log noise during polling 210 | logger.debug(f"API request error: {vm_info['error']}") 211 | return { 212 | "name": name, 213 | "status": "running", # Container is running even if API is not responsive 214 | "api_status": "error", 215 | "error": vm_info["error"], 216 | "container_status": container_status 217 | } 218 | 219 | # Process the VM status information 220 | vm_status = vm_info.get("status", "unknown") 221 | vnc_url = vm_info.get("vncUrl", "") 222 | ip_address = vm_info.get("ipAddress", "") 223 | 224 | # IMPORTANT: Always ensure we have a valid IP address for connectivity 225 | # If the API doesn't return an IP address, default to localhost (127.0.0.1) 226 | # This makes the behavior consistent with LumeProvider 227 | if not ip_address and vm_status == "running": 228 | ip_address = "127.0.0.1" 229 | logger.info(f"No IP address returned from API, defaulting to {ip_address}") 230 | vm_info["ipAddress"] = ip_address 231 | 232 | logger.info(f"VM {name} status: {vm_status}") 233 | 234 | if ip_address and vnc_url: 235 | logger.info(f"VM {name} has IP: {ip_address} and VNC URL: {vnc_url}") 236 | elif not ip_address and not vnc_url and vm_status != "running": 237 | # Not running is expected in this case 238 | logger.info(f"VM {name} is not running yet. Status: {vm_status}") 239 | else: 240 | # Missing IP or VNC but status is running - this is unusual but handled with default IP 241 | logger.warning(f"VM {name} is running but missing expected fields. API response: {vm_info}") 242 | 243 | # Return the full status information 244 | return { 245 | "name": name, 246 | "status": vm_status, 247 | "ip_address": ip_address, 248 | "vnc_url": vnc_url, 249 | "api_status": "ok", 250 | "container_status": container_status, 251 | **vm_info # Include all fields from the API response 252 | } 253 | except subprocess.SubprocessError as e: 254 | logger.error(f"Failed to check container status: {e}") 255 | return { 256 | "name": name, 257 | "status": "error", 258 | "error": f"Failed to check container status: {str(e)}" 259 | } 260 | 261 | async def list_vms(self) -> List[Dict[str, Any]]: 262 | """List all VMs managed by this provider. 263 | 264 | For Lumier provider, there is only one VM per container. 265 | """ 266 | try: 267 | status = await self.get_vm("default") 268 | return [status] if status.get("status") != "unknown" else [] 269 | except Exception as e: 270 | logger.error(f"Failed to list VMs: {e}") 271 | return [] 272 | 273 | async def run_vm(self, image: str, name: str, run_opts: Dict[str, Any], storage: Optional[str] = None) -> Dict[str, Any]: 274 | """Run a VM with the given options. 275 | 276 | Args: 277 | image: Name/tag of the image to use 278 | name: Name of the VM to run (used for the container name and Docker image tag) 279 | run_opts: Options for running the VM, including: 280 | - cpu: Number of CPU cores 281 | - memory: Amount of memory (e.g. "8GB") 282 | - noVNC_port: Specific port for noVNC interface 283 | 284 | Returns: 285 | Dictionary with VM status information 286 | """ 287 | # Set the container name using the VM name for consistency 288 | self.container_name = name 289 | try: 290 | # First, check if container already exists and remove it 291 | try: 292 | check_cmd = ["docker", "ps", "-a", "--filter", f"name={self.container_name}", "--format", "{{.ID}}"] 293 | check_result = subprocess.run(check_cmd, capture_output=True, text=True) 294 | existing_container = check_result.stdout.strip() 295 | 296 | if existing_container: 297 | logger.info(f"Removing existing container: {self.container_name}") 298 | remove_cmd = ["docker", "rm", "-f", self.container_name] 299 | subprocess.run(remove_cmd, check=True) 300 | except subprocess.CalledProcessError as e: 301 | logger.warning(f"Error removing existing container: {e}") 302 | # Continue anyway, next steps will fail if there's a real problem 303 | 304 | # Prepare the Docker run command 305 | cmd = ["docker", "run", "-d", "--name", self.container_name] 306 | 307 | cmd.extend(["-p", f"{self.vnc_port}:8006"]) 308 | logger.debug(f"Using specified noVNC_port: {self.vnc_port}") 309 | 310 | # Set API URL using the API port 311 | self._api_url = f"http://{self.host}:{self.api_port}" 312 | 313 | # Parse memory setting 314 | memory_mb = self._parse_memory(run_opts.get("memory", "8GB")) 315 | 316 | # Add storage volume mount if storage is specified (for persistent VM storage) 317 | if self.storage and self.storage != "ephemeral": 318 | # Create storage directory if it doesn't exist 319 | storage_dir = os.path.abspath(os.path.expanduser(self.storage or "")) 320 | os.makedirs(storage_dir, exist_ok=True) 321 | 322 | # Add volume mount for storage 323 | cmd.extend([ 324 | "-v", f"{storage_dir}:/storage", 325 | "-e", f"HOST_STORAGE_PATH={storage_dir}" 326 | ]) 327 | logger.debug(f"Using persistent storage at: {storage_dir}") 328 | 329 | # Add shared folder volume mount if shared_path is specified 330 | if self.shared_path: 331 | # Create shared directory if it doesn't exist 332 | shared_dir = os.path.abspath(os.path.expanduser(self.shared_path or "")) 333 | os.makedirs(shared_dir, exist_ok=True) 334 | 335 | # Add volume mount for shared folder 336 | cmd.extend([ 337 | "-v", f"{shared_dir}:/shared", 338 | "-e", f"HOST_SHARED_PATH={shared_dir}" 339 | ]) 340 | logger.debug(f"Using shared folder at: {shared_dir}") 341 | 342 | # Add environment variables 343 | # Always use the container_name as the VM_NAME for consistency 344 | # Use the VM image passed from the Computer class 345 | logger.debug(f"Using VM image: {self.image}") 346 | 347 | # If ghcr.io is in the image, use the full image name 348 | if "ghcr.io" in self.image: 349 | vm_image = self.image 350 | else: 351 | vm_image = f"ghcr.io/trycua/{self.image}" 352 | 353 | cmd.extend([ 354 | "-e", f"VM_NAME={self.container_name}", 355 | "-e", f"VERSION={vm_image}", 356 | "-e", f"CPU_CORES={run_opts.get('cpu', '4')}", 357 | "-e", f"RAM_SIZE={memory_mb}", 358 | ]) 359 | 360 | # Specify the Lumier image with the full image name 361 | lumier_image = "trycua/lumier:latest" 362 | 363 | # First check if the image exists locally 364 | try: 365 | logger.debug(f"Checking if Docker image {lumier_image} exists locally...") 366 | check_image_cmd = ["docker", "image", "inspect", lumier_image] 367 | subprocess.run(check_image_cmd, capture_output=True, check=True) 368 | logger.debug(f"Docker image {lumier_image} found locally.") 369 | except subprocess.CalledProcessError: 370 | # Image doesn't exist locally 371 | logger.warning(f"\nWARNING: Docker image {lumier_image} not found locally.") 372 | logger.warning("The system will attempt to pull it from Docker Hub, which may fail if you have network connectivity issues.") 373 | logger.warning("If the Docker pull fails, you may need to manually pull the image first with:") 374 | logger.warning(f" docker pull {lumier_image}\n") 375 | 376 | # Add the image to the command 377 | cmd.append(lumier_image) 378 | 379 | # Print the Docker command for debugging 380 | logger.debug(f"DOCKER COMMAND: {' '.join(cmd)}") 381 | 382 | # Run the container with improved error handling 383 | try: 384 | result = subprocess.run(cmd, capture_output=True, text=True, check=True) 385 | except subprocess.CalledProcessError as e: 386 | if "no route to host" in str(e.stderr).lower() or "failed to resolve reference" in str(e.stderr).lower(): 387 | error_msg = (f"Network error while trying to pull Docker image '{lumier_image}'\n" 388 | f"Error: {e.stderr}\n\n" 389 | f"SOLUTION: Please try one of the following:\n" 390 | f"1. Check your internet connection\n" 391 | f"2. Pull the image manually with: docker pull {lumier_image}\n" 392 | f"3. Check if Docker is running properly\n") 393 | logger.error(error_msg) 394 | raise RuntimeError(error_msg) 395 | raise 396 | 397 | # Container started, now check VM status with polling 398 | logger.debug("Container started, checking VM status...") 399 | logger.debug("NOTE: This may take some time while the VM image is being pulled and initialized") 400 | 401 | # Start a background thread to show container logs in real-time 402 | import threading 403 | 404 | def show_container_logs(): 405 | # Give the container a moment to start generating logs 406 | time.sleep(1) 407 | logger.debug(f"\n---- CONTAINER LOGS FOR '{name}' (LIVE) ----") 408 | logger.debug("Showing logs as they are generated. Press Ctrl+C to stop viewing logs...\n") 409 | 410 | try: 411 | # Use docker logs with follow option 412 | log_cmd = ["docker", "logs", "--tail", "30", "--follow", name] 413 | process = subprocess.Popen(log_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, 414 | text=True, bufsize=1, universal_newlines=True) 415 | 416 | # Read and print logs line by line 417 | for line in process.stdout: 418 | logger.debug(line, end='') 419 | 420 | # Break if process has exited 421 | if process.poll() is not None: 422 | break 423 | except Exception as e: 424 | logger.error(f"\nError showing container logs: {e}") 425 | if self.verbose: 426 | logger.error(f"Error in log streaming thread: {e}") 427 | finally: 428 | logger.debug("\n---- LOG STREAMING ENDED ----") 429 | # Make sure process is terminated 430 | if 'process' in locals() and process.poll() is None: 431 | process.terminate() 432 | 433 | # Start log streaming in a background thread if verbose mode is enabled 434 | log_thread = threading.Thread(target=show_container_logs) 435 | log_thread.daemon = True # Thread will exit when main program exits 436 | log_thread.start() 437 | 438 | # Skip waiting for container readiness and just poll get_vm directly 439 | # Poll the get_vm method indefinitely until the VM is ready with an IP address 440 | attempt = 0 441 | consecutive_errors = 0 442 | vm_running = False 443 | 444 | while True: # Wait indefinitely 445 | try: 446 | # Use longer delays to give the system time to initialize 447 | if attempt > 0: 448 | # Start with 5s delay, then increase gradually up to 30s for later attempts 449 | # But use shorter delays while we're getting API errors 450 | if consecutive_errors > 0 and consecutive_errors < 5: 451 | wait_time = 3 # Use shorter delays when we're getting API errors 452 | else: 453 | wait_time = min(30, 5 + (attempt * 2)) 454 | 455 | logger.debug(f"Waiting {wait_time}s before retry #{attempt+1}...") 456 | await asyncio.sleep(wait_time) 457 | 458 | # Try to get VM status 459 | logger.debug(f"Checking VM status (attempt {attempt+1})...") 460 | vm_status = await self.get_vm(name) 461 | 462 | # Check for API errors 463 | if 'error' in vm_status: 464 | consecutive_errors += 1 465 | error_msg = vm_status.get('error', 'Unknown error') 466 | 467 | # Only print a user-friendly status message, not the raw error 468 | # since _lume_api_get already logged the technical details 469 | if consecutive_errors == 1 or attempt % 5 == 0: 470 | if 'Empty reply from server' in error_msg: 471 | logger.info("API server is starting up - container is running, but API isn't fully initialized yet.") 472 | logger.info("This is expected during the initial VM setup - will continue polling...") 473 | else: 474 | # Don't repeat the exact same error message each time 475 | logger.warning(f"API request error (attempt {attempt+1}): {error_msg}") 476 | # Just log that we're still working on it 477 | if attempt > 3: 478 | logger.debug("Still waiting for the API server to become available...") 479 | 480 | # If we're getting errors but container is running, that's normal during startup 481 | if vm_status.get('status') == 'running': 482 | if not vm_running: 483 | logger.info("Container is running, waiting for the VM within it to become fully ready...") 484 | logger.info("This might take a minute while the VM initializes...") 485 | vm_running = True 486 | 487 | # Increase counter and continue 488 | attempt += 1 489 | continue 490 | 491 | # Reset consecutive error counter when we get a successful response 492 | consecutive_errors = 0 493 | 494 | # If the VM is running, check if it has an IP address (which means it's fully ready) 495 | if vm_status.get('status') == 'running': 496 | vm_running = True 497 | 498 | # Check if we have an IP address, which means the VM is fully ready 499 | if 'ip_address' in vm_status and vm_status['ip_address']: 500 | logger.info(f"VM is now fully running with IP: {vm_status.get('ip_address')}") 501 | if 'vnc_url' in vm_status and vm_status['vnc_url']: 502 | logger.info(f"VNC URL: {vm_status.get('vnc_url')}") 503 | return vm_status 504 | else: 505 | logger.debug("VM is running but still initializing network interfaces...") 506 | logger.debug("Waiting for IP address to be assigned...") 507 | else: 508 | # VM exists but might still be starting up 509 | status = vm_status.get('status', 'unknown') 510 | logger.debug(f"VM found but status is: {status}. Continuing to poll...") 511 | 512 | # Increase counter for next iteration's delay calculation 513 | attempt += 1 514 | 515 | # If we reach a very large number of attempts, give a reassuring message but continue 516 | if attempt % 10 == 0: 517 | logger.debug(f"Still waiting after {attempt} attempts. This might take several minutes for first-time setup.") 518 | if not vm_running and attempt >= 20: 519 | logger.warning("\nNOTE: First-time VM initialization can be slow as images are downloaded.") 520 | logger.warning("If this continues for more than 10 minutes, you may want to check:") 521 | logger.warning(" 1. Docker logs with: docker logs " + name) 522 | logger.warning(" 2. If your network can access container registries") 523 | logger.warning("Press Ctrl+C to abort if needed.\n") 524 | 525 | # After 150 attempts (likely over 30-40 minutes), return current status 526 | if attempt >= 150: 527 | logger.debug(f"Reached 150 polling attempts. VM status is: {vm_status.get('status', 'unknown')}") 528 | logger.debug("Returning current VM status, but please check Docker logs if there are issues.") 529 | return vm_status 530 | 531 | except Exception as e: 532 | # Always continue retrying, but with increasing delays 533 | logger.warning(f"Error checking VM status (attempt {attempt+1}): {e}. Will retry.") 534 | consecutive_errors += 1 535 | 536 | # If we've had too many consecutive errors, might be a deeper problem 537 | if consecutive_errors >= 10: 538 | logger.warning(f"\nWARNING: Encountered {consecutive_errors} consecutive errors while checking VM status.") 539 | logger.warning("You may need to check the Docker container logs or restart the process.") 540 | logger.warning(f"Error details: {str(e)}\n") 541 | 542 | # Increase attempt counter for next iteration 543 | attempt += 1 544 | 545 | # After many consecutive errors, add a delay to avoid hammering the system 546 | if attempt > 5: 547 | error_delay = min(30, 10 + attempt) 548 | logger.warning(f"Multiple connection errors, waiting {error_delay}s before next attempt...") 549 | await asyncio.sleep(error_delay) 550 | 551 | except subprocess.CalledProcessError as e: 552 | error_msg = f"Failed to start Lumier container: {e.stderr if hasattr(e, 'stderr') else str(e)}" 553 | logger.error(error_msg) 554 | raise RuntimeError(error_msg) 555 | 556 | async def _wait_for_container_ready(self, container_name: str, timeout: int = 90) -> bool: 557 | """Wait for the Lumier container to be fully ready with a valid API response. 558 | 559 | Args: 560 | container_name: Name of the Docker container to check 561 | timeout: Maximum time to wait in seconds (default: 90 seconds) 562 | 563 | Returns: 564 | True if the container is running, even if API is not fully ready. 565 | This allows operations to continue with appropriate fallbacks. 566 | """ 567 | start_time = time.time() 568 | api_ready = False 569 | container_running = False 570 | 571 | logger.debug(f"Waiting for container {container_name} to be ready (timeout: {timeout}s)...") 572 | 573 | while time.time() - start_time < timeout: 574 | # Check if container is running 575 | try: 576 | check_cmd = ["docker", "ps", "--filter", f"name={container_name}", "--format", "{{.Status}}"] 577 | result = subprocess.run(check_cmd, capture_output=True, text=True, check=True) 578 | container_status = result.stdout.strip() 579 | 580 | if container_status and container_status.startswith("Up"): 581 | container_running = True 582 | logger.info(f"Container {container_name} is running with status: {container_status}") 583 | else: 584 | logger.warning(f"Container {container_name} not yet running, status: {container_status}") 585 | # container is not running yet, wait and try again 586 | await asyncio.sleep(2) # Longer sleep to give Docker time 587 | continue 588 | except subprocess.CalledProcessError as e: 589 | logger.warning(f"Error checking container status: {e}") 590 | await asyncio.sleep(2) 591 | continue 592 | 593 | # Container is running, check if API is responsive 594 | try: 595 | # First check the health endpoint 596 | api_url = f"http://{self.host}:{self.api_port}/health" 597 | logger.info(f"Checking API health at: {api_url}") 598 | 599 | # Use longer timeout for API health check since it may still be initializing 600 | curl_cmd = ["curl", "-s", "--connect-timeout", "5", "--max-time", "10", api_url] 601 | result = subprocess.run(curl_cmd, capture_output=True, text=True) 602 | 603 | if result.returncode == 0 and "ok" in result.stdout.lower(): 604 | api_ready = True 605 | logger.info(f"API is ready at {api_url}") 606 | break 607 | else: 608 | # API health check failed, now let's check if the VM status endpoint is responsive 609 | # This covers cases where the health endpoint isn't implemented but the VM API is working 610 | vm_api_url = f"http://{self.host}:{self.api_port}/lume/vms/{container_name}" 611 | if self.storage: 612 | import urllib.parse 613 | encoded_storage = urllib.parse.quote_plus(self.storage) 614 | vm_api_url += f"?storage={encoded_storage}" 615 | 616 | curl_vm_cmd = ["curl", "-s", "--connect-timeout", "5", "--max-time", "10", vm_api_url] 617 | vm_result = subprocess.run(curl_vm_cmd, capture_output=True, text=True) 618 | 619 | if vm_result.returncode == 0 and vm_result.stdout.strip(): 620 | # VM API responded with something - consider the API ready 621 | api_ready = True 622 | logger.info(f"VM API is ready at {vm_api_url}") 623 | break 624 | else: 625 | curl_code = result.returncode 626 | if curl_code == 0: 627 | curl_code = vm_result.returncode 628 | 629 | # Map common curl error codes to helpful messages 630 | if curl_code == 7: 631 | curl_error = "Failed to connect - API server is starting up" 632 | elif curl_code == 22: 633 | curl_error = "HTTP error returned from API server" 634 | elif curl_code == 28: 635 | curl_error = "Operation timeout - API server is slow to respond" 636 | elif curl_code == 52: 637 | curl_error = "Empty reply from server - API is starting but not ready" 638 | elif curl_code == 56: 639 | curl_error = "Network problem during data transfer" 640 | else: 641 | curl_error = f"Unknown curl error code: {curl_code}" 642 | 643 | logger.info(f"API not ready yet: {curl_error}") 644 | except subprocess.SubprocessError as e: 645 | logger.warning(f"Error checking API status: {e}") 646 | 647 | # If the container is running but API is not ready, that's OK - we'll just wait 648 | # a bit longer before checking again, as the container may still be initializing 649 | elapsed_seconds = time.time() - start_time 650 | if int(elapsed_seconds) % 5 == 0: # Only print status every 5 seconds to reduce verbosity 651 | logger.debug(f"Waiting for API to initialize... ({elapsed_seconds:.1f}s / {timeout}s)") 652 | 653 | await asyncio.sleep(3) # Longer sleep between API checks 654 | 655 | # Handle timeout - if the container is running but API is not ready, that's not 656 | # necessarily an error - the API might just need more time to start up 657 | if not container_running: 658 | logger.warning(f"Timed out waiting for container {container_name} to start") 659 | return False 660 | 661 | if not api_ready: 662 | logger.warning(f"Container {container_name} is running, but API is not fully ready yet.") 663 | logger.warning(f"NOTE: You may see some 'API request failed' messages while the API initializes.") 664 | 665 | # Return True if container is running, even if API isn't ready yet 666 | # This allows VM operations to proceed, with appropriate retries for API calls 667 | return container_running 668 | 669 | async def stop_vm(self, name: str, storage: Optional[str] = None) -> Dict[str, Any]: 670 | """Stop a running VM by stopping the Lumier container.""" 671 | try: 672 | # Use Docker commands to stop the container directly 673 | if hasattr(self, '_container_id') and self._container_id: 674 | logger.info(f"Stopping Lumier container: {self.container_name}") 675 | cmd = ["docker", "stop", self.container_name] 676 | result = subprocess.run(cmd, capture_output=True, text=True, check=True) 677 | logger.info(f"Container stopped: {result.stdout.strip()}") 678 | 679 | # Return minimal status info 680 | return { 681 | "name": name, 682 | "status": "stopped", 683 | "container_id": self._container_id, 684 | } 685 | else: 686 | # Try to find the container by name 687 | check_cmd = ["docker", "ps", "-a", "--filter", f"name={self.container_name}", "--format", "{{.ID}}"] 688 | check_result = subprocess.run(check_cmd, capture_output=True, text=True) 689 | container_id = check_result.stdout.strip() 690 | 691 | if container_id: 692 | logger.info(f"Found container ID: {container_id}") 693 | cmd = ["docker", "stop", self.container_name] 694 | result = subprocess.run(cmd, capture_output=True, text=True, check=True) 695 | logger.info(f"Container stopped: {result.stdout.strip()}") 696 | 697 | return { 698 | "name": name, 699 | "status": "stopped", 700 | "container_id": container_id, 701 | } 702 | else: 703 | logger.warning(f"No container found with name {self.container_name}") 704 | return { 705 | "name": name, 706 | "status": "unknown", 707 | } 708 | except subprocess.CalledProcessError as e: 709 | error_msg = f"Failed to stop container: {e.stderr if hasattr(e, 'stderr') else str(e)}" 710 | logger.error(error_msg) 711 | raise RuntimeError(f"Failed to stop Lumier container: {error_msg}") 712 | 713 | # update_vm is not implemented as it's not needed for Lumier 714 | # The BaseVMProvider requires it, so we provide a minimal implementation 715 | async def update_vm(self, name: str, update_opts: Dict[str, Any], storage: Optional[str] = None) -> Dict[str, Any]: 716 | """Not implemented for Lumier provider.""" 717 | logger.warning("update_vm is not implemented for Lumier provider") 718 | return {"name": name, "status": "unchanged"} 719 | 720 | async def get_logs(self, name: str, num_lines: int = 100, follow: bool = False, timeout: Optional[int] = None) -> str: 721 | """Get the logs from the Lumier container. 722 | 723 | Args: 724 | name: Name of the VM/container to get logs for 725 | num_lines: Number of recent log lines to return (default: 100) 726 | follow: If True, follow the logs (stream new logs as they are generated) 727 | timeout: Optional timeout in seconds for follow mode (None means no timeout) 728 | 729 | Returns: 730 | Container logs as a string 731 | 732 | Note: 733 | If follow=True, this function will continuously stream logs until timeout 734 | or until interrupted. The output will be printed to console in real-time. 735 | """ 736 | if not HAS_LUMIER: 737 | error_msg = "Docker is not available. Cannot get container logs." 738 | logger.error(error_msg) 739 | return error_msg 740 | 741 | # Make sure we have a container name 742 | container_name = name 743 | 744 | # Check if the container exists and is running 745 | try: 746 | # Check if the container exists 747 | inspect_cmd = ["docker", "container", "inspect", container_name] 748 | result = subprocess.run(inspect_cmd, capture_output=True, text=True) 749 | 750 | if result.returncode != 0: 751 | error_msg = f"Container '{container_name}' does not exist or is not accessible" 752 | logger.error(error_msg) 753 | return error_msg 754 | except Exception as e: 755 | error_msg = f"Error checking container status: {str(e)}" 756 | logger.error(error_msg) 757 | return error_msg 758 | 759 | # Base docker logs command 760 | log_cmd = ["docker", "logs"] 761 | 762 | # Add tail parameter to limit the number of lines 763 | log_cmd.extend(["--tail", str(num_lines)]) 764 | 765 | # Handle follow mode with or without timeout 766 | if follow: 767 | log_cmd.append("--follow") 768 | 769 | if timeout is not None: 770 | # For follow mode with timeout, we'll run the command and handle the timeout 771 | log_cmd.append(container_name) 772 | logger.info(f"Following logs for container '{container_name}' with timeout {timeout}s") 773 | logger.info(f"\n---- CONTAINER LOGS FOR '{container_name}' (LIVE) ----") 774 | logger.info(f"Press Ctrl+C to stop following logs\n") 775 | 776 | try: 777 | # Run with timeout 778 | process = subprocess.Popen(log_cmd, text=True) 779 | 780 | # Wait for the specified timeout 781 | if timeout: 782 | try: 783 | process.wait(timeout=timeout) 784 | except subprocess.TimeoutExpired: 785 | process.terminate() # Stop after timeout 786 | logger.info(f"\n---- LOG FOLLOWING STOPPED (timeout {timeout}s reached) ----") 787 | else: 788 | # Without timeout, wait for user interruption 789 | process.wait() 790 | 791 | return "Logs were displayed to console in follow mode" 792 | except KeyboardInterrupt: 793 | process.terminate() 794 | logger.info("\n---- LOG FOLLOWING STOPPED (user interrupted) ----") 795 | return "Logs were displayed to console in follow mode (interrupted)" 796 | else: 797 | # For follow mode without timeout, we'll print a helpful message 798 | log_cmd.append(container_name) 799 | logger.info(f"Following logs for container '{container_name}' indefinitely") 800 | logger.info(f"\n---- CONTAINER LOGS FOR '{container_name}' (LIVE) ----") 801 | logger.info(f"Press Ctrl+C to stop following logs\n") 802 | 803 | try: 804 | # Run the command and let it run until interrupted 805 | process = subprocess.Popen(log_cmd, text=True) 806 | process.wait() # Wait indefinitely (until user interrupts) 807 | return "Logs were displayed to console in follow mode" 808 | except KeyboardInterrupt: 809 | process.terminate() 810 | logger.info("\n---- LOG FOLLOWING STOPPED (user interrupted) ----") 811 | return "Logs were displayed to console in follow mode (interrupted)" 812 | else: 813 | # For non-follow mode, capture and return the logs as a string 814 | log_cmd.append(container_name) 815 | logger.info(f"Getting {num_lines} log lines for container '{container_name}'") 816 | 817 | try: 818 | result = subprocess.run(log_cmd, capture_output=True, text=True, check=True) 819 | logs = result.stdout 820 | 821 | # Only print header and logs if there's content 822 | if logs.strip(): 823 | logger.info(f"\n---- CONTAINER LOGS FOR '{container_name}' (LAST {num_lines} LINES) ----\n") 824 | logger.info(logs) 825 | logger.info(f"\n---- END OF LOGS ----") 826 | else: 827 | logger.info(f"\nNo logs available for container '{container_name}'") 828 | 829 | return logs 830 | except subprocess.CalledProcessError as e: 831 | error_msg = f"Error getting logs: {e.stderr}" 832 | logger.error(error_msg) 833 | return error_msg 834 | except Exception as e: 835 | error_msg = f"Unexpected error getting logs: {str(e)}" 836 | logger.error(error_msg) 837 | return error_msg 838 | 839 | async def restart_vm(self, name: str, storage: Optional[str] = None) -> Dict[str, Any]: 840 | raise NotImplementedError("LumierProvider does not support restarting VMs.") 841 | 842 | async def get_ip(self, name: str, storage: Optional[str] = None, retry_delay: int = 2) -> str: 843 | """Get the IP address of a VM, waiting indefinitely until it's available. 844 | 845 | Args: 846 | name: Name of the VM to get the IP for 847 | storage: Optional storage path override 848 | retry_delay: Delay between retries in seconds (default: 2) 849 | 850 | Returns: 851 | IP address of the VM when it becomes available 852 | """ 853 | # Use container_name = name for consistency 854 | self.container_name = name 855 | 856 | # Track total attempts for logging purposes 857 | total_attempts = 0 858 | 859 | # Loop indefinitely until we get a valid IP 860 | while True: 861 | total_attempts += 1 862 | 863 | # Log retry message but not on first attempt 864 | if total_attempts > 1: 865 | logger.info(f"Waiting for VM {name} IP address (attempt {total_attempts})...") 866 | 867 | try: 868 | # Get VM information 869 | vm_info = await self.get_vm(name, storage=storage) 870 | 871 | # Check if we got a valid IP 872 | ip = vm_info.get("ip_address", None) 873 | if ip and ip != "unknown" and not ip.startswith("0.0.0.0"): 874 | logger.info(f"Got valid VM IP address: {ip}") 875 | return ip 876 | 877 | # Check the VM status 878 | status = vm_info.get("status", "unknown") 879 | 880 | # Special handling for Lumier: it may report "stopped" even when the VM is starting 881 | # If the VM information contains an IP but status is stopped, it might be a race condition 882 | if status == "stopped" and "ip_address" in vm_info: 883 | ip = vm_info.get("ip_address") 884 | if ip and ip != "unknown" and not ip.startswith("0.0.0.0"): 885 | logger.info(f"Found valid IP {ip} despite VM status being {status}") 886 | return ip 887 | logger.info(f"VM status is {status}, but still waiting for IP to be assigned") 888 | # If VM is not running yet, log and wait 889 | elif status != "running": 890 | logger.info(f"VM is not running yet (status: {status}). Waiting...") 891 | # If VM is running but no IP yet, wait and retry 892 | else: 893 | logger.info("VM is running but no valid IP address yet. Waiting...") 894 | 895 | except Exception as e: 896 | logger.warning(f"Error getting VM {name} IP: {e}, continuing to wait...") 897 | 898 | # Wait before next retry 899 | await asyncio.sleep(retry_delay) 900 | 901 | # Add progress log every 10 attempts 902 | if total_attempts % 10 == 0: 903 | logger.info(f"Still waiting for VM {name} IP after {total_attempts} attempts...") 904 | 905 | async def __aenter__(self): 906 | """Async context manager entry. 907 | 908 | This method is called when entering an async context manager block. 909 | Returns self to be used in the context. 910 | """ 911 | logger.debug("Entering LumierProvider context") 912 | 913 | # Initialize the API URL with the default value if not already set 914 | # This ensures get_vm can work before run_vm is called 915 | if not hasattr(self, '_api_url') or not self._api_url: 916 | self._api_url = f"http://{self.host}:{self.api_port}" 917 | logger.info(f"Initialized default Lumier API URL: {self._api_url}") 918 | 919 | return self 920 | 921 | async def __aexit__(self, exc_type, exc_val, exc_tb): 922 | """Async context manager exit. 923 | 924 | This method is called when exiting an async context manager block. 925 | It handles proper cleanup of resources, including stopping any running containers. 926 | """ 927 | logger.debug(f"Exiting LumierProvider context, handling exceptions: {exc_type}") 928 | try: 929 | # If we have a container ID, we should stop it to clean up resources 930 | if hasattr(self, '_container_id') and self._container_id: 931 | logger.info(f"Stopping Lumier container on context exit: {self.container_name}") 932 | try: 933 | cmd = ["docker", "stop", self.container_name] 934 | subprocess.run(cmd, capture_output=True, text=True, check=True) 935 | logger.info(f"Container stopped during context exit: {self.container_name}") 936 | except subprocess.CalledProcessError as e: 937 | logger.warning(f"Failed to stop container during cleanup: {e.stderr}") 938 | # Don't raise an exception here, we want to continue with cleanup 939 | except Exception as e: 940 | logger.error(f"Error during LumierProvider cleanup: {e}") 941 | # We don't want to suppress the original exception if there was one 942 | if exc_type is None: 943 | raise 944 | # Return False to indicate that any exception should propagate 945 | return False 946 | ``` -------------------------------------------------------------------------------- /libs/python/computer/computer/computer.py: -------------------------------------------------------------------------------- ```python 1 | import traceback 2 | from typing import Optional, List, Literal, Dict, Any, Union, TYPE_CHECKING, cast 3 | import asyncio 4 | from .models import Computer as ComputerConfig, Display 5 | from .interface.factory import InterfaceFactory 6 | import time 7 | from PIL import Image 8 | import io 9 | import re 10 | from .logger import Logger, LogLevel 11 | import json 12 | import logging 13 | from core.telemetry import is_telemetry_enabled, record_event 14 | import os 15 | from . import helpers 16 | 17 | import platform 18 | 19 | SYSTEM_INFO = { 20 | "os": platform.system().lower(), 21 | "os_version": platform.release(), 22 | "python_version": platform.python_version(), 23 | } 24 | 25 | # Import provider related modules 26 | from .providers.base import VMProviderType 27 | from .providers.factory import VMProviderFactory 28 | 29 | OSType = Literal["macos", "linux", "windows"] 30 | 31 | class Computer: 32 | """Computer is the main class for interacting with the computer.""" 33 | 34 | def create_desktop_from_apps(self, apps): 35 | """ 36 | Create a virtual desktop from a list of app names, returning a DioramaComputer 37 | that proxies Diorama.Interface but uses diorama_cmds via the computer interface. 38 | 39 | Args: 40 | apps (list[str]): List of application names to include in the desktop. 41 | Returns: 42 | DioramaComputer: A proxy object with the Diorama interface, but using diorama_cmds. 43 | """ 44 | assert "app-use" in self.experiments, "App Usage is an experimental feature. Enable it by passing experiments=['app-use'] to Computer()" 45 | from .diorama_computer import DioramaComputer 46 | return DioramaComputer(self, apps) 47 | 48 | def __init__( 49 | self, 50 | display: Union[Display, Dict[str, int], str] = "1024x768", 51 | memory: str = "8GB", 52 | cpu: str = "4", 53 | os_type: OSType = "macos", 54 | name: str = "", 55 | image: Optional[str] = None, 56 | shared_directories: Optional[List[str]] = None, 57 | use_host_computer_server: bool = False, 58 | verbosity: Union[int, LogLevel] = logging.INFO, 59 | telemetry_enabled: bool = True, 60 | provider_type: Union[str, VMProviderType] = VMProviderType.LUME, 61 | port: Optional[int] = 7777, 62 | noVNC_port: Optional[int] = 8006, 63 | host: str = os.environ.get("PYLUME_HOST", "localhost"), 64 | storage: Optional[str] = None, 65 | ephemeral: bool = False, 66 | api_key: Optional[str] = None, 67 | experiments: Optional[List[str]] = None 68 | ): 69 | """Initialize a new Computer instance. 70 | 71 | Args: 72 | display: The display configuration. Can be: 73 | - A Display object 74 | - A dict with 'width' and 'height' 75 | - A string in format "WIDTHxHEIGHT" (e.g. "1920x1080") 76 | Defaults to "1024x768" 77 | memory: The VM memory allocation. Defaults to "8GB" 78 | cpu: The VM CPU allocation. Defaults to "4" 79 | os_type: The operating system type ('macos' or 'linux') 80 | name: The VM name 81 | image: The VM image name 82 | shared_directories: Optional list of directory paths to share with the VM 83 | use_host_computer_server: If True, target localhost instead of starting a VM 84 | verbosity: Logging level (standard Python logging levels: logging.DEBUG, logging.INFO, etc.) 85 | LogLevel enum values are still accepted for backward compatibility 86 | telemetry_enabled: Whether to enable telemetry tracking. Defaults to True. 87 | provider_type: The VM provider type to use (lume, qemu, cloud) 88 | port: Optional port to use for the VM provider server 89 | noVNC_port: Optional port for the noVNC web interface (Lumier provider) 90 | host: Host to use for VM provider connections (e.g. "localhost", "host.docker.internal") 91 | storage: Optional path for persistent VM storage (Lumier provider) 92 | ephemeral: Whether to use ephemeral storage 93 | api_key: Optional API key for cloud providers 94 | experiments: Optional list of experimental features to enable (e.g. ["app-use"]) 95 | """ 96 | 97 | self.logger = Logger("computer", verbosity) 98 | self.logger.info("Initializing Computer...") 99 | 100 | if not image: 101 | if os_type == "macos": 102 | image = "macos-sequoia-cua:latest" 103 | elif os_type == "linux": 104 | image = "trycua/cua-ubuntu:latest" 105 | image = str(image) 106 | 107 | # Store original parameters 108 | self.image = image 109 | self.port = port 110 | self.noVNC_port = noVNC_port 111 | self.host = host 112 | self.os_type = os_type 113 | self.provider_type = provider_type 114 | self.ephemeral = ephemeral 115 | 116 | self.api_key = api_key 117 | self.experiments = experiments or [] 118 | 119 | if "app-use" in self.experiments: 120 | assert self.os_type == "macos", "App use experiment is only supported on macOS" 121 | 122 | # The default is currently to use non-ephemeral storage 123 | if storage and ephemeral and storage != "ephemeral": 124 | raise ValueError("Storage path and ephemeral flag cannot be used together") 125 | 126 | # Windows Sandbox always uses ephemeral storage 127 | if self.provider_type == VMProviderType.WINSANDBOX: 128 | if not ephemeral and storage != None and storage != "ephemeral": 129 | self.logger.warning("Windows Sandbox storage is always ephemeral. Setting ephemeral=True.") 130 | self.ephemeral = True 131 | self.storage = "ephemeral" 132 | else: 133 | self.storage = "ephemeral" if ephemeral else storage 134 | 135 | # For Lumier provider, store the first shared directory path to use 136 | # for VM file sharing 137 | self.shared_path = None 138 | if shared_directories and len(shared_directories) > 0: 139 | self.shared_path = shared_directories[0] 140 | self.logger.info(f"Using first shared directory for VM file sharing: {self.shared_path}") 141 | 142 | # Store telemetry preference 143 | self._telemetry_enabled = telemetry_enabled 144 | 145 | # Set initialization flag 146 | self._initialized = False 147 | self._running = False 148 | 149 | # Configure root logger 150 | self.verbosity = verbosity 151 | self.logger = Logger("computer", verbosity) 152 | 153 | # Configure component loggers with proper hierarchy 154 | self.vm_logger = Logger("computer.vm", verbosity) 155 | self.interface_logger = Logger("computer.interface", verbosity) 156 | 157 | if not use_host_computer_server: 158 | if ":" not in image: 159 | image = f"{image}:latest" 160 | 161 | if not name: 162 | # Normalize the name to be used for the VM 163 | name = image.replace(":", "_") 164 | # Remove any forward slashes 165 | name = name.replace("/", "_") 166 | 167 | # Convert display parameter to Display object 168 | if isinstance(display, str): 169 | # Parse string format "WIDTHxHEIGHT" 170 | match = re.match(r"(\d+)x(\d+)", display) 171 | if not match: 172 | raise ValueError( 173 | "Display string must be in format 'WIDTHxHEIGHT' (e.g. '1024x768')" 174 | ) 175 | width, height = map(int, match.groups()) 176 | display_config = Display(width=width, height=height) 177 | elif isinstance(display, dict): 178 | display_config = Display(**display) 179 | else: 180 | display_config = display 181 | 182 | self.config = ComputerConfig( 183 | image=image.split(":")[0], 184 | tag=image.split(":")[1], 185 | name=name, 186 | display=display_config, 187 | memory=memory, 188 | cpu=cpu, 189 | ) 190 | # Initialize VM provider but don't start it yet - we'll do that in run() 191 | self.config.vm_provider = None # Will be initialized in run() 192 | 193 | # Store shared directories config 194 | self.shared_directories = shared_directories or [] 195 | 196 | # Placeholder for VM provider context manager 197 | self._provider_context = None 198 | 199 | # Initialize with proper typing - None at first, will be set in run() 200 | self._interface = None 201 | self.use_host_computer_server = use_host_computer_server 202 | 203 | # Record initialization in telemetry (if enabled) 204 | if telemetry_enabled and is_telemetry_enabled(): 205 | record_event("computer_initialized", SYSTEM_INFO) 206 | else: 207 | self.logger.debug("Telemetry disabled - skipping initialization tracking") 208 | 209 | async def __aenter__(self): 210 | """Start the computer.""" 211 | await self.run() 212 | return self 213 | 214 | async def __aexit__(self, exc_type, exc_val, exc_tb): 215 | """Stop the computer.""" 216 | await self.disconnect() 217 | 218 | def __enter__(self): 219 | """Start the computer.""" 220 | # Run the event loop to call the async enter method 221 | loop = asyncio.get_event_loop() 222 | loop.run_until_complete(self.__aenter__()) 223 | return self 224 | 225 | def __exit__(self, exc_type, exc_val, exc_tb): 226 | """Stop the computer.""" 227 | loop = asyncio.get_event_loop() 228 | loop.run_until_complete(self.__aexit__(exc_type, exc_val, exc_tb)) 229 | 230 | async def run(self) -> Optional[str]: 231 | """Initialize the VM and computer interface.""" 232 | if TYPE_CHECKING: 233 | from .interface.base import BaseComputerInterface 234 | 235 | # If already initialized, just log and return 236 | if hasattr(self, "_initialized") and self._initialized: 237 | self.logger.info("Computer already initialized, skipping initialization") 238 | return 239 | 240 | self.logger.info("Starting computer...") 241 | start_time = time.time() 242 | 243 | try: 244 | # If using host computer server 245 | if self.use_host_computer_server: 246 | self.logger.info("Using host computer server") 247 | # Set ip_address for host computer server mode 248 | ip_address = "localhost" 249 | # Create the interface with explicit type annotation 250 | from .interface.base import BaseComputerInterface 251 | 252 | self._interface = cast( 253 | BaseComputerInterface, 254 | InterfaceFactory.create_interface_for_os( 255 | os=self.os_type, ip_address=ip_address # type: ignore[arg-type] 256 | ), 257 | ) 258 | 259 | self.logger.info("Waiting for host computer server to be ready...") 260 | await self._interface.wait_for_ready() 261 | self.logger.info("Host computer server ready") 262 | else: 263 | # Start or connect to VM 264 | self.logger.info(f"Starting VM: {self.image}") 265 | if not self._provider_context: 266 | try: 267 | provider_type_name = self.provider_type.name if isinstance(self.provider_type, VMProviderType) else self.provider_type 268 | self.logger.verbose(f"Initializing {provider_type_name} provider context...") 269 | 270 | # Explicitly set provider parameters 271 | storage = "ephemeral" if self.ephemeral else self.storage 272 | verbose = self.verbosity >= LogLevel.DEBUG 273 | ephemeral = self.ephemeral 274 | port = self.port if self.port is not None else 7777 275 | host = self.host if self.host else "localhost" 276 | image = self.image 277 | shared_path = self.shared_path 278 | noVNC_port = self.noVNC_port 279 | 280 | # Create VM provider instance with explicit parameters 281 | try: 282 | if self.provider_type == VMProviderType.LUMIER: 283 | self.logger.info(f"Using VM image for Lumier provider: {image}") 284 | if shared_path: 285 | self.logger.info(f"Using shared path for Lumier provider: {shared_path}") 286 | if noVNC_port: 287 | self.logger.info(f"Using noVNC port for Lumier provider: {noVNC_port}") 288 | self.config.vm_provider = VMProviderFactory.create_provider( 289 | self.provider_type, 290 | port=port, 291 | host=host, 292 | storage=storage, 293 | shared_path=shared_path, 294 | image=image, 295 | verbose=verbose, 296 | ephemeral=ephemeral, 297 | noVNC_port=noVNC_port, 298 | ) 299 | elif self.provider_type == VMProviderType.LUME: 300 | self.config.vm_provider = VMProviderFactory.create_provider( 301 | self.provider_type, 302 | port=port, 303 | host=host, 304 | storage=storage, 305 | verbose=verbose, 306 | ephemeral=ephemeral, 307 | ) 308 | elif self.provider_type == VMProviderType.CLOUD: 309 | self.config.vm_provider = VMProviderFactory.create_provider( 310 | self.provider_type, 311 | api_key=self.api_key, 312 | verbose=verbose, 313 | ) 314 | elif self.provider_type == VMProviderType.WINSANDBOX: 315 | self.config.vm_provider = VMProviderFactory.create_provider( 316 | self.provider_type, 317 | port=port, 318 | host=host, 319 | storage=storage, 320 | verbose=verbose, 321 | ephemeral=ephemeral, 322 | noVNC_port=noVNC_port, 323 | ) 324 | elif self.provider_type == VMProviderType.DOCKER: 325 | self.config.vm_provider = VMProviderFactory.create_provider( 326 | self.provider_type, 327 | port=port, 328 | host=host, 329 | storage=storage, 330 | shared_path=shared_path, 331 | image=image or "trycua/cua-ubuntu:latest", 332 | verbose=verbose, 333 | ephemeral=ephemeral, 334 | noVNC_port=noVNC_port, 335 | ) 336 | else: 337 | raise ValueError(f"Unsupported provider type: {self.provider_type}") 338 | self._provider_context = await self.config.vm_provider.__aenter__() 339 | self.logger.verbose("VM provider context initialized successfully") 340 | except ImportError as ie: 341 | self.logger.error(f"Failed to import provider dependencies: {ie}") 342 | if str(ie).find("lume") >= 0 and str(ie).find("lumier") < 0: 343 | self.logger.error("Please install with: pip install cua-computer[lume]") 344 | elif str(ie).find("lumier") >= 0 or str(ie).find("docker") >= 0: 345 | self.logger.error("Please install with: pip install cua-computer[lumier] and make sure Docker is installed") 346 | elif str(ie).find("cloud") >= 0: 347 | self.logger.error("Please install with: pip install cua-computer[cloud]") 348 | raise 349 | except Exception as e: 350 | self.logger.error(f"Failed to initialize provider context: {e}") 351 | raise RuntimeError(f"Failed to initialize VM provider: {e}") 352 | 353 | # Check if VM exists or create it 354 | is_running = False 355 | try: 356 | if self.config.vm_provider is None: 357 | raise RuntimeError(f"VM provider not initialized for {self.config.name}") 358 | 359 | vm = await self.config.vm_provider.get_vm(self.config.name) 360 | self.logger.verbose(f"Found existing VM: {self.config.name}") 361 | is_running = vm.get("status") == "running" 362 | except Exception as e: 363 | self.logger.error(f"VM not found: {self.config.name}") 364 | self.logger.error(f"Error: {e}") 365 | raise RuntimeError( 366 | f"VM {self.config.name} could not be found or created." 367 | ) 368 | 369 | # Start the VM if it's not running 370 | if not is_running: 371 | self.logger.info(f"VM {self.config.name} is not running, starting it...") 372 | 373 | # Convert paths to dictionary format for shared directories 374 | shared_dirs = [] 375 | for path in self.shared_directories: 376 | self.logger.verbose(f"Adding shared directory: {path}") 377 | path = os.path.abspath(os.path.expanduser(path)) 378 | if os.path.exists(path): 379 | # Add path in format expected by Lume API 380 | shared_dirs.append({ 381 | "hostPath": path, 382 | "readOnly": False 383 | }) 384 | else: 385 | self.logger.warning(f"Shared directory does not exist: {path}") 386 | 387 | # Prepare run options to pass to the provider 388 | run_opts = {} 389 | 390 | # Add display information if available 391 | if self.config.display is not None: 392 | display_info = { 393 | "width": self.config.display.width, 394 | "height": self.config.display.height, 395 | } 396 | 397 | # Check if scale_factor exists before adding it 398 | if hasattr(self.config.display, "scale_factor"): 399 | display_info["scale_factor"] = self.config.display.scale_factor 400 | 401 | run_opts["display"] = display_info 402 | 403 | # Add shared directories if available 404 | if self.shared_directories: 405 | run_opts["shared_directories"] = shared_dirs.copy() 406 | 407 | # Run the VM with the provider 408 | try: 409 | if self.config.vm_provider is None: 410 | raise RuntimeError(f"VM provider not initialized for {self.config.name}") 411 | 412 | # Use the complete run_opts we prepared earlier 413 | # Handle ephemeral storage for run_vm method too 414 | storage_param = "ephemeral" if self.ephemeral else self.storage 415 | 416 | # Log the image being used 417 | self.logger.info(f"Running VM using image: {self.image}") 418 | 419 | # Call provider.run_vm with explicit image parameter 420 | response = await self.config.vm_provider.run_vm( 421 | image=self.image, 422 | name=self.config.name, 423 | run_opts=run_opts, 424 | storage=storage_param 425 | ) 426 | self.logger.info(f"VM run response: {response if response else 'None'}") 427 | except Exception as run_error: 428 | self.logger.error(f"Failed to run VM: {run_error}") 429 | raise RuntimeError(f"Failed to start VM: {run_error}") 430 | 431 | # Wait for VM to be ready with a valid IP address 432 | self.logger.info("Waiting for VM to be ready with a valid IP address...") 433 | try: 434 | if self.provider_type == VMProviderType.LUMIER: 435 | max_retries = 60 # Increased for Lumier VM startup which takes longer 436 | retry_delay = 3 # 3 seconds between retries for Lumier 437 | else: 438 | max_retries = 30 # Default for other providers 439 | retry_delay = 2 # 2 seconds between retries 440 | 441 | self.logger.info(f"Waiting up to {max_retries * retry_delay} seconds for VM to be ready...") 442 | ip = await self.get_ip(max_retries=max_retries, retry_delay=retry_delay) 443 | 444 | # If we get here, we have a valid IP 445 | self.logger.info(f"VM is ready with IP: {ip}") 446 | ip_address = ip 447 | except TimeoutError as timeout_error: 448 | self.logger.error(str(timeout_error)) 449 | raise RuntimeError(f"VM startup timed out: {timeout_error}") 450 | except Exception as wait_error: 451 | self.logger.error(f"Error waiting for VM: {wait_error}") 452 | raise RuntimeError(f"VM failed to become ready: {wait_error}") 453 | except Exception as e: 454 | self.logger.error(f"Failed to initialize computer: {e}") 455 | self.logger.error(traceback.format_exc()) 456 | raise RuntimeError(f"Failed to initialize computer: {e}") 457 | 458 | try: 459 | # Verify we have a valid IP before initializing the interface 460 | if not ip_address or ip_address == "unknown" or ip_address == "0.0.0.0": 461 | raise RuntimeError(f"Cannot initialize interface - invalid IP address: {ip_address}") 462 | 463 | # Initialize the interface using the factory with the specified OS 464 | self.logger.info(f"Initializing interface for {self.os_type} at {ip_address}") 465 | from .interface.base import BaseComputerInterface 466 | 467 | # Pass authentication credentials if using cloud provider 468 | if self.provider_type == VMProviderType.CLOUD and self.api_key and self.config.name: 469 | self._interface = cast( 470 | BaseComputerInterface, 471 | InterfaceFactory.create_interface_for_os( 472 | os=self.os_type, 473 | ip_address=ip_address, 474 | api_key=self.api_key, 475 | vm_name=self.config.name 476 | ), 477 | ) 478 | else: 479 | self._interface = cast( 480 | BaseComputerInterface, 481 | InterfaceFactory.create_interface_for_os( 482 | os=self.os_type, 483 | ip_address=ip_address 484 | ), 485 | ) 486 | 487 | # Wait for the WebSocket interface to be ready 488 | self.logger.info("Connecting to WebSocket interface...") 489 | 490 | try: 491 | # Use a single timeout for the entire connection process 492 | # The VM should already be ready at this point, so we're just establishing the connection 493 | await self._interface.wait_for_ready(timeout=30) 494 | self.logger.info("WebSocket interface connected successfully") 495 | except TimeoutError as e: 496 | self.logger.error(f"Failed to connect to WebSocket interface at {ip_address}") 497 | raise TimeoutError( 498 | f"Could not connect to WebSocket interface at {ip_address}:8000/ws: {str(e)}" 499 | ) 500 | # self.logger.warning( 501 | # f"Could not connect to WebSocket interface at {ip_address}:8000/ws: {str(e)}, expect missing functionality" 502 | # ) 503 | 504 | # Create an event to keep the VM running in background if needed 505 | if not self.use_host_computer_server: 506 | self._stop_event = asyncio.Event() 507 | self._keep_alive_task = asyncio.create_task(self._stop_event.wait()) 508 | 509 | self.logger.info("Computer is ready") 510 | 511 | # Set the initialization flag and clear the initializing flag 512 | self._initialized = True 513 | 514 | # Set this instance as the default computer for remote decorators 515 | helpers.set_default_computer(self) 516 | 517 | self.logger.info("Computer successfully initialized") 518 | except Exception as e: 519 | raise 520 | finally: 521 | # Log initialization time for performance monitoring 522 | duration_ms = (time.time() - start_time) * 1000 523 | self.logger.debug(f"Computer initialization took {duration_ms:.2f}ms") 524 | return 525 | 526 | async def disconnect(self) -> None: 527 | """Disconnect from the computer's WebSocket interface.""" 528 | if self._interface: 529 | self._interface.close() 530 | 531 | async def stop(self) -> None: 532 | """Disconnect from the computer's WebSocket interface and stop the computer.""" 533 | start_time = time.time() 534 | 535 | try: 536 | self.logger.info("Stopping Computer...") 537 | 538 | # In VM mode, first explicitly stop the VM, then exit the provider context 539 | if not self.use_host_computer_server and self._provider_context and self.config.vm_provider is not None: 540 | try: 541 | self.logger.info(f"Stopping VM {self.config.name}...") 542 | await self.config.vm_provider.stop_vm( 543 | name=self.config.name, 544 | storage=self.storage # Pass storage explicitly for clarity 545 | ) 546 | except Exception as e: 547 | self.logger.error(f"Error stopping VM: {e}") 548 | 549 | self.logger.verbose("Closing VM provider context...") 550 | await self.config.vm_provider.__aexit__(None, None, None) 551 | self._provider_context = None 552 | 553 | await self.disconnect() 554 | self.logger.info("Computer stopped") 555 | except Exception as e: 556 | self.logger.debug(f"Error during cleanup: {e}") # Log as debug since this might be expected 557 | finally: 558 | # Log stop time for performance monitoring 559 | duration_ms = (time.time() - start_time) * 1000 560 | self.logger.debug(f"Computer stop process took {duration_ms:.2f}ms") 561 | return 562 | 563 | async def start(self) -> None: 564 | """Start the computer.""" 565 | await self.run() 566 | 567 | async def restart(self) -> None: 568 | """Restart the computer. 569 | 570 | If using a VM provider that supports restart, this will issue a restart 571 | without tearing down the provider context, then reconnect the interface. 572 | Falls back to stop()+run() when a provider restart is not available. 573 | """ 574 | # Host computer server: just disconnect and run again 575 | if self.use_host_computer_server: 576 | try: 577 | await self.disconnect() 578 | finally: 579 | await self.run() 580 | return 581 | 582 | # If no VM provider context yet, fall back to full run 583 | if not getattr(self, "_provider_context", None) or self.config.vm_provider is None: 584 | self.logger.info("No provider context active; performing full restart via run()") 585 | await self.run() 586 | return 587 | 588 | # Gracefully close current interface connection if present 589 | if self._interface: 590 | try: 591 | self._interface.close() 592 | except Exception as e: 593 | self.logger.debug(f"Error closing interface prior to restart: {e}") 594 | 595 | # Attempt provider-level restart if implemented 596 | try: 597 | storage_param = "ephemeral" if self.ephemeral else self.storage 598 | if hasattr(self.config.vm_provider, "restart_vm"): 599 | self.logger.info(f"Restarting VM {self.config.name} via provider...") 600 | await self.config.vm_provider.restart_vm(name=self.config.name, storage=storage_param) 601 | else: 602 | # Fallback: stop then start without leaving provider context 603 | self.logger.info(f"Provider has no restart_vm; performing stop+start for {self.config.name}...") 604 | await self.config.vm_provider.stop_vm(name=self.config.name, storage=storage_param) 605 | await self.config.vm_provider.run_vm(image=self.image, name=self.config.name, run_opts={}, storage=storage_param) 606 | except Exception as e: 607 | self.logger.error(f"Failed to restart VM via provider: {e}") 608 | # As a last resort, do a full stop (with provider context exit) and run 609 | try: 610 | await self.stop() 611 | finally: 612 | await self.run() 613 | return 614 | 615 | # Wait for VM to be ready and reconnect interface 616 | try: 617 | self.logger.info("Waiting for VM to be ready after restart...") 618 | if self.provider_type == VMProviderType.LUMIER: 619 | max_retries = 60 620 | retry_delay = 3 621 | else: 622 | max_retries = 30 623 | retry_delay = 2 624 | ip_address = await self.get_ip(max_retries=max_retries, retry_delay=retry_delay) 625 | 626 | self.logger.info(f"Re-initializing interface for {self.os_type} at {ip_address}") 627 | from .interface.base import BaseComputerInterface 628 | 629 | if self.provider_type == VMProviderType.CLOUD and self.api_key and self.config.name: 630 | self._interface = cast( 631 | BaseComputerInterface, 632 | InterfaceFactory.create_interface_for_os( 633 | os=self.os_type, 634 | ip_address=ip_address, 635 | api_key=self.api_key, 636 | vm_name=self.config.name, 637 | ), 638 | ) 639 | else: 640 | self._interface = cast( 641 | BaseComputerInterface, 642 | InterfaceFactory.create_interface_for_os( 643 | os=self.os_type, 644 | ip_address=ip_address, 645 | ), 646 | ) 647 | 648 | self.logger.info("Connecting to WebSocket interface after restart...") 649 | await self._interface.wait_for_ready(timeout=30) 650 | self.logger.info("Computer reconnected and ready after restart") 651 | except Exception as e: 652 | self.logger.error(f"Failed to reconnect after restart: {e}") 653 | # Try a full reset if reconnection failed 654 | try: 655 | await self.stop() 656 | finally: 657 | await self.run() 658 | 659 | # @property 660 | async def get_ip(self, max_retries: int = 15, retry_delay: int = 3) -> str: 661 | """Get the IP address of the VM or localhost if using host computer server. 662 | 663 | This method delegates to the provider's get_ip method, which waits indefinitely 664 | until the VM has a valid IP address. 665 | 666 | Args: 667 | max_retries: Unused parameter, kept for backward compatibility 668 | retry_delay: Delay between retries in seconds (default: 2) 669 | 670 | Returns: 671 | IP address of the VM or localhost if using host computer server 672 | """ 673 | # For host computer server, always return localhost immediately 674 | if self.use_host_computer_server: 675 | return "127.0.0.1" 676 | 677 | # Get IP from the provider - each provider implements its own waiting logic 678 | if self.config.vm_provider is None: 679 | raise RuntimeError("VM provider is not initialized") 680 | 681 | # Log that we're waiting for the IP 682 | self.logger.info(f"Waiting for VM {self.config.name} to get an IP address...") 683 | 684 | # Call the provider's get_ip method which will wait indefinitely 685 | storage_param = "ephemeral" if self.ephemeral else self.storage 686 | 687 | # Log the image being used 688 | self.logger.info(f"Running VM using image: {self.image}") 689 | 690 | # Call provider.get_ip with explicit image parameter 691 | ip = await self.config.vm_provider.get_ip( 692 | name=self.config.name, 693 | storage=storage_param, 694 | retry_delay=retry_delay 695 | ) 696 | 697 | # Log success 698 | self.logger.info(f"VM {self.config.name} has IP address: {ip}") 699 | return ip 700 | 701 | 702 | async def wait_vm_ready(self) -> Optional[Dict[str, Any]]: 703 | """Wait for VM to be ready with an IP address. 704 | 705 | Returns: 706 | VM status information or None if using host computer server. 707 | """ 708 | if self.use_host_computer_server: 709 | return None 710 | 711 | timeout = 600 # 10 minutes timeout (increased from 4 minutes) 712 | interval = 2.0 # 2 seconds between checks (increased to reduce API load) 713 | start_time = time.time() 714 | last_status = None 715 | attempts = 0 716 | 717 | self.logger.info(f"Waiting for VM {self.config.name} to be ready (timeout: {timeout}s)...") 718 | 719 | while time.time() - start_time < timeout: 720 | attempts += 1 721 | elapsed = time.time() - start_time 722 | 723 | try: 724 | # Keep polling for VM info 725 | if self.config.vm_provider is None: 726 | self.logger.error("VM provider is not initialized") 727 | vm = None 728 | else: 729 | vm = await self.config.vm_provider.get_vm(self.config.name) 730 | 731 | # Log full VM properties for debugging (every 30 attempts) 732 | if attempts % 30 == 0: 733 | self.logger.info( 734 | f"VM properties at attempt {attempts}: {vars(vm) if vm else 'None'}" 735 | ) 736 | 737 | # Get current status for logging 738 | current_status = getattr(vm, "status", None) if vm else None 739 | if current_status != last_status: 740 | self.logger.info( 741 | f"VM status changed to: {current_status} (after {elapsed:.1f}s)" 742 | ) 743 | last_status = current_status 744 | 745 | # Check for IP address - ensure it's not None or empty 746 | ip = getattr(vm, "ip_address", None) if vm else None 747 | if ip and ip.strip(): # Check for non-empty string 748 | self.logger.info( 749 | f"VM {self.config.name} got IP address: {ip} (after {elapsed:.1f}s)" 750 | ) 751 | return vm 752 | 753 | if attempts % 10 == 0: # Log every 10 attempts to avoid flooding 754 | self.logger.info( 755 | f"Still waiting for VM IP address... (elapsed: {elapsed:.1f}s)" 756 | ) 757 | else: 758 | self.logger.debug( 759 | f"Waiting for VM IP address... Current IP: {ip}, Status: {current_status}" 760 | ) 761 | 762 | except Exception as e: 763 | self.logger.warning(f"Error checking VM status (attempt {attempts}): {str(e)}") 764 | # If we've been trying for a while and still getting errors, log more details 765 | if elapsed > 60: # After 1 minute of errors, log more details 766 | self.logger.error(f"Persistent error getting VM status: {str(e)}") 767 | self.logger.info("Trying to get VM list for debugging...") 768 | try: 769 | if self.config.vm_provider is not None: 770 | vms = await self.config.vm_provider.list_vms() 771 | self.logger.info( 772 | f"Available VMs: {[getattr(vm, 'name', None) for vm in vms if hasattr(vm, 'name')]}" 773 | ) 774 | except Exception as list_error: 775 | self.logger.error(f"Failed to list VMs: {str(list_error)}") 776 | 777 | await asyncio.sleep(interval) 778 | 779 | # If we get here, we've timed out 780 | elapsed = time.time() - start_time 781 | self.logger.error(f"VM {self.config.name} not ready after {elapsed:.1f} seconds") 782 | 783 | # Try to get final VM status for debugging 784 | try: 785 | if self.config.vm_provider is not None: 786 | vm = await self.config.vm_provider.get_vm(self.config.name) 787 | # VM data is returned as a dictionary from the Lumier provider 788 | status = vm.get('status', 'unknown') if vm else "unknown" 789 | ip = vm.get('ip_address') if vm else None 790 | else: 791 | status = "unknown" 792 | ip = None 793 | self.logger.error(f"Final VM status: {status}, IP: {ip}") 794 | except Exception as e: 795 | self.logger.error(f"Failed to get final VM status: {str(e)}") 796 | 797 | raise TimeoutError( 798 | f"VM {self.config.name} not ready after {elapsed:.1f} seconds - IP address not assigned" 799 | ) 800 | 801 | async def update(self, cpu: Optional[int] = None, memory: Optional[str] = None): 802 | """Update VM settings.""" 803 | self.logger.info( 804 | f"Updating VM settings: CPU={cpu or self.config.cpu}, Memory={memory or self.config.memory}" 805 | ) 806 | update_opts = { 807 | "cpu": cpu or int(self.config.cpu), 808 | "memory": memory or self.config.memory 809 | } 810 | if self.config.vm_provider is not None: 811 | await self.config.vm_provider.update_vm( 812 | name=self.config.name, 813 | update_opts=update_opts, 814 | storage=self.storage # Pass storage explicitly for clarity 815 | ) 816 | else: 817 | raise RuntimeError("VM provider not initialized") 818 | 819 | def get_screenshot_size(self, screenshot: bytes) -> Dict[str, int]: 820 | """Get the dimensions of a screenshot. 821 | 822 | Args: 823 | screenshot: The screenshot bytes 824 | 825 | Returns: 826 | Dict[str, int]: Dictionary containing 'width' and 'height' of the image 827 | """ 828 | image = Image.open(io.BytesIO(screenshot)) 829 | width, height = image.size 830 | return {"width": width, "height": height} 831 | 832 | @property 833 | def interface(self): 834 | """Get the computer interface for interacting with the VM. 835 | 836 | Returns: 837 | The computer interface 838 | """ 839 | if not hasattr(self, "_interface") or self._interface is None: 840 | error_msg = "Computer interface not initialized. Call run() first." 841 | self.logger.error(error_msg) 842 | self.logger.error( 843 | "Make sure to call await computer.run() before using any interface methods." 844 | ) 845 | raise RuntimeError(error_msg) 846 | 847 | return self._interface 848 | 849 | @property 850 | def telemetry_enabled(self) -> bool: 851 | """Check if telemetry is enabled for this computer instance. 852 | 853 | Returns: 854 | bool: True if telemetry is enabled, False otherwise 855 | """ 856 | return self._telemetry_enabled 857 | 858 | async def to_screen_coordinates(self, x: float, y: float) -> tuple[float, float]: 859 | """Convert normalized coordinates to screen coordinates. 860 | 861 | Args: 862 | x: X coordinate between 0 and 1 863 | y: Y coordinate between 0 and 1 864 | 865 | Returns: 866 | tuple[float, float]: Screen coordinates (x, y) 867 | """ 868 | return await self.interface.to_screen_coordinates(x, y) 869 | 870 | async def to_screenshot_coordinates(self, x: float, y: float) -> tuple[float, float]: 871 | """Convert screen coordinates to screenshot coordinates. 872 | 873 | Args: 874 | x: X coordinate in screen space 875 | y: Y coordinate in screen space 876 | 877 | Returns: 878 | tuple[float, float]: (x, y) coordinates in screenshot space 879 | """ 880 | return await self.interface.to_screenshot_coordinates(x, y) 881 | 882 | 883 | # Add virtual environment management functions to computer interface 884 | async def venv_install(self, venv_name: str, requirements: list[str]): 885 | """Install packages in a virtual environment. 886 | 887 | Args: 888 | venv_name: Name of the virtual environment 889 | requirements: List of package requirements to install 890 | 891 | Returns: 892 | Tuple of (stdout, stderr) from the installation command 893 | """ 894 | requirements = requirements or [] 895 | # Windows vs POSIX handling 896 | if self.os_type == "windows": 897 | # Use %USERPROFILE% for home directory and cmd.exe semantics 898 | venv_path = f"%USERPROFILE%\\.venvs\\{venv_name}" 899 | ensure_dir_cmd = "if not exist \"%USERPROFILE%\\.venvs\" mkdir \"%USERPROFILE%\\.venvs\"" 900 | create_cmd = f"if not exist \"{venv_path}\" python -m venv \"{venv_path}\"" 901 | requirements_str = " ".join(requirements) 902 | # Activate via activate.bat and install 903 | install_cmd = f"call \"{venv_path}\\Scripts\\activate.bat\" && pip install {requirements_str}" if requirements_str else f"echo No requirements to install" 904 | await self.interface.run_command(ensure_dir_cmd) 905 | await self.interface.run_command(create_cmd) 906 | return await self.interface.run_command(install_cmd) 907 | else: 908 | # POSIX (macOS/Linux) 909 | venv_path = f"$HOME/.venvs/{venv_name}" 910 | create_cmd = f"mkdir -p \"$HOME/.venvs\" && python3 -m venv \"{venv_path}\"" 911 | # Check if venv exists, if not create it 912 | check_cmd = f"test -d \"{venv_path}\" || ({create_cmd})" 913 | _ = await self.interface.run_command(check_cmd) 914 | # Install packages 915 | requirements_str = " ".join(requirements) 916 | install_cmd = ( 917 | f". \"{venv_path}/bin/activate\" && pip install {requirements_str}" 918 | if requirements_str 919 | else "echo No requirements to install" 920 | ) 921 | return await self.interface.run_command(install_cmd) 922 | 923 | async def venv_cmd(self, venv_name: str, command: str): 924 | """Execute a shell command in a virtual environment. 925 | 926 | Args: 927 | venv_name: Name of the virtual environment 928 | command: Shell command to execute in the virtual environment 929 | 930 | Returns: 931 | Tuple of (stdout, stderr) from the command execution 932 | """ 933 | if self.os_type == "windows": 934 | # Windows (cmd.exe) 935 | venv_path = f"%USERPROFILE%\\.venvs\\{venv_name}" 936 | # Check existence and signal if missing 937 | check_cmd = f"if not exist \"{venv_path}\" (echo VENV_NOT_FOUND) else (echo VENV_FOUND)" 938 | result = await self.interface.run_command(check_cmd) 939 | if "VENV_NOT_FOUND" in getattr(result, "stdout", ""): 940 | # Auto-create the venv with no requirements 941 | await self.venv_install(venv_name, []) 942 | # Activate and run the command 943 | full_command = f"call \"{venv_path}\\Scripts\\activate.bat\" && {command}" 944 | return await self.interface.run_command(full_command) 945 | else: 946 | # POSIX (macOS/Linux) 947 | venv_path = f"$HOME/.venvs/{venv_name}" 948 | # Check if virtual environment exists 949 | check_cmd = f"test -d \"{venv_path}\"" 950 | result = await self.interface.run_command(check_cmd) 951 | if result.stderr or "test:" in result.stdout: # venv doesn't exist 952 | # Auto-create the venv with no requirements 953 | await self.venv_install(venv_name, []) 954 | # Activate virtual environment and run command 955 | full_command = f". \"{venv_path}/bin/activate\" && {command}" 956 | return await self.interface.run_command(full_command) 957 | 958 | async def venv_exec(self, venv_name: str, python_func, *args, **kwargs): 959 | """Execute Python function in a virtual environment using source code extraction. 960 | 961 | Args: 962 | venv_name: Name of the virtual environment 963 | python_func: A callable function to execute 964 | *args: Positional arguments to pass to the function 965 | **kwargs: Keyword arguments to pass to the function 966 | 967 | Returns: 968 | The result of the function execution, or raises any exception that occurred 969 | """ 970 | import base64 971 | import inspect 972 | import json 973 | import textwrap 974 | 975 | try: 976 | # Get function source code using inspect.getsource 977 | source = inspect.getsource(python_func) 978 | # Remove common leading whitespace (dedent) 979 | func_source = textwrap.dedent(source).strip() 980 | 981 | # Remove decorators 982 | while func_source.lstrip().startswith("@"): 983 | func_source = func_source.split("\n", 1)[1].strip() 984 | 985 | # Get function name for execution 986 | func_name = python_func.__name__ 987 | 988 | # Serialize args and kwargs as JSON (safer than dill for cross-version compatibility) 989 | args_json = json.dumps(args, default=str) 990 | kwargs_json = json.dumps(kwargs, default=str) 991 | 992 | except OSError as e: 993 | raise Exception(f"Cannot retrieve source code for function {python_func.__name__}: {e}") 994 | except Exception as e: 995 | raise Exception(f"Failed to reconstruct function source: {e}") 996 | 997 | # Create Python code that will define and execute the function 998 | python_code = f''' 999 | import json 1000 | import traceback 1001 | 1002 | try: 1003 | # Define the function from source 1004 | {textwrap.indent(func_source, " ")} 1005 | 1006 | # Deserialize args and kwargs from JSON 1007 | args_json = """{args_json}""" 1008 | kwargs_json = """{kwargs_json}""" 1009 | args = json.loads(args_json) 1010 | kwargs = json.loads(kwargs_json) 1011 | 1012 | # Execute the function 1013 | result = {func_name}(*args, **kwargs) 1014 | 1015 | # Create success output payload 1016 | output_payload = {{ 1017 | "success": True, 1018 | "result": result, 1019 | "error": None 1020 | }} 1021 | 1022 | except Exception as e: 1023 | # Create error output payload 1024 | output_payload = {{ 1025 | "success": False, 1026 | "result": None, 1027 | "error": {{ 1028 | "type": type(e).__name__, 1029 | "message": str(e), 1030 | "traceback": traceback.format_exc() 1031 | }} 1032 | }} 1033 | 1034 | # Serialize the output payload as JSON 1035 | import json 1036 | output_json = json.dumps(output_payload, default=str) 1037 | 1038 | # Print the JSON output with markers 1039 | print(f"<<<VENV_EXEC_START>>>{{output_json}}<<<VENV_EXEC_END>>>") 1040 | ''' 1041 | 1042 | # Encode the Python code in base64 to avoid shell escaping issues 1043 | encoded_code = base64.b64encode(python_code.encode('utf-8')).decode('ascii') 1044 | 1045 | # Execute the Python code in the virtual environment 1046 | python_command = f"python -c \"import base64; exec(base64.b64decode('{encoded_code}').decode('utf-8'))\"" 1047 | result = await self.venv_cmd(venv_name, python_command) 1048 | 1049 | # Parse the output to extract the payload 1050 | start_marker = "<<<VENV_EXEC_START>>>" 1051 | end_marker = "<<<VENV_EXEC_END>>>" 1052 | 1053 | # Print original stdout 1054 | print(result.stdout[:result.stdout.find(start_marker)]) 1055 | 1056 | if start_marker in result.stdout and end_marker in result.stdout: 1057 | start_idx = result.stdout.find(start_marker) + len(start_marker) 1058 | end_idx = result.stdout.find(end_marker) 1059 | 1060 | if start_idx < end_idx: 1061 | output_json = result.stdout[start_idx:end_idx] 1062 | 1063 | try: 1064 | # Decode and deserialize the output payload from JSON 1065 | output_payload = json.loads(output_json) 1066 | except Exception as e: 1067 | raise Exception(f"Failed to decode output payload: {e}") 1068 | 1069 | if output_payload["success"]: 1070 | return output_payload["result"] 1071 | else: 1072 | # Recreate and raise the original exception 1073 | error_info = output_payload["error"] 1074 | error_class = eval(error_info["type"]) 1075 | raise error_class(error_info["message"]) 1076 | else: 1077 | raise Exception("Invalid output format: markers found but no content between them") 1078 | else: 1079 | # Fallback: return stdout/stderr if no payload markers found 1080 | raise Exception(f"No output payload found. stdout: {result.stdout}, stderr: {result.stderr}") 1081 | ```